Compare commits

..

96 Commits

Author SHA1 Message Date
Janez T
e502bc23b7 Update changelog to drop LPCNet 2026-03-14 21:35:57 +01:00
Janez T
7be1460ef6 Update changelog highlights 2026-03-14 21:32:41 +01:00
Janez T
30c273c656 Fix connection provider auto-add bug 2026-03-14 21:30:23 +01:00
Janez T
5712feba53 Reduce composer mention padding 2026-03-14 21:10:22 +01:00
Janez T
6de12225fc feat: Finish discovery flow
ref:
2026-03-14 21:04:23 +01:00
Janez T
6e9e9e397d Fix RX advert data parsing 2026-03-14 16:34:31 +01:00
Janez T
a3147e9d7c Summarize repo changes 2026-03-14 10:39:27 +01:00
Janez T
5e1912d9f2 Remove artificial UI delays 2026-03-14 10:24:50 +01:00
Janez T
b42328bd82 Ensure contact sync waits 2026-03-14 10:24:14 +01:00
Janez T
1b801a10ef Remove hex logs and keep path data 2026-03-14 10:07:11 +01:00
Janez T
06632ee32d Update log display handling 2026-03-14 10:01:17 +01:00
Janez T
e7a481f97b Warn channels for delayed log rx 2026-03-13 20:32:07 +01:00
Janez T
9f31f50e61 Warn channels about delayed message 2026-03-13 20:14:07 +01:00
Janez T
d4237b7569 Add channel echo warning tracking 2026-03-13 20:07:39 +01:00
Janez T
b35c11c804 fix: bump meshcore client
ref:
2026-03-13 20:02:13 +01:00
Janez T
be2cf53374 Investigate Command 0x1f timeout 2026-03-13 19:58:41 +01:00
Janez T
13a729492f fix: update meshcore client ref
ref:
2026-03-13 19:56:50 +01:00
Janez T
9d8594fe05 Fix channel message dedup logic 2026-03-13 19:39:26 +01:00
Janez T
249268dd00 Prevent duplicate channel messages 2026-03-13 19:39:03 +01:00
Janez T
63aa9619e6 Clear message input on send 2026-03-13 19:38:05 +01:00
Janez T
a7ad4bb8ec Clear message input after send 2026-03-13 19:36:20 +01:00
Janez T
636e114428 Fix channel syncing and logging 2026-03-13 19:27:14 +01:00
Janez T
d5684c51c7 Fix channel sync and logging 2026-03-13 19:24:42 +01:00
Janez T
ced39b6992 Fix channel sync errors 2026-03-13 19:15:41 +01:00
Janez T
083b715039 Add clearChannelMessages to Messages 2026-03-13 18:55:05 +01:00
Janez T
36584be0ab Fix channel deletion state 2026-03-13 18:49:54 +01:00
Janez T
dc19296804 Fix channel overwrite bug 2026-03-13 18:48:17 +01:00
Janez T
40072175e3 Prevent duplicate hash channels 2026-03-13 18:46:33 +01:00
Janez T
11f3bf9f97 Fix duplicate self messages 2026-03-13 18:44:17 +01:00
Janez T
24e715d01e Bump iOS project version 2026-03-13 14:18:27 +01:00
Janez T
6e17de3866 Add profile storage and sync 2026-03-13 10:34:52 +01:00
Janez T
90877a1891 Add bidirectional refresh physics 2026-03-13 10:31:37 +01:00
Janez T
77e43843bf Update contacts tab widgets 2026-03-13 10:25:23 +01:00
Janez T
1e70f52069 Allow overriding contact name 2026-03-13 10:16:04 +01:00
Janez T
5e404944e9 Investigate REQ PATH usage 2026-03-12 20:56:37 +01:00
Janez T
53daf2c9a7 Add packet filter stats and hex 2026-03-12 20:54:04 +01:00
Janez T
c36d63a0ad Add Flutter live traffic monitor 2026-03-12 20:19:24 +01:00
Janez T
69ec2dfdb5 Add live mesh traffic view 2026-03-12 20:12:25 +01:00
Janez T
d2e6b692f3 Fix voice quality regression 2026-03-12 19:38:41 +01:00
Janez T
baa1aa6edd Update contacts filter UI 2026-03-12 14:46:36 +01:00
Janez T
38cb3dc031 Fix retry flow and contact filters 2026-03-12 14:41:12 +01:00
Janez T
abb73a4c65 Fix retry flow and contact filters 2026-03-12 14:39:44 +01:00
Janez T
2d1e6c0ca4 Fix tracing path and display issues 2026-03-12 14:08:00 +01:00
Janez T
b38a8c55d6 Switch lpcnet plugin to git 2026-03-11 21:20:53 +01:00
Janez T
ca872f2049 Add LPCNet voice mode support 2026-03-11 21:16:08 +01:00
Janez T
1b6ff59af3 Add LPCNet voice mode option 2026-03-11 20:47:17 +01:00
Janez T
e31bfac2ac Retain rx path for adverts 2026-03-11 20:21:17 +01:00
Janez T
99bb446ee9 Update iOS Runner project version 2026-03-11 16:31:53 +01:00
Janez T
088f3d37ef Update Portuguese wizard text 2026-03-11 16:07:38 +01:00
Janez T
7f200e5334 Update translations for all locales 2026-03-11 16:07:09 +01:00
Janez T
ae16417886 Resolve localization and name bugs 2026-03-11 15:54:00 +01:00
Janez T
467371946d Add channel slot detection fix 2026-03-11 15:30:31 +01:00
Janez T
2d23328ad0 Fix empty channel slot handling 2026-03-11 15:10:57 +01:00
Janez T
0dfb84bf4e Fix onboarding layout issues 2026-03-11 15:08:04 +01:00
Janez T
193a193e8a Ensure constant iOS location updates 2026-03-10 19:56:24 +01:00
Janez T
c1d1ec7d0c Enable iOS background GPS updates 2026-03-10 19:50:02 +01:00
Janez T
b415b63b4b Add GPX import export for trails 2026-03-10 13:52:37 +01:00
Janez T
818b9dbfb2 Review MeshCore payload status 2026-03-10 11:05:54 +01:00
Janez T
8085b89c07 Add repeaters map menu entry 2026-03-10 10:38:59 +01:00
Janez T
86771a3c55 Add fullscreen repeater map 2026-03-10 10:25:43 +01:00
Janez T
4b668ac2e9 Fix channel list layout behavior 2026-03-10 09:46:38 +01:00
Janez T
9deb563c05 Align and compact channel cards 2026-03-10 09:45:16 +01:00
Janez T
15ac3e2798 Align channel avatars and compact 2026-03-10 09:13:24 +01:00
Janez T
97addf7ba6 Hide last seen and tweak channels 2026-03-10 09:06:30 +01:00
Janez T
025bd737e7 Group contacts by last seen 2026-03-10 08:54:55 +01:00
Janez T
07b675dec6 Align repeater tile behavior 2026-03-10 08:48:04 +01:00
Janez T
d675ce18dd Use LogRxRouteDecoder in AppProvider 2026-03-10 08:43:58 +01:00
Janez T
a8e31bb2e8 Investigate DEVICE_QUERY timeout 2026-03-09 19:59:29 +01:00
Janez T
3cd96ba961 Fix scan command timeout 2026-03-09 19:53:36 +01:00
Janez T
a98fc6314e Analyze BLE MeshCore scan log 2026-03-09 19:45:17 +01:00
Janez T
ae1ea71f58 Analyze MeshCore BLE scan logs 2026-03-09 19:39:14 +01:00
Janez T
4dff79cd7b Update client ref and deps 2026-03-09 19:33:42 +01:00
Janez T
cbcb329c70 Fix NotificationService platform 2026-03-09 19:23:35 +01:00
Janez T
7abf96abf2 Point web build to meshcore-sar 2026-03-09 19:17:26 +01:00
Janez T
13bf74178c Update iOS TestFlight link 2026-03-09 16:31:42 +01:00
Janez T
66a00c9569 Remove sender name from notification 2026-03-08 21:12:08 +01:00
Janez T
b60fa008eb Fix ios notifications 2026-03-08 21:07:59 +01:00
Janez T
d113f7a881 Match sensors bg and hide counts 2026-03-08 21:03:44 +01:00
Janez T
2ab8681ac4 Add multilingual localization 2026-03-08 20:50:53 +01:00
Janez T
de5fb1e164 Translate missing localization 2026-03-08 20:46:09 +01:00
Janez T
dae7e67a8a Translate missing strings 2026-03-08 20:42:21 +01:00
Janez T
d51924021f Update German localization 2026-03-08 20:38:41 +01:00
Janez T
0f91292fd7 Update messaging options and UI 2026-03-08 20:34:38 +01:00
Janez T
b471ab0990 Add message font scale setting 2026-03-08 20:29:55 +01:00
Janez T
771cddb80d Display unread counts per channel 2026-03-08 20:22:10 +01:00
Janez T
bfd15065f0 Add message filters and counts 2026-03-08 20:15:48 +01:00
Janez T
f72db76538 Add reply shortcut for messages 2026-03-08 20:10:51 +01:00
Janez T
a96f96ca4c Add nearestrelay fallback settings 2026-03-08 20:05:24 +01:00
Janez T
9f62055eef Enable route hash settings 2026-03-08 17:00:54 +01:00
Janez T
6b2f18130c Add nearest relay fallback toggle 2026-03-08 16:56:57 +01:00
Janez T
922b40b755 Implement meshcore route rotation 2026-03-08 16:46:46 +01:00
Janez T
17d7c43745 Implement meshcore-open route reload 2026-03-08 14:26:05 +01:00
Janez T
e026c1dbbd Fix invalid simulator slice upload 2026-03-08 09:42:07 +01:00
Janez T
9ff24c1fcb Fix invalid objective_c framework 2026-03-08 09:40:10 +01:00
Janez Troha
948a22c4d2 Merge pull request #16 from MGJ520/main
Update Chinese translation
2026-03-08 09:33:25 +01:00
MGJ
aa0777d4a3 Update Chinese translation 2026-03-08 12:39:57 +08:00
141 changed files with 44338 additions and 23978 deletions

View File

@@ -280,7 +280,7 @@ jobs:
run: flutter pub get
- name: Build web release
run: flutter build web --release --base-href /meshcore_sar_app/
run: flutter build web --release --base-href /meshcore-sar/
- name: Upload pages artifact
uses: actions/upload-pages-artifact@v3

3
.gitignore vendored
View File

@@ -80,10 +80,11 @@ create_feature_graphic.py
# Tool caches and local state
.cachebro/
.osgrep/
third_party/lpcnet_flutter/
# Claude Code local settings (permissions, personal config)
.claude/settings.local.json
# Dart code coverage
coverage/
lcov.info
lcov.info

13
CHANGELOG.md Normal file
View File

@@ -0,0 +1,13 @@
# Changelog
## 2026-03-08 to 2026-03-14
- Discovery and contacts: finished the discovery flow, removed artificial UI delays, added profile storage and sync, and allowed contact name overrides.
- Messaging and channels: fixed channel sync, deduplication, overwrite and deletion issues, cleared message input after send, and added delayed-message warning tracking.
- Voice and transport: improved voice playback quality, improved scan and advert parsing, and kept route path data visible in logs.
- Maps and field operations: enabled iOS background GPS updates, added GPX trail import/export, introduced a fullscreen repeater map, and added live mesh traffic views.
- Localization and UI polish: added multilingual localization coverage, updated locale strings, fixed onboarding layout issues, and refined channel and contacts presentation.
## Key PRs
- [#16](https://github.com/dz0ny/meshcore-sar/pull/16) Merge pull request #16 from `MGJ520/main`

View File

@@ -16,7 +16,7 @@
MeshCore SAR helps teams coordinate in low-connectivity or no-connectivity environments with messaging, voice, images, maps, and live location context in one app.
It uses the MeshCore protocol over LoRa for long-range, infrastructure-free communication.
`iOS TestFlight:` https://testflight.apple.com/join/HhzerdHp
`iOS TestFlight:` https://testflight.apple.com/join/nCZYMPPz
## Highlights

View File

@@ -61,6 +61,9 @@ PODS:
- Flutter
- package_info_plus (0.4.5):
- Flutter
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- permission_handler_apple (9.3.0):
- Flutter
- record_ios (1.2.0):
@@ -97,6 +100,7 @@ DEPENDENCIES:
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- nsd_ios (from `.symlinks/plugins/nsd_ios/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- record_ios (from `.symlinks/plugins/record_ios/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
@@ -141,6 +145,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/nsd_ios/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
record_ios:
@@ -173,6 +179,7 @@ SPEC CHECKSUMS:
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
nsd_ios: 596ad79109ddd3e52d665f650f36428049e3653e
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
record_ios: 412daca2350b228e698fffcd08f1f94ceb1e3844
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf

View File

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

View File

@@ -1,5 +1,6 @@
import UIKit
import Flutter
import UserNotifications
@main
@objc class AppDelegate: FlutterAppDelegate {
@@ -8,6 +9,7 @@ import Flutter
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}

View File

@@ -43,7 +43,7 @@
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>105</string>
<string>115</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>
@@ -102,6 +102,7 @@
<key>UIBackgroundModes</key>
<array>
<string>location</string>
<string>bluetooth-central</string>
<string>bluetooth-peripheral</string>
</array>
<key>UILaunchStoryboardName</key>

View File

@@ -5,17 +5,22 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.001342">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000235">
</testcase>
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.40706">
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.385323">
</testcase>
<testcase classname="fastlane.lanes" name="2: build_app" time="70.83361">
<testcase classname="fastlane.lanes" name="2: build_app" time="93.530304">
</testcase>
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="210.562484">
</testcase>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

2332
lib/l10n/app_pl.arb Normal file

File diff suppressed because it is too large Load Diff

2332
lib/l10n/app_pt.arb Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

2332
lib/l10n/app_tr.arb Normal file

File diff suppressed because it is too large Load Diff

2332
lib/l10n/app_uk.arb Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,7 @@
import 'dart:io' show Platform;
import 'dart:async';
import 'package:flutter/foundation.dart'
show TargetPlatform, defaultTargetPlatform, kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:provider/provider.dart';
@@ -19,8 +22,10 @@ import 'services/voice_codec_service.dart';
import 'services/voice_player_service.dart';
import 'services/notification_service.dart';
import 'services/locale_preferences.dart';
import 'services/mesh_map_nodes_service.dart';
import 'services/update_checker_service.dart';
import 'services/wizard_preferences.dart';
import 'screens/discovery_screen.dart';
import 'screens/home_screen.dart';
import 'screens/welcome_wizard_screen.dart';
import 'theme/app_theme.dart';
@@ -38,11 +43,13 @@ class MeshCoreSarApp extends StatefulWidget {
}
class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey<NavigatorState>();
AppThemeMode _themeMode = AppThemeMode.system;
Locale? _locale;
bool _isInitialized = false;
bool _shouldShowPermissionDialog = false;
bool _wizardCompleted = true; // Will be updated in _initializeApp()
String? _pendingNotificationPayload;
@override
void initState() {
@@ -62,6 +69,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
// Set up notification tap handler for update notifications
NotificationService().onNotificationTapped = _handleNotificationTap;
_pendingNotificationPayload = NotificationService().consumeLaunchPayload();
// Check if we need to request location permissions
await _checkLocationPermissions();
@@ -70,10 +78,21 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
// Shows notification if update is available
_checkForUpdates();
// Refresh remote mesh node cache in background when the app starts.
unawaited(MeshMapNodesService.syncInBackgroundIfStale());
setState(() {
_wizardCompleted = wizardCompleted;
_isInitialized = true;
});
if (_pendingNotificationPayload != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
final payload = _pendingNotificationPayload;
_pendingNotificationPayload = null;
_handleNotificationTap(payload);
});
}
}
/// Handle notification tap
@@ -82,10 +101,27 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
debugPrint('[Main] Notification tapped: $payload');
if (!_wizardCompleted) {
_pendingNotificationPayload = payload;
return;
}
// Handle update notification tap
if (payload.startsWith('update:')) {
final downloadUrl = payload.substring(7); // Remove 'update:' prefix
_launchUpdateDownload(downloadUrl);
return;
}
if (payload.startsWith('discovery:')) {
final navigator = _navigatorKey.currentState;
if (navigator == null) {
_pendingNotificationPayload = payload;
return;
}
navigator.push(
MaterialPageRoute(builder: (context) => const DiscoveryScreen()),
);
}
// SAR and message notifications handled by their respective providers
}
@@ -158,7 +194,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
/// Shows notification if update is available
Future<void> _checkForUpdates() async {
// Only check for updates on Android
if (!Platform.isAndroid) {
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
debugPrint('[UpdateChecker] Skipping update check (not Android)');
return;
}
@@ -297,6 +333,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
builder: (context) {
final systemBrightness = MediaQuery.platformBrightnessOf(context);
final materialApp = MaterialApp(
navigatorKey: _navigatorKey,
key: ValueKey<String?>(
'${_locale?.languageCode ?? 'system'}_${_themeMode.name}',
),
@@ -324,7 +361,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
// Wrap in SafeArea for Android only to fix navigation bar overlap (API ≥36)
// iOS doesn't need SafeArea wrapping (causes extra black space at bottom)
if (Platform.isAndroid) {
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
return SafeArea(
left: false,
right: false,

View File

@@ -232,7 +232,7 @@ extension ContactLocalization on Contact {
String get routeSummary {
if (routeIsUnknown) {
return 'Unknown';
return 'Flood/Unknown';
}
if (!routeHasPath || routeHopCount == 0) {
return 'Direct';
@@ -240,6 +240,5 @@ extension ContactLocalization on Contact {
return '$routeHopCount hop${routeHopCount == 1 ? '' : 's'} via $routeHashSize-byte hashes';
}
bool get routeSupportsLegacyRawTransport =>
routeHasPath && routeSignedPathLen >= 0;
bool get routeSupportsLegacyRawTransport => routeHasPath;
}

View File

@@ -0,0 +1,39 @@
class SavedContactGroup {
final String id;
final String sectionKey;
final String label;
final String query;
final DateTime createdAt;
final List<String>? matchPrefixes;
final bool isAutoGroup;
const SavedContactGroup({
required this.id,
required this.sectionKey,
required this.label,
required this.query,
required this.createdAt,
this.matchPrefixes,
this.isAutoGroup = false,
});
SavedContactGroup copyWith({
String? id,
String? sectionKey,
String? label,
String? query,
DateTime? createdAt,
List<String>? matchPrefixes,
bool? isAutoGroup,
}) {
return SavedContactGroup(
id: id ?? this.id,
sectionKey: sectionKey ?? this.sectionKey,
label: label ?? this.label,
query: query ?? this.query,
createdAt: createdAt ?? this.createdAt,
matchPrefixes: matchPrefixes ?? this.matchPrefixes,
isAutoGroup: isAutoGroup ?? this.isAutoGroup,
);
}
}

View File

@@ -66,6 +66,11 @@ class DeviceInfo {
final int? advLat;
final int? advLon;
final bool? manualAddContacts;
final bool? autoAddUsers;
final bool? autoAddRepeaters;
final bool? autoAddRoomServers;
final bool? autoAddSensors;
final bool? autoAddOverwriteOldest;
final int? radioFreq;
final int? radioBw;
final int? radioSf;
@@ -113,6 +118,11 @@ class DeviceInfo {
this.advLat,
this.advLon,
this.manualAddContacts,
this.autoAddUsers,
this.autoAddRepeaters,
this.autoAddRoomServers,
this.autoAddSensors,
this.autoAddOverwriteOldest,
this.radioFreq,
this.radioBw,
this.radioSf,
@@ -239,6 +249,11 @@ class DeviceInfo {
int? advLat,
int? advLon,
bool? manualAddContacts,
bool? autoAddUsers,
bool? autoAddRepeaters,
bool? autoAddRoomServers,
bool? autoAddSensors,
bool? autoAddOverwriteOldest,
int? radioFreq,
int? radioBw,
int? radioSf,
@@ -278,6 +293,12 @@ class DeviceInfo {
advLat: advLat ?? this.advLat,
advLon: advLon ?? this.advLon,
manualAddContacts: manualAddContacts ?? this.manualAddContacts,
autoAddUsers: autoAddUsers ?? this.autoAddUsers,
autoAddRepeaters: autoAddRepeaters ?? this.autoAddRepeaters,
autoAddRoomServers: autoAddRoomServers ?? this.autoAddRoomServers,
autoAddSensors: autoAddSensors ?? this.autoAddSensors,
autoAddOverwriteOldest:
autoAddOverwriteOldest ?? this.autoAddOverwriteOldest,
radioFreq: radioFreq ?? this.radioFreq,
radioBw: radioBw ?? this.radioBw,
radioSf: radioSf ?? this.radioSf,

View File

@@ -30,6 +30,7 @@ class MessageReceptionDetails {
final int? senderToReceiptMs;
final int? estimatedTransmitMs;
final int? postTransmitDelayMs;
final int receivedCopies;
const MessageReceptionDetails({
required this.capturedAt,
@@ -40,6 +41,7 @@ class MessageReceptionDetails {
this.senderToReceiptMs,
this.estimatedTransmitMs,
this.postTransmitDelayMs,
this.receivedCopies = 1,
});
String? get pathBytesHex => pathBytes == null || pathBytes!.isEmpty
@@ -56,9 +58,60 @@ class MessageReceptionDetails {
'senderToReceiptMs': senderToReceiptMs,
'estimatedTransmitMs': estimatedTransmitMs,
'postTransmitDelayMs': postTransmitDelayMs,
'receivedCopies': receivedCopies,
};
}
MessageReceptionDetails copyWith({
DateTime? capturedAt,
DateTime? packetLoggedAt,
int? rssiDbm,
double? snrDb,
List<int>? pathBytes,
int? senderToReceiptMs,
int? estimatedTransmitMs,
int? postTransmitDelayMs,
int? receivedCopies,
}) {
return MessageReceptionDetails(
capturedAt: capturedAt ?? this.capturedAt,
packetLoggedAt: packetLoggedAt ?? this.packetLoggedAt,
rssiDbm: rssiDbm ?? this.rssiDbm,
snrDb: snrDb ?? this.snrDb,
pathBytes: pathBytes ?? this.pathBytes,
senderToReceiptMs: senderToReceiptMs ?? this.senderToReceiptMs,
estimatedTransmitMs: estimatedTransmitMs ?? this.estimatedTransmitMs,
postTransmitDelayMs: postTransmitDelayMs ?? this.postTransmitDelayMs,
receivedCopies: receivedCopies ?? this.receivedCopies,
);
}
static MessageReceptionDetails mergeDuplicate({
MessageReceptionDetails? existing,
MessageReceptionDetails? incoming,
}) {
final base =
existing ??
incoming ??
MessageReceptionDetails(capturedAt: DateTime.now());
return base.copyWith(
capturedAt: incoming?.capturedAt ?? existing?.capturedAt,
packetLoggedAt: incoming?.packetLoggedAt ?? existing?.packetLoggedAt,
rssiDbm: incoming?.rssiDbm ?? existing?.rssiDbm,
snrDb: incoming?.snrDb ?? existing?.snrDb,
pathBytes: incoming?.pathBytes ?? existing?.pathBytes,
senderToReceiptMs:
incoming?.senderToReceiptMs ?? existing?.senderToReceiptMs,
estimatedTransmitMs:
incoming?.estimatedTransmitMs ?? existing?.estimatedTransmitMs,
postTransmitDelayMs:
incoming?.postTransmitDelayMs ?? existing?.postTransmitDelayMs,
receivedCopies:
(existing?.receivedCopies ?? 1) + (incoming?.receivedCopies ?? 1),
);
}
static MessageReceptionDetails? fromJson(Map<String, dynamic> json) {
final capturedAtMillis = json['capturedAtMillis'];
if (capturedAtMillis is! int) {
@@ -81,6 +134,7 @@ class MessageReceptionDetails {
senderToReceiptMs: json['senderToReceiptMs'] as int?,
estimatedTransmitMs: json['estimatedTransmitMs'] as int?,
postTransmitDelayMs: json['postTransmitDelayMs'] as int?,
receivedCopies: json['receivedCopies'] as int? ?? 1,
);
}
}

View File

@@ -0,0 +1,79 @@
import 'path_selection.dart';
class MessageRouteMetadata {
final PathSelectionMode mode;
final bool routerFallbackAttempted;
final String? relayName;
final String? relayKey6;
final String? canonicalPath;
final int? hopCount;
const MessageRouteMetadata({
required this.mode,
required this.routerFallbackAttempted,
this.relayName,
this.relayKey6,
this.canonicalPath,
this.hopCount,
});
factory MessageRouteMetadata.fromSelection(
PathSelection selection, {
required bool routerFallbackAttempted,
}) {
return MessageRouteMetadata(
mode: selection.mode,
routerFallbackAttempted: routerFallbackAttempted,
relayName: selection.relayName,
relayKey6: selection.relayKey6,
canonicalPath: selection.canonicalPath.isEmpty
? null
: selection.canonicalPath,
hopCount: selection.hopCount > 0 ? selection.hopCount : null,
);
}
String get modeLabel {
switch (mode) {
case PathSelectionMode.directCurrent:
return 'Current direct path';
case PathSelectionMode.directHistorical:
return 'Rotated direct path';
case PathSelectionMode.flood:
return 'Flood route';
case PathSelectionMode.nearestRouter:
final suffix = relayName?.trim().isNotEmpty == true
? ' via $relayName'
: relayKey6?.trim().isNotEmpty == true
? ' via $relayKey6'
: '';
return 'Nearest router$suffix';
}
}
Map<String, dynamic> toJson() {
return {
'mode': mode.name,
'router_fallback_attempted': routerFallbackAttempted,
'relay_name': relayName,
'relay_key6': relayKey6,
'canonical_path': canonicalPath,
'hop_count': hopCount,
};
}
factory MessageRouteMetadata.fromJson(Map<String, dynamic> json) {
return MessageRouteMetadata(
mode: PathSelectionMode.values.firstWhere(
(value) => value.name == json['mode'],
orElse: () => PathSelectionMode.directCurrent,
),
routerFallbackAttempted:
json['router_fallback_attempted'] as bool? ?? false,
relayName: json['relay_name'] as String?,
relayKey6: json['relay_key6'] as String?,
canonicalPath: json['canonical_path'] as String?,
hopCount: json['hop_count'] as int?,
);
}
}

View File

@@ -0,0 +1,197 @@
enum PathRecordSource { learned, observed }
class PathRecord {
final List<int> pathBytes;
final int hopCount;
final int hashSize;
final PathRecordSource source;
final int successCount;
final int failureCount;
final int lastRoundTripTimeMs;
final DateTime lastUsedAt;
const PathRecord({
required this.pathBytes,
required this.hopCount,
required this.hashSize,
required this.source,
required this.successCount,
required this.failureCount,
required this.lastRoundTripTimeMs,
required this.lastUsedAt,
});
String get signature =>
pathBytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
double get successRate =>
(successCount + 1) / (successCount + failureCount + 2);
PathRecord copyWith({
List<int>? pathBytes,
int? hopCount,
int? hashSize,
PathRecordSource? source,
int? successCount,
int? failureCount,
int? lastRoundTripTimeMs,
DateTime? lastUsedAt,
}) {
return PathRecord(
pathBytes: pathBytes ?? this.pathBytes,
hopCount: hopCount ?? this.hopCount,
hashSize: hashSize ?? this.hashSize,
source: source ?? this.source,
successCount: successCount ?? this.successCount,
failureCount: failureCount ?? this.failureCount,
lastRoundTripTimeMs: lastRoundTripTimeMs ?? this.lastRoundTripTimeMs,
lastUsedAt: lastUsedAt ?? this.lastUsedAt,
);
}
Map<String, dynamic> toJson() {
return {
'path_bytes': pathBytes,
'hop_count': hopCount,
'hash_size': hashSize,
'source': source.name,
'success_count': successCount,
'failure_count': failureCount,
'last_round_trip_time_ms': lastRoundTripTimeMs,
'last_used_at': lastUsedAt.toIso8601String(),
};
}
factory PathRecord.fromJson(Map<String, dynamic> json) {
return PathRecord(
pathBytes: (json['path_bytes'] as List<dynamic>? ?? const <dynamic>[])
.map((value) => value as int)
.toList(),
hopCount: json['hop_count'] as int? ?? 0,
hashSize: json['hash_size'] as int? ?? 1,
source: PathRecordSource.values.firstWhere(
(value) => value.name == (json['source'] as String? ?? 'learned'),
orElse: () => PathRecordSource.learned,
),
successCount: json['success_count'] as int? ?? 0,
failureCount: json['failure_count'] as int? ?? 0,
lastRoundTripTimeMs: json['last_round_trip_time_ms'] as int? ?? 0,
lastUsedAt:
DateTime.tryParse(json['last_used_at'] as String? ?? '') ??
DateTime.fromMillisecondsSinceEpoch(0),
);
}
}
class FloodPathStats {
final int successCount;
final int failureCount;
final int lastRoundTripTimeMs;
final DateTime? lastUsedAt;
const FloodPathStats({
required this.successCount,
required this.failureCount,
required this.lastRoundTripTimeMs,
required this.lastUsedAt,
});
const FloodPathStats.empty()
: successCount = 0,
failureCount = 0,
lastRoundTripTimeMs = 0,
lastUsedAt = null;
FloodPathStats copyWith({
int? successCount,
int? failureCount,
int? lastRoundTripTimeMs,
DateTime? lastUsedAt,
}) {
return FloodPathStats(
successCount: successCount ?? this.successCount,
failureCount: failureCount ?? this.failureCount,
lastRoundTripTimeMs: lastRoundTripTimeMs ?? this.lastRoundTripTimeMs,
lastUsedAt: lastUsedAt ?? this.lastUsedAt,
);
}
Map<String, dynamic> toJson() {
return {
'success_count': successCount,
'failure_count': failureCount,
'last_round_trip_time_ms': lastRoundTripTimeMs,
'last_used_at': lastUsedAt?.toIso8601String(),
};
}
factory FloodPathStats.fromJson(Map<String, dynamic> json) {
return FloodPathStats(
successCount: json['success_count'] as int? ?? 0,
failureCount: json['failure_count'] as int? ?? 0,
lastRoundTripTimeMs: json['last_round_trip_time_ms'] as int? ?? 0,
lastUsedAt: DateTime.tryParse(json['last_used_at'] as String? ?? ''),
);
}
}
class ContactPathHistory {
final String contactPublicKeyHex;
final List<PathRecord> directPaths;
final FloodPathStats floodStats;
final int rotationIndex;
const ContactPathHistory({
required this.contactPublicKeyHex,
required this.directPaths,
required this.floodStats,
required this.rotationIndex,
});
const ContactPathHistory.empty(this.contactPublicKeyHex)
: directPaths = const <PathRecord>[],
floodStats = const FloodPathStats.empty(),
rotationIndex = 0;
ContactPathHistory copyWith({
List<PathRecord>? directPaths,
FloodPathStats? floodStats,
int? rotationIndex,
}) {
return ContactPathHistory(
contactPublicKeyHex: contactPublicKeyHex,
directPaths: directPaths ?? this.directPaths,
floodStats: floodStats ?? this.floodStats,
rotationIndex: rotationIndex ?? this.rotationIndex,
);
}
Map<String, dynamic> toJson() {
return {
'direct_paths': directPaths.map((record) => record.toJson()).toList(),
'flood_stats': floodStats.toJson(),
'rotation_index': rotationIndex,
};
}
List<PathRecord> get observedPaths => directPaths
.where((record) => record.source == PathRecordSource.observed)
.toList();
factory ContactPathHistory.fromJson(
String contactPublicKeyHex,
Map<String, dynamic> json,
) {
return ContactPathHistory(
contactPublicKeyHex: contactPublicKeyHex,
directPaths: (json['direct_paths'] as List<dynamic>? ?? const <dynamic>[])
.whereType<Map<String, dynamic>>()
.map(PathRecord.fromJson)
.toList(),
floodStats: json['flood_stats'] is Map<String, dynamic>
? FloodPathStats.fromJson(json['flood_stats'] as Map<String, dynamic>)
: const FloodPathStats.empty(),
rotationIndex: json['rotation_index'] as int? ?? 0,
);
}
}

View File

@@ -0,0 +1,65 @@
import 'dart:typed_data';
enum PathSelectionMode { directCurrent, directHistorical, flood, nearestRouter }
class PathSelection {
final PathSelectionMode mode;
final Uint8List pathBytes;
final int hopCount;
final int hashSize;
final String? relayName;
final String? relayKey6;
const PathSelection({
required this.mode,
required this.pathBytes,
required this.hopCount,
required this.hashSize,
this.relayName,
this.relayKey6,
});
PathSelection.flood()
: mode = PathSelectionMode.flood,
pathBytes = Uint8List(0),
hopCount = -1,
hashSize = 1,
relayName = null,
relayKey6 = null;
bool get usesFlood => mode == PathSelectionMode.flood;
bool get hasDirectPath => !usesFlood && pathBytes.isNotEmpty && hopCount > 0;
String get canonicalPath {
if (!hasDirectPath) return '';
final hops = <String>[];
for (var index = 0; index < pathBytes.length; index += hashSize) {
hops.add(
pathBytes
.sublist(index, index + hashSize)
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join()
.toUpperCase(),
);
}
return hops.join(',');
}
PathSelection copyWith({
PathSelectionMode? mode,
Uint8List? pathBytes,
int? hopCount,
int? hashSize,
String? relayName,
String? relayKey6,
}) {
return PathSelection(
mode: mode ?? this.mode,
pathBytes: pathBytes ?? this.pathBytes,
hopCount: hopCount ?? this.hopCount,
hashSize: hashSize ?? this.hashSize,
relayName: relayName ?? this.relayName,
relayKey6: relayKey6 ?? this.relayKey6,
);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,8 @@ enum DrawingMode { none, line, rectangle, measure }
/// Provider for managing map drawings
class DrawingProvider with ChangeNotifier {
static const String _storageKey = 'map_drawings';
static const String _showReceivedDrawingsKey = 'map_show_received_drawings';
static const String _showSarMarkersKey = 'map_show_sar_markers';
// Drawing state
DrawingMode _drawingMode = DrawingMode.none;
@@ -20,6 +22,7 @@ class DrawingProvider with ChangeNotifier {
// Completed drawings
final List<MapDrawing> _drawings = [];
bool _isInitialized = false;
// In-progress drawing
MapDrawing? _currentDrawing;
@@ -51,13 +54,16 @@ class DrawingProvider with ChangeNotifier {
List<LatLng> get currentLinePoints => List.unmodifiable(_currentLinePoints);
LatLng? get rectangleStartPoint => _rectangleStartPoint;
bool get isDrawing => _drawingMode != DrawingMode.none;
bool get isInitialized => _isInitialized;
LatLng? get measurementPoint1 => _measurementPoint1;
LatLng? get measurementPoint2 => _measurementPoint2;
double? get measuredDistance => _measuredDistance;
/// Initialize and load saved drawings
Future<void> initialize() async {
await _loadPreferences();
await _loadDrawings();
_isInitialized = true;
}
/// Set drawing mode
@@ -77,15 +83,30 @@ class DrawingProvider with ChangeNotifier {
}
/// Toggle visibility of received drawings
void toggleReceivedDrawings() {
Future<void> toggleReceivedDrawings() async {
_showReceivedDrawings = !_showReceivedDrawings;
notifyListeners();
await _savePreferences();
}
/// Toggle visibility of SAR markers
void toggleSarMarkers() {
Future<void> toggleSarMarkers() async {
_showSarMarkers = !_showSarMarkers;
notifyListeners();
await _savePreferences();
}
Future<void> _loadPreferences() async {
final prefs = await SharedPreferences.getInstance();
_showReceivedDrawings = prefs.getBool(_showReceivedDrawingsKey) ?? true;
_showSarMarkers = prefs.getBool(_showSarMarkersKey) ?? true;
notifyListeners();
}
Future<void> _savePreferences() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_showReceivedDrawingsKey, _showReceivedDrawings);
await prefs.setBool(_showSarMarkersKey, _showSarMarkers);
}
/// Start drawing a line

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:typed_data';
/// Message delivery tracking helper
@@ -30,6 +31,9 @@ class MessageDeliveryTracker {
/// Map of ACK tag to timestamp for timeout cleanup
final Map<int, DateTime> _ackTagTimestamps = {};
/// Completer signalled when a pending ACK slot is freed (delivery or removal).
Completer<void>? _slotFreedCompleter;
/// Track a pending message ID before sending
///
/// This is called BEFORE sending the message. When RESP_CODE_SENT
@@ -112,6 +116,7 @@ class MessageDeliveryTracker {
_messageIdToAckTag.remove(messageId);
}
_ackTagTimestamps.remove(ackCode);
_notifySlotFreed();
}
/// Remove ACK tag mapping by message ID
@@ -122,6 +127,7 @@ class MessageDeliveryTracker {
if (ackTag != null) {
_ackTagToMessageId.remove(ackTag);
_ackTagTimestamps.remove(ackTag);
_notifySlotFreed();
}
_pendingMessageIds.remove(messageId);
final emptyKeys = <String>[];
@@ -166,6 +172,7 @@ class MessageDeliveryTracker {
_ackTagToMessageId.clear();
_messageIdToAckTag.clear();
_ackTagTimestamps.clear();
_notifySlotFreed();
}
/// Get count of pending ACK tags
@@ -179,6 +186,27 @@ class MessageDeliveryTracker {
/// Returns true if >= 7 pending ACKs (stay under firmware limit of 8)
bool get shouldRateLimit => pendingCount >= 7;
/// Wait until a pending ACK slot is freed, or [timeout] elapses.
///
/// Returns immediately if not at the rate limit.
Future<void> waitForSlot({
Duration timeout = const Duration(milliseconds: 500),
}) async {
if (!shouldRateLimit) return;
_slotFreedCompleter ??= Completer<void>();
await _slotFreedCompleter!.future.timeout(
timeout,
onTimeout: () {},
);
}
void _notifySlotFreed() {
if (_slotFreedCompleter != null && !_slotFreedCompleter!.isCompleted) {
_slotFreedCompleter!.complete();
}
_slotFreedCompleter = null;
}
/// Get oldest pending ACK timestamp (for debugging)
DateTime? get oldestPendingTimestamp {
if (_ackTagTimestamps.isEmpty) return null;

View File

@@ -5,15 +5,13 @@ import '../../models/contact.dart';
/// Manages message retry state and logic
///
/// This helper class centralizes retry logic for direct messages, implementing
/// a progressive timeout strategy (4s, 8s, 12s) for messages sent to contacts
/// with learned routing paths.
/// This helper class centralizes retry logic for direct messages.
///
/// IMPORTANT: Based on MeshCore firmware analysis:
/// - Firmware calculates timeout based on path length and airtime
/// - Direct mode: ~(path_len * airtime * 2) + margin
/// - Flood mode: ~10-30 seconds for multi-hop
/// - Our timeouts (4s, 8s, 12s) are conservative for direct paths
/// - Our retry delays (1s, 2s, 4s, 8s) are app-level backoff timers
/// - Firmware does NOT automatically retry - app must implement
class MessageRetryManager {
// Track retry state for each message ID
@@ -21,10 +19,10 @@ class MessageRetryManager {
final Map<String, DateTime> _lastRetryTimes = {};
final Map<String, int> _pathFailureStreaks = {};
// Progressive timeout values in milliseconds
// These are app-level timeouts, separate from firmware's suggested timeout
// Firmware timeout is for ACK arrival, these are for retry attempts
static const List<int> _timeouts = [4000, 8000, 12000];
static const int maxRetryAttempts = 4;
// Retry backoff values in milliseconds.
static const List<int> _retryDelays = [1000, 2000, 4000, 8000];
static const int _defaultLoRaSf = 10;
static const int _defaultLoRaCr = 5;
static const int _defaultLoRaBwHz = 250000;
@@ -32,13 +30,12 @@ class MessageRetryManager {
static const int _defaultLoRaCrcEnabled = 1;
static const int _defaultLoRaExplicitHeader = 1;
/// Get timeout for a specific retry attempt (0-2)
/// Returns: 4000ms for attempt 0, 8000ms for attempt 1, 12000ms for attempt 2
int getTimeoutForAttempt(int attempt) {
if (attempt < 0 || attempt >= _timeouts.length) {
return _timeouts.last; // Default to last timeout if out of range
/// Get backoff delay for the next retry attempt.
int getDelayForAttempt(int attempt) {
if (attempt < 0 || attempt >= _retryDelays.length) {
return _retryDelays.last;
}
return _timeouts[attempt];
return _retryDelays[attempt];
}
/// Calculate a conservative delivery-ACK timeout when firmware doesn't
@@ -65,43 +62,8 @@ class MessageRetryManager {
return ((airtimeMs * (hopCount + 1) * 2) + 1500).clamp(4000, 20000);
}
/// Check if a message is eligible for retry
///
/// Returns true if:
/// - The message has retryAttempt < 3
/// - The contact has a learned path (contact.hasPath == true)
/// - The message hasn't used flood fallback yet
///
/// Messages to contacts without paths should NOT retry (flood mode already broadcasts)
bool canRetry(Message message, Contact contact) {
// Never retry if already tried flood mode
if (message.usedFloodFallback) {
return false;
}
// Never retry beyond 3 attempts
if (message.retryAttempt >= 3) {
return false;
}
// Only retry if contact has a learned path
// If no path, the device uses flood mode automatically - retrying won't help
return contact.routeHasPath;
}
/// Check if should fall back to flood mode
///
/// Returns true if:
/// - Message has exhausted all 3 retry attempts with direct mode
/// - Contact HAS a learned path (so direct mode was used)
/// - Hasn't already used flood fallback
///
/// IMPORTANT: Only contacts WITH paths need flood fallback.
/// Contacts without paths already use flood mode automatically.
bool shouldUseFloodFallback(Message message, Contact contact) {
return message.retryAttempt >= 3 &&
contact.routeHasPath &&
!message.usedFloodFallback;
return message.retryAttempt < maxRetryAttempts;
}
/// Track a retry attempt for a message

View File

@@ -38,12 +38,6 @@ Future<bool> serveCachedSessionFragments<T>({
);
return false;
}
if (!requester.routeSupportsLegacyRawTransport) {
debugPrint(
'⚠️ [$providerLabel] ${requester.advName} route uses unsupported 3-byte raw transport on current client',
);
return false;
}
if (requester.outPath.isEmpty) {
debugPrint(
'⚠️ [$providerLabel] ${requester.advName} has empty outPath payload',
@@ -64,7 +58,7 @@ Future<bool> serveCachedSessionFragments<T>({
try {
await sendRawPacket(
contactPath: requester.outPath,
contactPathLen: requester.routeSignedPathLen,
contactPathLen: requester.routeEncodedPathLen,
payload: encodeBinary(fragment),
);
servedCount++;

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
@@ -6,6 +8,10 @@ import '../models/location_trail.dart';
import '../models/map_drawing.dart';
class MapProvider with ChangeNotifier {
MapProvider() {
unawaited(_loadInitialState());
}
LatLng? _targetLocation;
double? _targetZoom;
bool _shouldAnimate = false;
@@ -33,6 +39,7 @@ class MapProvider with ChangeNotifier {
// Contact trail toggles
bool _showAllContactTrails = true; // Default to showing all contact trails
bool _hideRepeatersOnMap = false;
// Imported trail (from GPX)
LocationTrail? _importedTrail;
@@ -67,6 +74,7 @@ class MapProvider with ChangeNotifier {
// Contact trail getters
bool get showAllContactTrails => _showAllContactTrails;
bool get hideRepeatersOnMap => _hideRepeatersOnMap;
// Imported trail getters
LocationTrail? get importedTrail => _importedTrail;
@@ -350,6 +358,11 @@ class MapProvider with ChangeNotifier {
notifyListeners();
}
Future<void> _loadInitialState() async {
await Future.wait([loadOverlayState(), loadTrailSettings()]);
await loadRepeaterVisibilitySettings();
}
/// Save overlay state to SharedPreferences
Future<void> _saveOverlayState() async {
final prefs = await SharedPreferences.getInstance();
@@ -386,6 +399,20 @@ class MapProvider with ChangeNotifier {
await prefs.setBool('map_show_all_contact_trails', _showAllContactTrails);
}
Future<void> setHideRepeatersOnMap(bool hide) async {
if (_hideRepeatersOnMap == hide) return;
_hideRepeatersOnMap = hide;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('map_hide_repeaters', _hideRepeatersOnMap);
}
Future<void> loadRepeaterVisibilitySettings() async {
final prefs = await SharedPreferences.getInstance();
_hideRepeatersOnMap = prefs.getBool('map_hide_repeaters') ?? false;
notifyListeners();
}
/// Set imported trail (from GPX import)
void setImportedTrail(LocationTrail trail) {
_importedTrail = trail;

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart';
@@ -299,11 +300,15 @@ class VoiceProvider with ChangeNotifier {
);
try {
final pcm = await _codec.decodePackets(session.packets, session.mode);
final decodedPcm = await _codec.decodePackets(
session.packets,
session.mode,
);
final pcm = _preparePlaybackPcm(decodedPcm, session.mode);
debugPrint('🎙️ [VoiceProvider] decoded ${pcm.length} PCM samples');
_playingSessionId = sessionId;
notifyListeners();
await _player.play(pcm);
await _player.play(pcm, sampleRateHz: session.mode.sampleRateHz);
} catch (e, st) {
debugPrint('❌ [VoiceProvider] Playback error: $e\n$st');
if (_playingSessionId == sessionId) {
@@ -319,6 +324,10 @@ class VoiceProvider with ChangeNotifier {
notifyListeners();
}
Int16List _preparePlaybackPcm(Int16List pcm, VoicePacketMode mode) {
return pcm;
}
Future<void> clearStoredVoiceData() async {
_sessions.clear();
_outgoingSessions.clear();

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,660 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'dart:typed_data';
import '../l10n/app_localizations.dart';
import '../models/contact.dart';
import '../providers/connection_provider.dart';
import '../providers/contacts_provider.dart';
import '../services/mesh_map_nodes_service.dart';
import '../widgets/compact_signal_indicator.dart' show SignalMetric;
enum _DiscoveryMenuAction { repeaters, sensors }
class DiscoveryScreen extends StatefulWidget {
const DiscoveryScreen({super.key});
@override
State<DiscoveryScreen> createState() => _DiscoveryScreenState();
}
class _DiscoveryScreenState extends State<DiscoveryScreen> {
static const int _repeaterAdvertType = 2;
static const int _sensorAdvertType = 4;
final Set<String> _resolvingAdvertKeys = <String>{};
final Set<int> _runningDiscoveryTypes = <int>{};
bool _isResolvingAll = false;
late final Future<List<MeshMapNode>> _cachedNodesFuture;
@override
void initState() {
super.initState();
_cachedNodesFuture = MeshMapNodesService.loadCachedNodes(
cacheTtl: MeshMapNodesService.traceCacheTtl,
);
}
Future<void> _handleMenuAction(_DiscoveryMenuAction action) async {
switch (action) {
case _DiscoveryMenuAction.repeaters:
await _discoverNodeType(_repeaterAdvertType);
break;
case _DiscoveryMenuAction.sensors:
await _discoverNodeType(_sensorAdvertType);
break;
}
}
Future<void> _clearAllDiscoveries() async {
final pendingCount = context.read<ContactsProvider>().pendingAdverts.length;
if (pendingCount == 0) {
return;
}
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Clear discoveries'),
content: Text(
'Remove all $pendingCount pending discover${pendingCount == 1 ? 'y' : 'ies'} from this device?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('Clear all'),
),
],
),
);
if (confirmed != true || !mounted) {
return;
}
await context.read<ContactsProvider>().clearPendingAdverts();
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Cleared pending discoveries.')),
);
}
Future<void> _discoverNodeType(int advertType) async {
if (_runningDiscoveryTypes.contains(advertType)) return;
setState(() {
_runningDiscoveryTypes.add(advertType);
});
try {
await context.read<ConnectionProvider>().discoverNodeType(
advertType: advertType,
);
if (!mounted) return;
final label = switch (advertType) {
_repeaterAdvertType => 'Repeater discovery sent',
_sensorAdvertType => 'Sensor discovery sent',
_ => 'Discovery sent',
};
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(label)));
} finally {
if (mounted) {
setState(() {
_runningDiscoveryTypes.remove(advertType);
});
}
}
}
Future<void> _resolveAdvert(PendingAdvert advert) async {
final keyHex = advert.publicKeyHex;
if (_resolvingAdvertKeys.contains(keyHex)) return;
setState(() {
_resolvingAdvertKeys.add(keyHex);
});
try {
final connectionProvider = context.read<ConnectionProvider>();
final contactsProvider = context.read<ContactsProvider>();
await connectionProvider.getContact(advert.publicKey);
if (connectionProvider.error == 'Not found') {
connectionProvider.clearError();
final fallbackContact = _contactFromPendingAdvert(advert);
await connectionProvider.addOrUpdateContact(fallbackContact);
contactsProvider.addOrUpdateContact(
fallbackContact,
devicePublicKey: connectionProvider.deviceInfo.publicKey,
);
}
if ((advert.typeValue ?? 0) == _sensorAdvertType) {
await connectionProvider.requestTelemetry(advert.publicKey);
}
} finally {
if (mounted) {
setState(() {
_resolvingAdvertKeys.remove(keyHex);
});
}
}
}
Future<void> _resolveAll(List<PendingAdvert> adverts) async {
if (_isResolvingAll || adverts.isEmpty) return;
setState(() {
_isResolvingAll = true;
});
try {
for (final advert in adverts) {
if (!mounted) break;
await _resolveAdvert(advert);
}
} finally {
if (mounted) {
setState(() {
_isResolvingAll = false;
});
}
}
}
String _formatRelativeTime(BuildContext context, DateTime when) {
final l10n = AppLocalizations.of(context)!;
final diff = DateTime.now().difference(when);
if (diff.inMinutes < 1) return l10n.justNow;
if (diff.inMinutes < 60) return l10n.minutesAgo(diff.inMinutes);
if (diff.inHours < 24) return l10n.hoursAgo(diff.inHours);
return l10n.daysAgo(diff.inDays);
}
Contact _contactFromPendingAdvert(PendingAdvert advert) {
final lastAdvert =
advert.lastAdvert ?? (advert.receivedAt.millisecondsSinceEpoch ~/ 1000);
final advName = advert.advName?.trim().isNotEmpty == true
? advert.advName!.trim()
: advert.shortDisplayKey;
return Contact(
publicKey: Uint8List.fromList(advert.publicKey),
type: ContactType.fromValue(advert.typeValue ?? 0),
flags: advert.flags ?? 0,
outPathLen: advert.signedEncodedPathLen ?? -1,
outPath: advert.paddedPathBytes == null
? Uint8List(64)
: Uint8List.fromList(advert.paddedPathBytes!),
advName: advName,
lastAdvert: lastAdvert,
advLat: advert.advLat ?? 0,
advLon: advert.advLon ?? 0,
lastMod: lastAdvert,
);
}
String _displayNameForAdvert(
PendingAdvert advert,
ContactsProvider contactsProvider,
List<MeshMapNode> cachedNodes,
) {
final advertisedName = advert.advName?.trim();
if (advertisedName != null && advertisedName.isNotEmpty) {
return advertisedName;
}
Contact? existingMatch;
for (final contact in contactsProvider.contacts) {
if (contact.publicKeyHex == advert.publicKeyHex) {
existingMatch = contact;
break;
}
}
if (existingMatch != null && existingMatch.displayName.trim().isNotEmpty) {
return existingMatch.displayName;
}
MeshMapNode? cachedMatch;
for (final node in cachedNodes) {
if (node.publicKey == advert.publicKeyHex.toLowerCase()) {
cachedMatch = node;
break;
}
}
if (cachedMatch != null && cachedMatch.name.trim().isNotEmpty) {
return cachedMatch.name;
}
return advert.shortDisplayKey;
}
IconData _iconForAdvert(PendingAdvert advert) {
return switch (advert.typeValue) {
_repeaterAdvertType => Icons.router_outlined,
_sensorAdvertType => Icons.sensors_outlined,
_ => Icons.campaign_outlined,
};
}
String? _typeLabelForAdvert(PendingAdvert advert) {
return switch (advert.typeValue) {
_repeaterAdvertType => 'Repeater',
_sensorAdvertType => 'Sensor',
3 => 'Room',
1 => 'Chat',
_ => null,
};
}
String? _resolvedTypeLabelForAdvert(
PendingAdvert advert,
List<MeshMapNode> cachedNodes,
) {
final directType = _typeLabelForAdvert(advert);
if (directType != null) {
return directType;
}
for (final node in cachedNodes) {
if (node.publicKey == advert.publicKeyHex.toLowerCase()) {
return switch (node.type) {
1 => 'Repeater',
4 => 'Sensor',
3 => 'Room',
2 => 'Chat',
_ => null,
};
}
}
return null;
}
Widget _buildAdvertTitle(
BuildContext context, {
required String displayName,
String? subtitle,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
displayName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.bold),
),
if (subtitle != null && subtitle.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
],
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(
title: const Text('Discovery'),
actions: [
Consumer<ConnectionProvider>(
builder: (context, connectionProvider, child) {
final isConnected = connectionProvider.deviceInfo.isConnected;
final repeatersBusy = _runningDiscoveryTypes.contains(
_repeaterAdvertType,
);
final sensorsBusy = _runningDiscoveryTypes.contains(
_sensorAdvertType,
);
return PopupMenuButton<_DiscoveryMenuAction>(
tooltip: 'Discovery tools',
onSelected: _handleMenuAction,
itemBuilder: (context) => [
PopupMenuItem<_DiscoveryMenuAction>(
value: _DiscoveryMenuAction.repeaters,
enabled: isConnected && !repeatersBusy,
child: Row(
children: [
repeatersBusy
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.router_outlined),
const SizedBox(width: 12),
const Text('Discover repeaters'),
],
),
),
PopupMenuItem<_DiscoveryMenuAction>(
value: _DiscoveryMenuAction.sensors,
enabled: isConnected && !sensorsBusy,
child: Row(
children: [
sensorsBusy
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.sensors_outlined),
const SizedBox(width: 12),
const Text('Discover sensors'),
],
),
),
],
);
},
),
],
),
body: FutureBuilder<List<MeshMapNode>>(
future: _cachedNodesFuture,
builder: (context, nodesSnapshot) =>
Consumer2<ContactsProvider, ConnectionProvider>(
builder: (context, contactsProvider, connectionProvider, child) {
final pendingAdverts = contactsProvider.pendingAdverts;
final isConnected = connectionProvider.deviceInfo.isConnected;
final cachedNodes = nodesSnapshot.data ?? const <MeshMapNode>[];
return ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Icons.person_search),
const SizedBox(width: 12),
Expanded(
child: Text(
'Pending discoveries (${pendingAdverts.length})',
style: Theme.of(context).textTheme.titleMedium,
),
),
],
),
const SizedBox(height: 12),
Text(
'Resolve entries manually so they do not auto-populate contacts.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 14),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: isConnected &&
pendingAdverts.isNotEmpty &&
!_isResolvingAll
? () => _resolveAll(pendingAdverts)
: null,
icon: _isResolvingAll
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(
Icons.download_for_offline_outlined,
),
label: const Text('Resolve all'),
),
),
const SizedBox(width: 10),
Expanded(
child: OutlinedButton.icon(
onPressed: pendingAdverts.isNotEmpty
? _clearAllDiscoveries
: null,
icon: const Icon(Icons.clear_all_rounded),
label: const Text('Clear all'),
),
),
],
),
],
),
),
),
const SizedBox(height: 16),
if (pendingAdverts.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 48),
child: Column(
children: [
Icon(
Icons.person_search_outlined,
size: 64,
color: Theme.of(context).disabledColor,
),
const SizedBox(height: 16),
Text(
'No pending discoveries',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
'Unknown adverts will appear here until you choose to resolve them.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
...pendingAdverts.map((advert) {
final isResolving = _resolvingAdvertKeys.contains(
advert.publicKeyHex,
);
final displayName = _displayNameForAdvert(
advert,
contactsProvider,
cachedNodes,
);
final typeLabel = _resolvedTypeLabelForAdvert(
advert,
cachedNodes,
);
final downMetric = SignalMetric.fromValues(
rssiDbm: advert.rxRssiDbm,
snrDb: advert.rxSnr,
);
final upMetric = SignalMetric.fromValues(
rssiDbm: advert.repeaterLastRssi,
snrDb: advert.repeaterLastSnr,
);
final detailLines = <String>[
'${l10n.publicKey}: ${advert.shortDisplayKey}',
];
final summaryParts = <String>[];
final battery = advert.repeaterBatteryPercent;
if (battery != null) {
summaryParts.add('Battery ${battery.round()}%');
}
if (advert.repeaterQueueLen != null) {
summaryParts.add('Queue ${advert.repeaterQueueLen}');
}
if (summaryParts.isNotEmpty) {
detailLines.add(summaryParts.join(''));
}
detailLines.add(
'${l10n.lastSeen}: ${_formatRelativeTime(context, advert.receivedAt)}',
);
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
CircleAvatar(
child: Icon(_iconForAdvert(advert)),
),
const SizedBox(width: 12),
Expanded(
child: _buildAdvertTitle(
context,
displayName: displayName,
subtitle: typeLabel,
),
),
if (downMetric != null || upMetric != null) ...[
const SizedBox(width: 12),
_buildSignalSummary(
context,
downMetric: downMetric,
upMetric: upMetric,
),
],
const SizedBox(width: 8),
isResolving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: IconButton(
visualDensity: VisualDensity.compact,
icon: const Icon(
Icons.person_add_alt_1,
),
tooltip: 'Resolve contact',
onPressed: isConnected
? () => _resolveAdvert(advert)
: null,
),
],
),
const SizedBox(height: 10),
Text(
detailLines.join('\n'),
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
);
}),
],
);
},
),
),
);
}
Widget _buildSignalSummary(
BuildContext context, {
SignalMetric? downMetric,
SignalMetric? upMetric,
}) {
if (downMetric == null && upMetric == null) {
return const SizedBox.shrink();
}
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (downMetric != null)
_buildDirectionalSignal(
context,
icon: Icons.south_west_rounded,
metric: downMetric,
),
if (downMetric != null && upMetric != null) const SizedBox(height: 6),
if (upMetric != null)
_buildDirectionalSignal(
context,
icon: Icons.north_east_rounded,
metric: upMetric,
),
],
);
}
Widget _buildDirectionalSignal(
BuildContext context, {
required IconData icon,
required SignalMetric metric,
}) {
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(
icon,
size: 11,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 3),
_buildMiniSignalBars(context, metric),
const SizedBox(width: 4),
Text(
metric.valueLabel,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
],
);
}
Widget _buildMiniSignalBars(BuildContext context, SignalMetric metric) {
final inactive = Theme.of(context).colorScheme.outlineVariant;
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (var index = 0; index < 3; index++) ...[
if (index > 0) const SizedBox(width: 2),
Container(
width: 3,
height: 6.0 + (index * 4),
decoration: BoxDecoration(
color: index < metric.activeBars ? metric.color : inactive,
borderRadius: BorderRadius.circular(2),
),
),
],
],
);
}
}

View File

@@ -12,17 +12,22 @@ import '../providers/contacts_provider.dart';
import '../theme/app_theme.dart';
import 'messages_tab.dart';
import 'contacts_tab.dart';
import 'discovery_screen.dart';
import 'sensors_tab.dart';
import 'map_tab.dart';
import 'repeaters_map_screen.dart';
import 'settings_screen.dart';
import 'device_config_screen.dart';
import 'packet_log_screen.dart';
import 'live_traffic_screen.dart';
import 'spectrum_scan_screen.dart';
import '../utils/toast_logger.dart';
import '../l10n/app_localizations.dart';
import '../widgets/permission_request_dialog.dart';
import '../widgets/connection_dialog.dart';
import '../utils/battery_display_helper.dart';
import '../services/developer_mode_service.dart';
import '../services/mesh_map_nodes_service.dart';
enum _HomeTab { messages, contacts, sensors, map }
@@ -55,6 +60,7 @@ class _HomeScreenState extends State<HomeScreen>
int _currentIndex = 0;
bool _isMapFullscreen = false;
bool _showRxTxIndicators = true;
bool _isDeveloperModeEnabled = false;
bool _isMapEnabled = true;
bool _isContactsEnabled = true;
bool _isSensorsEnabled = false;
@@ -90,6 +96,8 @@ class _HomeScreenState extends State<HomeScreen>
// Initialize synchronously so first build always has a valid controller.
_initTabController();
_loadRxTxPreference();
_loadDeveloperModePreference();
MeshMapNodesService.syncInBackgroundIfStale();
// Show permission dialog after the first frame if needed
if (widget.shouldShowPermissionDialog) {
@@ -192,7 +200,6 @@ class _HomeScreenState extends State<HomeScreen>
switch (tab) {
case _HomeTab.messages:
context.read<MessagesProvider>().markAllAsRead();
break;
case _HomeTab.contacts:
context.read<ContactsProvider>().markAllAsViewed();
@@ -213,10 +220,17 @@ class _HomeScreenState extends State<HomeScreen>
);
}
void _openLiveTraffic(ConnectionProvider provider) {
openLiveTrafficScreen(context, provider);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
_lifecycleState = state;
_syncFastLocationUiState();
if (state == AppLifecycleState.resumed) {
MeshMapNodesService.syncInBackgroundIfStale();
}
}
Future<void> _loadRxTxPreference() async {
@@ -228,6 +242,14 @@ class _HomeScreenState extends State<HomeScreen>
}
}
Future<void> _loadDeveloperModePreference() async {
final isEnabled = await DeveloperModeService.isEnabled();
if (!mounted) return;
setState(() {
_isDeveloperModeEnabled = isEnabled;
});
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
@@ -378,10 +400,14 @@ class _HomeScreenState extends State<HomeScreen>
required int count,
required bool isActive,
required Color activeColor,
bool compact = false,
}) {
final color = isActive ? activeColor : Colors.grey;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
padding: EdgeInsets.symmetric(
horizontal: compact ? 7 : 8,
vertical: compact ? 4 : 5,
),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(999),
@@ -390,15 +416,15 @@ class _HomeScreenState extends State<HomeScreen>
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 7,
height: 7,
width: compact ? 6 : 7,
height: compact ? 6 : 7,
decoration: BoxDecoration(shape: BoxShape.circle, color: color),
),
const SizedBox(width: 6),
SizedBox(width: compact ? 5 : 6),
Text(
'$label:$count',
style: TextStyle(
fontSize: 11,
fontSize: compact ? 10 : 11,
fontWeight: FontWeight.w600,
color: color,
),
@@ -598,56 +624,160 @@ class _HomeScreenState extends State<HomeScreen>
),
PopupMenuButton(
icon: const Icon(Icons.more_vert),
itemBuilder: (context) => [
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.radar),
const SizedBox(width: 8),
const Text('Spectrum Scan'),
],
),
onTap: () {
final navigator = Navigator.of(context);
Future.delayed(Duration.zero, () {
if (!mounted) return;
navigator.push(
MaterialPageRoute(
builder: (context) => const SpectrumScanScreen(),
),
);
});
},
),
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.settings),
const SizedBox(width: 8),
Text(AppLocalizations.of(context)!.settings),
],
),
onTap: () {
// Capture context-dependent objects before async gap
final navigator = Navigator.of(context);
Future.delayed(Duration.zero, () async {
if (!mounted) return;
await navigator.push(
MaterialPageRoute(
builder: (context) => SettingsScreen(
onThemeChanged: widget.onThemeChanged,
onLocaleChanged: widget.onLocaleChanged,
currentTheme: widget.currentTheme,
currentLocale: widget.currentLocale,
itemBuilder: (context) {
final items = <PopupMenuEntry<void>>[];
if (_isDeveloperModeEnabled) {
items.add(
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.radar),
const SizedBox(width: 8),
const Text('Spectrum Scan'),
],
),
onTap: () {
final navigator = Navigator.of(context);
Future.delayed(Duration.zero, () {
if (!mounted) return;
navigator.push(
MaterialPageRoute(
builder: (context) =>
const SpectrumScanScreen(),
),
);
});
},
),
);
}
items.add(
PopupMenuItem(
child: const Row(
children: [
Icon(Icons.radar_outlined),
SizedBox(width: 8),
Text('Live Traffic'),
],
),
onTap: () {
final navigator = Navigator.of(context);
final provider = context.read<ConnectionProvider>();
Future.delayed(Duration.zero, () {
if (!mounted) return;
navigator.push(
MaterialPageRoute(
builder: (_) => LiveTrafficScreen.fromProvider(
provider,
openPacketLogs: () {
navigator.push(
MaterialPageRoute(
builder: (_) => PacketLogScreen(
bleService: provider.bleService,
),
),
);
},
),
),
);
});
},
),
);
items.add(
PopupMenuItem(
child: const Row(
children: [
Icon(Icons.router_outlined),
SizedBox(width: 8),
Text('Repeaters Map'),
],
),
onTap: () {
final navigator = Navigator.of(context);
Future.delayed(Duration.zero, () {
if (!mounted) return;
navigator.push(
MaterialPageRoute(
builder: (context) =>
const RepeatersMapScreen(),
fullscreenDialog: true,
),
);
});
},
),
);
items.add(
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.person_search),
const SizedBox(width: 8),
Consumer<ContactsProvider>(
builder: (context, contactsProvider, child) {
final pendingCount =
contactsProvider.pendingAdverts.length;
return Text(
pendingCount > 0
? 'Discovery ($pendingCount)'
: 'Discovery',
);
},
),
);
// Reload preference when returning from settings
_loadRxTxPreference();
});
},
),
],
],
),
onTap: () {
final navigator = Navigator.of(context);
Future.delayed(Duration.zero, () {
if (!mounted) return;
navigator.push(
MaterialPageRoute(
builder: (context) => const DiscoveryScreen(),
),
);
});
},
),
);
items.add(
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.settings),
const SizedBox(width: 8),
Text(AppLocalizations.of(context)!.settings),
],
),
onTap: () {
final navigator = Navigator.of(context);
Future.delayed(Duration.zero, () async {
if (!mounted) return;
await navigator.push(
MaterialPageRoute(
builder: (context) => SettingsScreen(
onThemeChanged: widget.onThemeChanged,
onLocaleChanged: widget.onLocaleChanged,
currentTheme: widget.currentTheme,
currentLocale: widget.currentLocale,
),
),
);
_loadRxTxPreference();
_loadDeveloperModePreference();
});
},
),
);
return items;
},
),
],
),
@@ -657,6 +787,9 @@ class _HomeScreenState extends State<HomeScreen>
switch (tab) {
case _HomeTab.messages:
return MessagesTab(
isActive:
_currentTab == _HomeTab.messages &&
_lifecycleState == AppLifecycleState.resumed,
onNavigateToMap: _isMapEnabled
? () => _navigateToTab(_HomeTab.map)
: null,
@@ -666,6 +799,7 @@ class _HomeScreenState extends State<HomeScreen>
onNavigateToMap: _isMapEnabled
? () => _navigateToTab(_HomeTab.map)
: null,
onNavigateToMessages: () => _navigateToTab(_HomeTab.messages),
);
case _HomeTab.sensors:
return const SensorsTab();
@@ -715,7 +849,7 @@ class _HomeScreenState extends State<HomeScreen>
Icons.message,
unreadCount,
),
text: AppLocalizations.of(context)!.messages,
text: 'Chat',
);
case _HomeTab.contacts:
return Tab(
@@ -999,6 +1133,7 @@ class _HomeScreenState extends State<HomeScreen>
SizedBox(width: isTight ? 8 : 12),
if (_showRxTxIndicators)
GestureDetector(
onTap: () => _openLiveTraffic(provider),
onLongPress: () {
Navigator.push(
context,
@@ -1014,17 +1149,19 @@ class _HomeScreenState extends State<HomeScreen>
txActive: provider.txActivity,
)
: Container(
constraints: const BoxConstraints(minHeight: 48),
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh
.withValues(alpha: 0.85),
borderRadius: BorderRadius.circular(20),
borderRadius: BorderRadius.circular(22),
),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
_buildActivityBadge(
@@ -1032,13 +1169,15 @@ class _HomeScreenState extends State<HomeScreen>
count: provider.rxPacketCount,
isActive: provider.rxActivity,
activeColor: Colors.green,
compact: true,
),
const SizedBox(height: 6),
const SizedBox(height: 4),
_buildActivityBadge(
label: 'TX',
count: provider.txPacketCount,
isActive: provider.txActivity,
activeColor: Colors.blue,
compact: true,
),
],
),

File diff suppressed because it is too large Load Diff

View File

@@ -22,11 +22,13 @@ import '../models/message.dart';
import '../services/background_location_service.dart';
import '../services/location_tracking_service.dart';
import '../services/map_marker_service.dart';
import '../services/message_destination_preferences.dart';
import '../services/trail_color_service.dart';
import '../widgets/map_debug_info.dart';
import '../widgets/map/compass_widget.dart';
import '../widgets/map/detailed_compass_dialog.dart';
import '../widgets/map/drawing_layer.dart';
import '../widgets/map/drawing_toolbar.dart';
import '../widgets/map/location_trail_layer.dart';
import '../widgets/map/trail_controls.dart';
import '../widgets/map/map_message_overlay.dart';
@@ -815,6 +817,174 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
}
}
Future<void> _showSarMarkerActions(
SarMarker marker,
MessagesProvider messagesProvider,
ContactsProvider contactsProvider,
) async {
final message = messagesProvider.getMessageById(marker.id);
await showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (sheetContext) {
final theme = Theme.of(sheetContext);
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(marker.emoji, style: const TextStyle(fontSize: 24)),
const SizedBox(width: 10),
Expanded(
child: Text(
marker.displayName,
style: theme.textTheme.titleMedium,
),
),
],
),
const SizedBox(height: 12),
Text(
'${marker.location.latitude.toStringAsFixed(6)}, ${marker.location.longitude.toStringAsFixed(6)}',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 4),
Text(
marker.senderName != null
? '${marker.timeAgo}${marker.senderName}'
: marker.timeAgo,
style: theme.textTheme.bodySmall,
),
if (marker.notes != null && marker.notes!.isNotEmpty) ...[
const SizedBox(height: 12),
Text(marker.notes!),
],
const SizedBox(height: 16),
if (message != null)
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.chat_bubble_outline),
title: const Text('Open message'),
subtitle: const Text('Jump to the related SAR message'),
onTap: () async {
Navigator.pop(sheetContext);
await _openSarMarkerMessage(
message,
messagesProvider,
contactsProvider,
);
},
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.delete_outline, color: Colors.red),
title: Text(
'Remove marker',
style: TextStyle(color: theme.colorScheme.error),
),
subtitle: Text(
message != null
? 'This also removes the linked SAR message.'
: 'Hide this marker from the map.',
),
onTap: () async {
final confirmed = await _confirmSarMarkerRemoval(
hasMessage: message != null,
);
if (!mounted ||
!sheetContext.mounted ||
confirmed != true) {
return;
}
Navigator.pop(sheetContext);
await messagesProvider.removeSarMarkerPermanently(
marker.id,
);
},
),
],
),
),
);
},
);
}
Future<bool?> _confirmSarMarkerRemoval({required bool hasMessage}) {
return showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Remove SAR marker'),
content: Text(
hasMessage
? 'This will remove the marker and its linked chat message.'
: 'This will hide the marker from the map, even if it is not visible in chat.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: Text(AppLocalizations.of(dialogContext)!.cancel),
),
TextButton(
onPressed: () => Navigator.pop(dialogContext, true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: Text(AppLocalizations.of(dialogContext)!.delete),
),
],
),
);
}
Future<void> _openSarMarkerMessage(
Message message,
MessagesProvider messagesProvider,
ContactsProvider contactsProvider,
) async {
if (message.isChannelMessage) {
final channelContact = contactsProvider.channels.where((contact) {
return contact.publicKey.length > 1 &&
contact.publicKey[1] == (message.channelIdx ?? 0);
}).firstOrNull;
messagesProvider.navigateToDestination(
MessageDestinationPreferences.destinationTypeChannel,
recipientPublicKeyHex: channelContact?.publicKeyHex,
);
} else {
Contact? destinationContact;
if (message.recipientPublicKey != null) {
destinationContact = contactsProvider.contacts.where((contact) {
return contact.publicKey.length >=
message.recipientPublicKey!.length &&
contact.publicKey.matches(message.recipientPublicKey!);
}).firstOrNull;
} else if (message.senderPublicKeyPrefix != null &&
message.senderPublicKeyPrefix!.length >= 6) {
destinationContact = contactsProvider.findContactByPrefix(
message.senderPublicKeyPrefix!,
);
}
if (destinationContact != null) {
messagesProvider.navigateToDestination(
destinationContact.isRoom
? MessageDestinationPreferences.destinationTypeRoom
: MessageDestinationPreferences.destinationTypeContact,
recipientPublicKeyHex: destinationContact.publicKeyHex,
);
}
}
messagesProvider.navigateToMessage(message.id);
widget.onNavigateToMessages?.call();
}
/// Show SAR dialog with pre-populated location from map long press
void _showSarDialogWithLocation(LatLng location) {
// Create a Position object from the LatLng coordinates
@@ -1118,9 +1288,26 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
Widget build(BuildContext context) {
super.build(context); // Required for AutomaticKeepAliveClientMixin
return Consumer3<ContactsProvider, MessagesProvider, DrawingProvider>(
builder: (context, contactsProvider, messagesProvider, drawingProvider, child) {
final contactsWithLocation = contactsProvider.contactsWithLocation;
return Consumer4<
ContactsProvider,
MessagesProvider,
DrawingProvider,
MapProvider
>(
builder: (
context,
contactsProvider,
messagesProvider,
drawingProvider,
mapProvider,
child,
) {
final allContactsWithLocation = contactsProvider.contactsWithLocation;
final contactsWithLocation = mapProvider.hideRepeatersOnMap
? allContactsWithLocation
.where((contact) => !contact.isRepeater)
.toList()
: allContactsWithLocation;
// Filter SAR markers based on visibility toggle
final allSarMarkers = messagesProvider.sarMarkers;
final sarMarkers = drawingProvider.showSarMarkers
@@ -1718,7 +1905,7 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
onTap: (contact) {
_showDetailedCompassWithContact(
context,
contactsProvider.contactsWithLocation,
contactsWithLocation,
messagesProvider.sarMarkers,
contact,
);
@@ -1730,9 +1917,11 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
context: context,
mapRotation: _getMapRotation(),
onTap: (marker) {
// Navigate to the corresponding message in Messages tab
messagesProvider.navigateToMessage(marker.id);
widget.onNavigateToMessages?.call();
_showSarMarkerActions(
marker,
messagesProvider,
contactsProvider,
);
},
),
// User location marker with directional pointer
@@ -2020,7 +2209,7 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
child: GestureDetector(
onTap: () => _showDetailedCompass(
context,
contactsProvider.contactsWithLocation,
contactsWithLocation,
messagesProvider.sarMarkers,
),
child: CompassWidget(
@@ -2131,6 +2320,8 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
right: 16,
child: Column(
children: [
const DrawingToolbar(),
const SizedBox(height: 8),
// Hide other buttons when in drawing mode
if (!drawingProvider.isDrawing) ...[
// Current Location - always center to GPS

View File

@@ -39,8 +39,9 @@ import '../l10n/app_localizations.dart';
class MessagesTab extends StatefulWidget {
final VoidCallback? onNavigateToMap;
final bool isActive;
const MessagesTab({super.key, this.onNavigateToMap});
const MessagesTab({super.key, this.onNavigateToMap, this.isActive = true});
@override
State<MessagesTab> createState() => _MessagesTabState();
@@ -50,6 +51,7 @@ class _MessagesTabState extends State<MessagesTab> {
static const int _maxContactMessageBytes = 156;
static const int _maxChannelMessageBytes = 127;
static const double _composerOverlayHeight = 148;
static const Duration _channelAutoReadDelay = Duration(seconds: 5);
final TextEditingController _textController = TextEditingController();
final FocusNode _focusNode = FocusNode();
@@ -57,6 +59,12 @@ class _MessagesTabState extends State<MessagesTab> {
int _messageByteCount = 0;
String? _highlightedMessageId;
Timer? _highlightTimer; // Timer for clearing message highlight
Timer? _channelReadTimer;
String? _pendingChannelReadKey;
bool _suppressMentionTrigger = false;
TextRange? _activeMentionRange;
String _mentionQuery = '';
List<Contact> _mentionSuggestions = const [];
// Message destination state
String _destinationType =
@@ -71,7 +79,7 @@ class _MessagesTabState extends State<MessagesTab> {
final VoiceRecorderService _voiceRecorder = VoiceRecorderService();
bool _isRecording = false;
bool _isSendingVoice = false;
static const int _maxVoicePackets = 10;
static const Duration _maxVoiceRecordingDuration = Duration(seconds: 30);
static const double _silenceRmsThreshold = 500.0;
static const double _silencePeakThreshold = 1400.0;
static const int _maxInteriorSilentChunks = 2;
@@ -85,18 +93,17 @@ class _MessagesTabState extends State<MessagesTab> {
@override
void initState() {
super.initState();
_textController.addListener(_updateCharacterCount);
_textController.addListener(_handleComposerChanged);
_focusNode.addListener(_handleFocusChanged);
// Load saved message destination
_loadSavedDestination();
_loadVoiceBitrate();
// Mark all messages as read when tab is opened
_loadVoiceSettings();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<MessagesProvider>().markAllAsRead();
_checkForNavigationRequest();
});
}
Future<void> _loadVoiceBitrate() async {
Future<void> _loadVoiceSettings() async {
final bitrate = await VoiceBitratePreferences.getBitrate();
if (!mounted) return;
setState(() {
@@ -117,29 +124,52 @@ class _MessagesTabState extends State<MessagesTab> {
@override
void dispose() {
_highlightTimer?.cancel();
_channelReadTimer?.cancel();
_voiceStreamSub?.cancel();
_voiceRecorder.dispose();
_focusNode.removeListener(_handleFocusChanged);
_textController.dispose();
_focusNode.dispose();
_scrollController.dispose();
super.dispose();
}
@override
void didUpdateWidget(covariant MessagesTab oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.isActive != widget.isActive) {
_syncChannelAutoReadTimer(context.read<MessagesProvider>());
}
}
void _checkForNavigationRequest() {
final messagesProvider = context.read<MessagesProvider>();
final targetMessageId = messagesProvider.targetMessageId;
final targetDestinationType = messagesProvider.targetDestinationType;
if (targetMessageId != null) {
_scrollToMessage(targetMessageId);
messagesProvider.clearMessageNavigation();
}
if (targetDestinationType != null) {
_applyPendingDestination(
type: targetDestinationType,
recipientPublicKeyHex: messagesProvider.targetRecipientPublicKeyHex,
);
messagesProvider.clearDestinationNavigation();
}
}
void _scrollToMessage(String messageId) {
final messagesProvider = context.read<MessagesProvider>();
final messages = _getFilteredMessages(messagesProvider);
final messages = messagesProvider.buildDisplayMessages(
_getFilteredMessages(messagesProvider),
);
final messageIndex = messages.indexWhere((m) => m.id == messageId);
final messageIndex = messages.indexWhere(
(entry) => entry.message.id == messageId,
);
if (messageIndex != -1 && _scrollController.hasClients) {
// Calculate position - accounting for reverse list
@@ -170,12 +200,130 @@ class _MessagesTabState extends State<MessagesTab> {
}
}
void _handleComposerChanged() {
_updateCharacterCount();
if (_suppressMentionTrigger) {
return;
}
_updateMentionSuggestions(_textController.value);
}
void _updateCharacterCount() {
setState(() {
_messageByteCount = utf8.encode(_textController.text).length;
});
}
void _handleFocusChanged() {
if (!_focusNode.hasFocus) {
_clearMentionSuggestions();
return;
}
if (_suppressMentionTrigger) {
return;
}
_updateMentionSuggestions(_textController.value);
}
void _updateMentionSuggestions(TextEditingValue value) {
final triggerRange = _getMentionTriggerRange(value);
if (triggerRange == null) {
_clearMentionSuggestions();
return;
}
final query = value.text.substring(
triggerRange.start + 1,
triggerRange.end,
);
final contacts = _buildMentionSuggestions(query);
if (!mounted) {
return;
}
setState(() {
_activeMentionRange = triggerRange;
_mentionQuery = query;
_mentionSuggestions = contacts;
});
}
void _clearMentionSuggestions() {
if (_activeMentionRange == null &&
_mentionQuery.isEmpty &&
_mentionSuggestions.isEmpty) {
return;
}
setState(() {
_activeMentionRange = null;
_mentionQuery = '';
_mentionSuggestions = const [];
});
}
TextRange? _getMentionTriggerRange(TextEditingValue value) {
if (!value.selection.isValid || !value.selection.isCollapsed) {
return null;
}
final cursorOffset = value.selection.baseOffset;
if (cursorOffset <= 0) {
return null;
}
final text = value.text;
final triggerStart = text.lastIndexOf('@', cursorOffset - 1);
if (triggerStart == -1) {
return null;
}
final leadingChar = triggerStart > 0 ? text[triggerStart - 1] : null;
if (leadingChar != null && !RegExp(r'[\s\(\[\{]').hasMatch(leadingChar)) {
return null;
}
final query = text.substring(triggerStart + 1, cursorOffset);
if (query.contains(RegExp(r'[\s@\[\]\n\r]'))) {
return null;
}
return TextRange(start: triggerStart, end: cursorOffset);
}
List<Contact> _buildMentionSuggestions(String query) {
final contactsProvider = context.read<ContactsProvider>();
final normalizedQuery = query.trim().toLowerCase();
final contacts =
contactsProvider.contacts
.where((contact) => contact.type == ContactType.chat)
.where((contact) {
if (normalizedQuery.isEmpty) return true;
return contact.displayName.toLowerCase().contains(
normalizedQuery,
);
})
.toList()
..sort((a, b) {
final aName = a.displayName.toLowerCase();
final bName = b.displayName.toLowerCase();
final aStarts =
normalizedQuery.isNotEmpty && aName.startsWith(normalizedQuery);
final bStarts =
normalizedQuery.isNotEmpty && bName.startsWith(normalizedQuery);
if (aStarts != bStarts) {
return aStarts ? -1 : 1;
}
return aName.compareTo(bName);
});
return contacts.take(8).toList(growable: false);
}
int get _maxMessageBytes =>
_destinationType == MessageDestinationPreferences.destinationTypeChannel
? _maxChannelMessageBytes
@@ -258,6 +406,7 @@ class _MessagesTabState extends State<MessagesTab> {
/// Show recipient selector bottom sheet
void _showRecipientSelector() {
final contactsProvider = context.read<ContactsProvider>();
final messagesProvider = context.read<MessagesProvider>();
// Filter contacts by type
final contacts = contactsProvider.contacts
@@ -278,6 +427,13 @@ class _MessagesTabState extends State<MessagesTab> {
contacts: contacts,
rooms: rooms,
channels: channels,
unreadCount: messagesProvider.unreadCount,
unreadCountsByPublicKey: {
for (final contact in [...contacts, ...rooms, ...channels])
contact.publicKeyHex: messagesProvider.getUnreadCountForDestination(
contact,
),
},
currentDestinationType: _destinationType,
currentRecipientPublicKey: _selectedRecipient?.publicKeyHex,
onSelect: _onRecipientSelected,
@@ -292,6 +448,8 @@ class _MessagesTabState extends State<MessagesTab> {
_selectedRecipient = recipient;
});
_markCurrentDestinationAsRead();
_enforceMessageByteLimit();
// Save to preferences
@@ -304,9 +462,139 @@ class _MessagesTabState extends State<MessagesTab> {
if (!mounted) return;
}
Future<void> _applyPendingDestination({
required String type,
String? recipientPublicKeyHex,
}) async {
Contact? recipient;
if (recipientPublicKeyHex != null) {
final contactsProvider = context.read<ContactsProvider>();
recipient = contactsProvider.contacts.where((contact) {
return contact.publicKeyHex == recipientPublicKeyHex;
}).firstOrNull;
}
await _onRecipientSelected(type, recipient);
if (!mounted) return;
_focusNode.requestFocus();
}
void _insertReplyMention(String displayName, {TextRange? replacementRange}) {
final trimmedName = displayName.trim();
if (trimmedName.isEmpty) return;
final mention = '@[$trimmedName] ';
final value = _textController.value;
final selection = replacementRange ?? value.selection;
final hasSelection =
selection.isValid &&
selection.start >= 0 &&
selection.end >= selection.start;
final start = hasSelection ? selection.start : value.text.length;
final end = hasSelection ? selection.end : value.text.length;
final nextText = value.text.replaceRange(start, end, mention);
final nextOffset = start + mention.length;
_suppressMentionTrigger = true;
_textController.value = value.copyWith(
text: nextText,
selection: TextSelection.collapsed(offset: nextOffset),
composing: TextRange.empty,
);
_suppressMentionTrigger = false;
_clearMentionSuggestions();
_enforceMessageByteLimit();
}
void _selectMention(Contact contact) {
_insertReplyMention(
contact.displayName,
replacementRange: _activeMentionRange,
);
_focusNode.requestFocus();
}
Future<void> _replyToMessage(Message message) async {
final l10n = AppLocalizations.of(context)!;
final contactsProvider = context.read<ContactsProvider>();
Contact? senderContact;
String? senderDisplayName;
String destinationType;
Contact? recipient;
if (message.isChannelMessage) {
final channelIdx = message.channelIdx ?? 0;
destinationType = MessageDestinationPreferences.destinationTypeChannel;
if (channelIdx != 0) {
recipient = contactsProvider.channels.where((contact) {
return contact.publicKey.length > 1 &&
contact.publicKey[1] == channelIdx;
}).firstOrNull;
if (recipient == null) {
ToastLogger.error(context, l10n.cannotReplyContactNotFound);
return;
}
}
final senderPrefix = message.senderPublicKeyPrefix;
if (senderPrefix != null && senderPrefix.length >= 6) {
senderContact = contactsProvider.findContactByPrefix(senderPrefix);
}
senderDisplayName =
senderContact?.displayName ?? message.senderName?.trim();
} else {
final roomRecipient = message.recipientPublicKey == null
? null
: contactsProvider.findContactByKey(message.recipientPublicKey!);
if (roomRecipient?.isRoom == true) {
destinationType = MessageDestinationPreferences.destinationTypeRoom;
recipient = roomRecipient;
final senderPrefix = message.senderPublicKeyPrefix;
if (senderPrefix != null && senderPrefix.length >= 6) {
senderContact = contactsProvider.findContactByPrefix(senderPrefix);
}
senderDisplayName =
senderContact?.displayName ?? message.senderName?.trim();
} else {
final senderPrefix = message.senderPublicKeyPrefix;
if (senderPrefix == null || senderPrefix.length < 6) {
ToastLogger.error(context, l10n.cannotReplySenderMissing);
return;
}
recipient = contactsProvider.findContactByPrefix(senderPrefix);
if (recipient == null) {
ToastLogger.error(context, l10n.cannotReplyContactNotFound);
return;
}
destinationType = recipient.isRoom
? MessageDestinationPreferences.destinationTypeRoom
: MessageDestinationPreferences.destinationTypeContact;
}
}
await _onRecipientSelected(destinationType, recipient);
if (!mounted) return;
if ((message.isChannelMessage || recipient?.isRoom == true) &&
senderDisplayName != null &&
senderDisplayName.isNotEmpty) {
_insertReplyMention(senderDisplayName);
}
_focusNode.requestFocus();
}
/// Get icon for current destination type
IconData _getDestinationIcon() {
if (_destinationType ==
if (_destinationType == MessageDestinationPreferences.destinationTypeAll) {
return Icons.all_inbox;
} else if (_destinationType ==
MessageDestinationPreferences.destinationTypeChannel) {
return Icons.public;
} else if (_destinationType ==
@@ -318,6 +606,9 @@ class _MessagesTabState extends State<MessagesTab> {
}
String _getDestinationLabel() {
if (_destinationType == MessageDestinationPreferences.destinationTypeAll) {
return AppLocalizations.of(context)!.showAll;
}
if (_destinationType ==
MessageDestinationPreferences.destinationTypeChannel &&
_selectedRecipient != null) {
@@ -343,6 +634,15 @@ class _MessagesTabState extends State<MessagesTab> {
return;
}
if (_destinationType == MessageDestinationPreferences.destinationTypeAll) {
if (!mounted) return;
ToastLogger.error(context, 'Select a channel, contact, or room first');
return;
}
_textController.clear();
_focusNode.unfocus();
try {
// Check destination type and send accordingly
if (_destinationType ==
@@ -373,12 +673,18 @@ class _MessagesTabState extends State<MessagesTab> {
await _sendToChannel(text, connectionProvider, messagesProvider, 0);
}
_textController.clear();
_focusNode.unfocus();
_markCurrentDestinationAsRead();
if (!mounted) return;
} catch (e) {
if (!mounted) return;
if (_textController.text.isEmpty) {
_textController.text = text;
_textController.selection = TextSelection.collapsed(
offset: _textController.text.length,
);
_focusNode.requestFocus();
}
ToastLogger.error(context, 'Failed to send: $e');
}
}
@@ -692,7 +998,7 @@ class _MessagesTabState extends State<MessagesTab> {
Future<void> _startVoiceRecording() async {
if (_isSendingVoice || _isRecording) return;
debugPrint('🎙️ [Voice] _startVoiceRecording called');
// Read fresh bitrate preference so settings changes apply immediately.
// Read fresh voice preferences so settings changes apply immediately.
final selectedBitrate = await VoiceBitratePreferences.getBitrate();
if (mounted) {
setState(() {
@@ -731,11 +1037,12 @@ class _MessagesTabState extends State<MessagesTab> {
_selectedVoiceBitrate,
);
final packetDuration = Duration(
milliseconds: codec2ModeFor(_activeVoiceMode!).packetDurationMs,
milliseconds: _activeVoiceMode!.packetDurationMs,
);
final maxVoicePackets = _maxVoicePacketsForMode(_activeVoiceMode!);
debugPrint(
'🎙️ [Voice] session=$_currentVoiceSessionId mode=$_activeVoiceMode chunkDuration=${packetDuration.inMilliseconds}ms',
'🎙️ [Voice] session=$_currentVoiceSessionId mode=$_activeVoiceMode chunkDuration=${packetDuration.inMilliseconds}ms maxPackets=$maxVoicePackets',
);
_recordedChunks.clear();
@@ -744,9 +1051,13 @@ class _MessagesTabState extends State<MessagesTab> {
try {
final stream = _voiceRecorder.startCapture(
chunkDuration: packetDuration,
sampleRateHz: _activeVoiceMode!.sampleRateHz,
enableBandPassFilter: appProvider.isVoiceBandPassFilterEnabled,
enableCompressor: appProvider.isVoiceCompressorEnabled,
enableLimiter: appProvider.isVoiceLimiterEnabled,
enableAutoGain: appProvider.isVoiceAutoGainEnabled,
enableEchoCancellation: appProvider.isVoiceEchoCancellationEnabled,
enableNoiseSuppression: appProvider.isVoiceNoiseSuppressionEnabled,
);
debugPrint('🎙️ [Voice] capture started, listening for chunks...');
_voiceStreamSub = stream.listen(
@@ -757,7 +1068,7 @@ class _MessagesTabState extends State<MessagesTab> {
'🎙️ [Voice] chunk #${_recordedChunks.length} received: ${pcmChunk.length} samples',
);
setState(() {});
if (_recordedChunks.length >= _maxVoicePackets) {
if (_recordedChunks.length >= maxVoicePackets) {
debugPrint('🎙️ [Voice] max packets reached, stopping');
_stopAndSendVoice();
}
@@ -774,6 +1085,12 @@ class _MessagesTabState extends State<MessagesTab> {
}
}
int _maxVoicePacketsForMode(VoicePacketMode mode) {
final packets =
_maxVoiceRecordingDuration.inMilliseconds ~/ mode.packetDurationMs;
return packets < 1 ? 1 : packets;
}
Future<void> _stopAndSendVoice() async {
if (!_isRecording) return;
final trimSilenceEnabled = context
@@ -788,9 +1105,14 @@ class _MessagesTabState extends State<MessagesTab> {
await _voiceRecorder.stopCapture();
final rawChunks = List<Int16List>.from(_recordedChunks);
final chunks = trimSilenceEnabled ? _trimSilence(rawChunks) : rawChunks;
final trimmedChunks = trimSilenceEnabled
? _trimSilence(rawChunks)
: rawChunks;
final sessionId = _currentVoiceSessionId;
final mode = _activeVoiceMode;
final chunks = mode == null
? trimmedChunks
: _prepareChunksForSending(trimmedChunks, mode);
_recordedChunks.clear();
debugPrint(
@@ -979,6 +1301,13 @@ class _MessagesTabState extends State<MessagesTab> {
messagesProvider.markMessageSent(msgId, 0, 0);
}
List<Int16List> _prepareChunksForSending(
List<Int16List> chunks,
VoicePacketMode mode,
) {
return chunks;
}
List<Int16List> _trimSilence(List<Int16List> chunks) {
if (chunks.isEmpty) return chunks;
@@ -1008,16 +1337,28 @@ class _MessagesTabState extends State<MessagesTab> {
bool _isSilentChunk(Int16List chunk) {
if (chunk.isEmpty) return true;
final rms = _chunkRms(chunk);
final peak = _chunkPeak(chunk);
return rms < _silenceRmsThreshold && peak < _silencePeakThreshold;
}
double _chunkRms(Int16List chunk) {
var sumSquares = 0.0;
for (final sample in chunk) {
sumSquares += sample * sample;
}
return math.sqrt(sumSquares / chunk.length);
}
int _chunkPeak(Int16List chunk) {
var peak = 0;
for (final sample in chunk) {
final absSample = sample.abs();
if (absSample > peak) peak = absSample;
sumSquares += sample * sample;
if (absSample > peak) {
peak = absSample;
}
}
final rms = math.sqrt(sumSquares / chunk.length);
return rms < _silenceRmsThreshold && peak < _silencePeakThreshold;
return peak;
}
bool _isPublicChannelSelected() {
@@ -1107,6 +1448,15 @@ class _MessagesTabState extends State<MessagesTab> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.search),
title: const Text('Search messages'),
onTap: () async {
await _runAfterSheetDismissal(sheetContext, () async {
_showFilteredMessageSearch();
});
},
),
ListTile(
leading: const Icon(Icons.add_location_alt),
title: Text(AppLocalizations.of(context)!.sendSarMarker),
@@ -1174,6 +1524,116 @@ class _MessagesTabState extends State<MessagesTab> {
);
}
void _showFilteredMessageSearch() {
final messagesProvider = context.read<MessagesProvider>();
final scopedMessages = _getFilteredMessages(messagesProvider);
final searchController = TextEditingController();
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Theme.of(context).colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (sheetContext) {
return StatefulBuilder(
builder: (context, setModalState) {
final query = searchController.text.trim().toLowerCase();
final matches = query.isEmpty
? scopedMessages
: scopedMessages.where((message) {
return message.text.toLowerCase().contains(query) ||
(message.senderName?.toLowerCase().contains(query) ??
false);
}).toList();
return SafeArea(
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(sheetContext).viewInsets.bottom,
),
child: SizedBox(
height: MediaQuery.of(sheetContext).size.height * 0.72,
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
child: TextField(
controller: searchController,
autofocus: true,
decoration: InputDecoration(
hintText: 'Search in current filter',
prefixIcon: const Icon(Icons.search),
suffixIcon: searchController.text.isEmpty
? null
: IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
searchController.clear();
setModalState(() {});
},
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
),
),
onChanged: (_) => setModalState(() {}),
),
),
Expanded(
child: matches.isEmpty
? Center(
child: Text(
query.isEmpty
? 'No messages in this filter'
: 'No matches in this filter',
),
)
: ListView.separated(
itemCount: matches.length,
separatorBuilder: (_, _) =>
const Divider(height: 1),
itemBuilder: (context, index) {
final message = matches[index];
final title =
message.senderName?.trim().isNotEmpty ==
true
? message.senderName!
: message.isSentMessage
? 'You'
: _getDestinationLabel();
return ListTile(
title: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
message.text,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
onTap: () {
Navigator.pop(sheetContext);
_scrollToMessage(message.id);
},
);
},
),
),
],
),
),
),
);
},
);
},
).whenComplete(searchController.dispose);
}
Widget _buildDestinationAvatar(BuildContext context) {
final recipient = _selectedRecipient;
if (recipient != null) {
@@ -1432,6 +1892,65 @@ class _MessagesTabState extends State<MessagesTab> {
}
}
void _markCurrentDestinationAsRead() {
_channelReadTimer?.cancel();
_pendingChannelReadKey = null;
context.read<MessagesProvider>().markDestinationAsRead(
destinationType: _destinationType,
contact: _selectedRecipient,
);
}
void _syncChannelAutoReadTimer(MessagesProvider messagesProvider) {
if (!widget.isActive ||
_destinationType !=
MessageDestinationPreferences.destinationTypeChannel) {
_channelReadTimer?.cancel();
_pendingChannelReadKey = null;
return;
}
final channelIdx = _selectedRecipient?.publicKey[1] ?? 0;
final unreadCount = messagesProvider.getUnreadCountForChannel(channelIdx);
if (unreadCount <= 0) {
_channelReadTimer?.cancel();
_pendingChannelReadKey = null;
return;
}
final nextKey = '$channelIdx:$unreadCount';
if (_pendingChannelReadKey == nextKey &&
_channelReadTimer?.isActive == true) {
return;
}
_channelReadTimer?.cancel();
_pendingChannelReadKey = nextKey;
_channelReadTimer = Timer(_channelAutoReadDelay, () {
if (!mounted || !widget.isActive) {
return;
}
if (_destinationType !=
MessageDestinationPreferences.destinationTypeChannel) {
return;
}
final currentChannelIdx = _selectedRecipient?.publicKey[1] ?? 0;
if (currentChannelIdx != channelIdx) {
return;
}
final latestProvider = context.read<MessagesProvider>();
if (latestProvider.getUnreadCountForChannel(channelIdx) <= 0) {
return;
}
_markCurrentDestinationAsRead();
});
}
List<Message> _getFilteredMessages(MessagesProvider messagesProvider) {
// Get all recent messages
final allMessages = messagesProvider.getRecentMessages(count: 100);
@@ -1439,17 +1958,18 @@ class _MessagesTabState extends State<MessagesTab> {
List<Message> filteredMessages;
// If channel destination is selected, filter by selected channel.
if (_destinationType ==
if (_destinationType == MessageDestinationPreferences.destinationTypeAll) {
filteredMessages = allMessages;
} else if (_destinationType ==
MessageDestinationPreferences.destinationTypeChannel) {
final selectedChannelIdx = _selectedRecipient?.publicKey[1] ?? 0;
if (selectedChannelIdx == 0) {
// Public channel view keeps showing all messages (current app behavior).
filteredMessages = allMessages;
} else {
filteredMessages = allMessages
.where((message) => message.channelIdx == selectedChannelIdx)
.toList();
}
filteredMessages = allMessages
.where(
(message) =>
message.isChannelMessage &&
(message.channelIdx ?? 0) == selectedChannelIdx,
)
.toList();
}
// If a contact or room is selected, filter by recipient/sender prefixes.
else if ((_destinationType ==
@@ -1458,6 +1978,10 @@ class _MessagesTabState extends State<MessagesTab> {
MessageDestinationPreferences.destinationTypeRoom) &&
_selectedRecipient != null) {
filteredMessages = allMessages.where((message) {
if (!message.isContactMessage) {
return false;
}
// Include messages sent TO this recipient
if (message.recipientPublicKey != null &&
message.recipientPublicKey!.length >= 6 &&
@@ -1519,7 +2043,10 @@ class _MessagesTabState extends State<MessagesTab> {
Widget build(BuildContext context) {
return Consumer<MessagesProvider>(
builder: (context, messagesProvider, child) {
final messages = _getFilteredMessages(messagesProvider);
_syncChannelAutoReadTimer(messagesProvider);
final messages = messagesProvider.buildDisplayMessages(
_getFilteredMessages(messagesProvider),
);
final bottomInset = MediaQuery.of(context).viewPadding.bottom;
final composerBottomPadding = bottomInset > 0 ? 2.0 : 10.0;
@@ -1538,6 +2065,7 @@ class _MessagesTabState extends State<MessagesTab> {
onRefresh: _handleRefresh,
onNavigateToMap: widget.onNavigateToMap,
onMessageTap: _handleMessageTap,
onReplyToMessage: _replyToMessage,
),
),
Positioned(
@@ -1556,6 +2084,9 @@ class _MessagesTabState extends State<MessagesTab> {
bottomPadding: composerBottomPadding,
destinationLabel: _getDestinationLabel(),
destinationAvatar: _buildDestinationAvatar(context),
mentionSuggestions: _mentionSuggestions,
mentionQuery: _mentionQuery,
onMentionSelected: _selectMention,
onShowComposerActions: _showComposerActions,
onShowRecipientSelector: _showRecipientSelector,
onStartVoiceRecording: _startVoiceRecording,

View File

@@ -8,6 +8,8 @@ import 'package:meshcore_client/meshcore_client.dart';
import '../l10n/app_localizations.dart';
import '../providers/connection_provider.dart';
import '../providers/contacts_provider.dart';
import '../services/mesh_map_nodes_service.dart';
import '../services/route_hash_preferences.dart';
import '../utils/log_rx_route_decoder.dart';
class PacketLogScreen extends StatefulWidget {
@@ -460,26 +462,6 @@ class _PacketLogCard extends StatelessWidget {
final isRx = log.direction == PacketDirection.rx;
final directionColor = isRx ? Colors.green : Colors.blue;
final rxInfo = log.logRxDataInfo;
final contacts = context.watch<ContactsProvider>().contacts;
final connectionProvider = context.watch<ConnectionProvider>();
final decodedRoute = LogRxRouteDecoder.decode(log.rawData);
final ownPublicKey = connectionProvider.deviceInfo.publicKey;
final ownName =
connectionProvider.deviceInfo.selfName ??
connectionProvider.deviceInfo.displayName;
final resolvedPath = decodedRoute?.pathHashes
.map(
(hash) => LogRxRouteDecoder.resolveHash(
hash,
contacts: contacts,
ownPublicKey: ownPublicKey,
ownName: ownName,
),
)
.toList();
final originalSender = resolvedPath != null && resolvedPath.isNotEmpty
? resolvedPath.first
: null;
return Card(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
@@ -604,13 +586,9 @@ class _PacketLogCard extends StatelessWidget {
),
),
],
if (isRx && decodedRoute != null) ...[
if (isRx) ...[
const SizedBox(height: 12),
_RouteSection(
route: decodedRoute,
path: resolvedPath ?? const [],
originalSender: originalSender,
),
_DecodedRouteSection(log: log),
],
const SizedBox(height: 12),
Container(
@@ -755,16 +733,96 @@ class _PacketLogCard extends StatelessWidget {
}
}
class _DecodedRouteSection extends StatelessWidget {
final BlePacketLog log;
const _DecodedRouteSection({required this.log});
@override
Widget build(BuildContext context) {
final contacts = context.watch<ContactsProvider>().contacts;
final connectionProvider = context.watch<ConnectionProvider>();
final ownPublicKey = connectionProvider.deviceInfo.publicKey;
final ownName =
connectionProvider.deviceInfo.selfName ??
connectionProvider.deviceInfo.displayName;
return FutureBuilder<List<dynamic>>(
future: Future.wait<dynamic>([
RouteHashPreferences.getHashSize(),
MeshMapNodesService.loadCachedNodes(
cacheTtl: MeshMapNodesService.traceCacheTtl,
),
]),
builder: (context, snapshot) {
final decodedRoute = LogRxRouteDecoder.decode(
log.rawData,
preferredHashSize: snapshot.data?.first as int?,
);
if (decodedRoute == null) {
return const SizedBox.shrink();
}
final cachedNodes = snapshot.hasData
? snapshot.data![1] as List<MeshMapNode>
: const <MeshMapNode>[];
final resolvedPath = decodedRoute.hopHashes
.map(
(hashHex) => _resolveHashWithFallback(
hashHex,
contacts: contacts,
cachedNodes: cachedNodes,
ownPublicKey: ownPublicKey,
ownName: ownName,
),
)
.toList();
return _RouteSection(route: decodedRoute, path: resolvedPath);
},
);
}
ResolvedNodeHash _resolveHashWithFallback(
String hashHex, {
required List<Contact> contacts,
required List<MeshMapNode> cachedNodes,
required Uint8List? ownPublicKey,
required String? ownName,
}) {
final localResolved = LogRxRouteDecoder.resolveHash(
hashHex,
contacts: contacts,
ownPublicKey: ownPublicKey,
ownName: ownName,
);
if (localResolved.matchCount > 0 || localResolved.isOwnNode) {
return localResolved;
}
final normalizedHashHex = hashHex.toLowerCase();
final matches = cachedNodes
.where((node) => node.publicKey.startsWith(normalizedHashHex))
.toList();
if (matches.isEmpty) {
return localResolved;
}
matches.sort((a, b) => b.updatedAtMs.compareTo(a.updatedAtMs));
return ResolvedNodeHash(
hashHex: normalizedHashHex,
label: matches.first.name,
isOwnNode: false,
isUniqueMatch: matches.length == 1,
matchCount: matches.length,
);
}
}
class _RouteSection extends StatelessWidget {
final DecodedLogRxRoute route;
final List<ResolvedNodeHash> path;
final ResolvedNodeHash? originalSender;
const _RouteSection({
required this.route,
required this.path,
required this.originalSender,
});
const _RouteSection({required this.route, required this.path});
@override
Widget build(BuildContext context) {
@@ -802,14 +860,14 @@ class _RouteSection extends StatelessWidget {
_FactCard(
icon: Icons.hub,
label: 'Hops',
value: '${route.pathHashes.length}',
value: '${route.hopCount}',
),
_FactCard(
icon: Icons.tag,
label: 'Hash size',
value:
'${route.hashSize} byte${route.hashSize == 1 ? '' : 's'}',
),
if (originalSender != null)
_FactCard(
icon: Icons.person_pin_circle,
label: 'Original sender',
value: _nodeLabel(originalSender!),
),
],
),
const SizedBox(height: 12),

View File

@@ -0,0 +1,775 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart' as flutter_map;
import 'package:geolocator/geolocator.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
import '../models/contact.dart';
import '../providers/connection_provider.dart';
import '../providers/contacts_provider.dart';
import '../services/mesh_map_nodes_service.dart';
class RepeatersMapScreen extends StatefulWidget {
const RepeatersMapScreen({super.key});
@override
State<RepeatersMapScreen> createState() => _RepeatersMapScreenState();
}
class _RepeatersMapScreenState extends State<RepeatersMapScreen> {
static const LatLng _fallbackCenter = LatLng(46.0569, 14.5058);
static const double _fallbackZoom = 7;
static const double _myLocationZoom = 14;
final flutter_map.MapController _mapController = flutter_map.MapController();
List<MeshMapNode> _onlineRepeaters = const [];
bool _isLoading = true;
bool _didFitCamera = false;
String? _error;
double _currentZoom = _fallbackZoom;
LatLng? _myLocation;
final Set<String> _addingRepeaters = <String>{};
@override
void initState() {
super.initState();
_loadRepeaters();
}
Future<void> _loadRepeaters({bool forceRefresh = false}) async {
setState(() {
_isLoading = true;
_error = null;
if (forceRefresh) {
_didFitCamera = false;
}
});
try {
final nodes = await MeshMapNodesService.fetchNodes(
forceRefresh: forceRefresh,
);
final repeaters = nodes.where(MeshMapNodesService.isRepeater).toList();
if (!mounted) return;
setState(() {
_onlineRepeaters = repeaters;
_isLoading = false;
});
_refreshMyLocation();
} catch (error) {
final cached = await MeshMapNodesService.loadCachedNodes();
final repeaters = cached.where(MeshMapNodesService.isRepeater).toList();
if (!mounted) return;
setState(() {
_onlineRepeaters = repeaters;
_isLoading = false;
_error = repeaters.isEmpty
? 'Unable to load repeaters right now.'
: 'Showing cached repeaters. Pull to retry.';
});
_refreshMyLocation();
}
}
List<_MapRepeater> _mergeRepeaters({
required List<MeshMapNode> onlineNodes,
required List<Contact> contactRepeaters,
}) {
final merged = <String, _MapRepeater>{};
for (final node in onlineNodes) {
merged[node.publicKey] = _MapRepeater(
publicKey: node.publicKey,
name: node.name,
latitude: node.latitude,
longitude: node.longitude,
isFromContacts: false,
isFromOnline: true,
);
}
for (final contact in contactRepeaters) {
final location = contact.displayLocation;
if (location == null) {
continue;
}
final key = contact.publicKeyHex.toLowerCase();
final existing = merged[key];
merged[key] = _MapRepeater(
publicKey: key,
name: contact.displayName.isNotEmpty
? contact.displayName
: existing?.name ?? 'Unknown repeater',
latitude: existing?.latitude ?? location.latitude,
longitude: existing?.longitude ?? location.longitude,
isFromContacts: true,
isFromOnline: existing?.isFromOnline ?? false,
);
}
final repeaters = merged.values.toList()
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
return repeaters;
}
Future<void> _refreshMyLocation() async {
try {
final position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.best,
distanceFilter: 0,
),
);
if (!mounted) return;
setState(() {
_myLocation = LatLng(position.latitude, position.longitude);
});
} catch (_) {
// Best effort only.
}
}
void _fitCameraIfNeeded(List<_MapRepeater> repeaters) {
if (_didFitCamera || repeaters.isEmpty || !mounted) {
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _didFitCamera || repeaters.isEmpty) {
return;
}
if (repeaters.length == 1) {
final repeater = repeaters.first;
_mapController.move(LatLng(repeater.latitude, repeater.longitude), 13);
_currentZoom = 13;
} else {
final bounds = flutter_map.LatLngBounds.fromPoints(
repeaters
.map((node) => LatLng(node.latitude, node.longitude))
.toList(),
);
_mapController.fitCamera(
flutter_map.CameraFit.bounds(
bounds: bounds,
padding: const EdgeInsets.all(48),
),
);
}
_didFitCamera = true;
});
}
void _showRepeaterDetails(_MapRepeater repeater) {
final isAdding = _addingRepeaters.contains(repeater.publicKey);
final canAdd = !repeater.isFromContacts;
final isConnected = context
.read<ConnectionProvider>()
.deviceInfo
.isConnected;
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (context) => SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
repeater.name,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 12),
_InfoLine(
label: 'Public key',
value: repeater.publicKey,
monospace: true,
),
_InfoLine(
label: 'Coordinates',
value:
'${repeater.latitude.toStringAsFixed(5)}, ${repeater.longitude.toStringAsFixed(5)}',
),
_InfoLine(label: 'Source', value: repeater.sourceLabel),
const SizedBox(height: 12),
if (canAdd)
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: !isConnected || isAdding
? null
: () async {
Navigator.of(context).pop();
await _addRepeaterToContacts(repeater);
},
icon: isAdding
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.person_add_alt_1),
label: Text(
!isConnected
? 'Connect device to add'
: 'Add to contacts',
),
),
)
else
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: null,
icon: const Icon(Icons.check_circle),
label: const Text('Already in contacts'),
),
),
],
),
),
),
);
}
Future<void> _addRepeaterToContacts(_MapRepeater repeater) async {
if (_addingRepeaters.contains(repeater.publicKey)) {
return;
}
final connectionProvider = context.read<ConnectionProvider>();
if (!connectionProvider.deviceInfo.isConnected) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Connect to a device before adding contacts'),
),
);
return;
}
setState(() {
_addingRepeaters.add(repeater.publicKey);
});
try {
await connectionProvider.getContact(_hexToBytes(repeater.publicKey));
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${repeater.name} added to contacts')),
);
} catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to add ${repeater.name}: $error')),
);
} finally {
if (mounted) {
setState(() {
_addingRepeaters.remove(repeater.publicKey);
});
}
}
}
Uint8List _hexToBytes(String hex) {
final normalized = hex.replaceAll(':', '').trim().toLowerCase();
return Uint8List.fromList(
List<int>.generate(
normalized.length ~/ 2,
(index) => int.parse(
normalized.substring(index * 2, index * 2 + 2),
radix: 16,
),
),
);
}
@override
Widget build(BuildContext context) {
final contactsProvider = context.watch<ContactsProvider>();
final repeaters = _mergeRepeaters(
onlineNodes: _onlineRepeaters,
contactRepeaters: contactsProvider.repeaters,
);
_fitCameraIfNeeded(repeaters);
final clusters = _buildClusters(repeaters);
final markers = clusters.map((cluster) {
final markerColor = switch (cluster.status) {
_ClusterStatus.contactsOnly => const Color(0xFF7C3AED),
_ClusterStatus.onlineOnly => const Color(0xFFE8681D),
_ClusterStatus.both => const Color(0xFF1B8F4F),
_ClusterStatus.mixed => const Color(0xFF2563EB),
};
if (cluster.members.length == 1) {
final repeater = cluster.members.first;
return flutter_map.Marker(
point: cluster.center,
width: 80,
height: 100,
child: GestureDetector(
onTap: () => _showRepeaterDetails(repeater),
child: _RepeaterMarker(color: markerColor, label: repeater.name),
),
);
}
return flutter_map.Marker(
point: cluster.center,
width: 48,
height: 48,
child: GestureDetector(
onTap: () => _zoomToCluster(cluster),
child: _ClusterMarker(
color: markerColor,
count: cluster.members.length,
),
),
);
}).toList();
final allMarkers = <flutter_map.Marker>[
...markers,
if (_myLocation != null)
flutter_map.Marker(
point: _myLocation!,
width: 24,
height: 24,
child: Container(
decoration: BoxDecoration(
color: const Color(0xFF2563EB),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 3),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.18),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
),
),
];
return Scaffold(
appBar: AppBar(
title: const Text('Repeaters Map'),
actions: [
IconButton(
onPressed: _isLoading
? null
: () => _loadRepeaters(forceRefresh: true),
icon: const Icon(Icons.refresh),
tooltip: 'Refresh',
),
],
),
body: Stack(
children: [
flutter_map.FlutterMap(
mapController: _mapController,
options: flutter_map.MapOptions(
initialCenter: _fallbackCenter,
initialZoom: _fallbackZoom,
onPositionChanged: (camera, hasGesture) {
final nextZoom = camera.zoom;
if (nextZoom != _currentZoom && mounted) {
setState(() {
_currentZoom = nextZoom;
});
}
},
),
children: [
flutter_map.TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'app.meshcore.sar',
),
flutter_map.MarkerLayer(markers: allMarkers),
],
),
if (_error != null)
Positioned(
top: 16,
left: 16,
right: 16,
child: Material(
elevation: 2,
borderRadius: BorderRadius.circular(14),
color: Theme.of(context).colorScheme.surface,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: Text(_error!),
),
),
),
if (_isLoading) const Center(child: CircularProgressIndicator()),
if (!_isLoading && repeaters.isEmpty)
Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
_error ?? 'No repeaters available.',
textAlign: TextAlign.center,
),
),
),
if (repeaters.isNotEmpty)
Positioned(
left: 16,
top: _error == null ? 16 : 72,
child: Material(
elevation: 2,
borderRadius: BorderRadius.circular(14),
color: Theme.of(context).colorScheme.surface,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 10,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: const [
_LegendRow(
color: Color(0xFF7C3AED),
label: 'From contacts',
),
SizedBox(height: 8),
_LegendRow(
color: Color(0xFFE8681D),
label: 'Online only',
),
SizedBox(height: 8),
_LegendRow(color: Color(0xFF1B8F4F), label: 'In both'),
],
),
),
),
),
if (repeaters.isNotEmpty)
Positioned(
left: 16,
right: 16,
bottom: 16,
child: Material(
elevation: 3,
borderRadius: BorderRadius.circular(16),
color: Theme.of(context).colorScheme.surface,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: Text(
'${repeaters.length} repeaters',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: _jumpToMyLocation,
tooltip: 'My location',
child: const Icon(Icons.my_location),
),
);
}
Future<void> _jumpToMyLocation() async {
await _refreshMyLocation();
final myLocation = _myLocation;
if (!mounted || myLocation == null) {
return;
}
_mapController.move(myLocation, _myLocationZoom);
setState(() {
_currentZoom = _myLocationZoom;
});
}
List<_RepeaterCluster> _buildClusters(List<_MapRepeater> repeaters) {
if (repeaters.isEmpty) {
return const [];
}
if (_currentZoom >= 11.5) {
return repeaters
.map(
(repeater) => _RepeaterCluster(
members: [repeater],
center: LatLng(repeater.latitude, repeater.longitude),
status: repeater.clusterStatus,
),
)
.toList();
}
final cellSize = _gridSizeForZoom(_currentZoom);
final buckets = <String, List<_MapRepeater>>{};
for (final repeater in repeaters) {
final latBucket = (repeater.latitude / cellSize).floor();
final lonBucket = (repeater.longitude / cellSize).floor();
final key = '$latBucket:$lonBucket';
buckets.putIfAbsent(key, () => <_MapRepeater>[]).add(repeater);
}
return buckets.values.map((members) {
final centerLat =
members.map((node) => node.latitude).reduce((a, b) => a + b) /
members.length;
final centerLon =
members.map((node) => node.longitude).reduce((a, b) => a + b) /
members.length;
final statuses = members.map((node) => node.clusterStatus).toSet();
return _RepeaterCluster(
members: members,
center: LatLng(centerLat, centerLon),
status: statuses.length == 1 ? statuses.first : _ClusterStatus.mixed,
);
}).toList();
}
double _gridSizeForZoom(double zoom) {
if (zoom < 5) return 3.0;
if (zoom < 7) return 1.5;
if (zoom < 9) return 0.7;
if (zoom < 10.5) return 0.28;
return 0.12;
}
void _zoomToCluster(_RepeaterCluster cluster) {
if (cluster.members.isEmpty) {
return;
}
final bounds = flutter_map.LatLngBounds.fromPoints(
cluster.members
.map((node) => LatLng(node.latitude, node.longitude))
.toList(),
);
_mapController.fitCamera(
flutter_map.CameraFit.bounds(
bounds: bounds,
padding: const EdgeInsets.all(64),
maxZoom: (_currentZoom + 2).clamp(8, 14),
),
);
}
}
class _InfoLine extends StatelessWidget {
final String label;
final String value;
final bool monospace;
const _InfoLine({
required this.label,
required this.value,
this.monospace = false,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 92,
child: Text(
label,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
),
),
Expanded(
child: Text(
value,
style: monospace
? const TextStyle(fontFamily: 'monospace')
: null,
),
),
],
),
);
}
}
class _LegendRow extends StatelessWidget {
final Color color;
final String label;
const _LegendRow({required this.color, required this.label});
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 12,
height: 12,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 8),
Text(label),
],
);
}
}
class _RepeaterMarker extends StatelessWidget {
final Color color;
final String label;
const _RepeaterMarker({required this.color, required this.label});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
padding: const EdgeInsets.all(6),
child: const Icon(Icons.router, color: Colors.white, size: 18),
),
const SizedBox(height: 2),
Container(
constraints: const BoxConstraints(maxWidth: 80),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(3),
),
child: Text(
label,
style: const TextStyle(
color: Colors.white,
fontSize: 9,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
textAlign: TextAlign.center,
),
),
],
);
}
}
class _ClusterMarker extends StatelessWidget {
final Color color;
final int count;
const _ClusterMarker({required this.color, required this.count});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.22),
blurRadius: 10,
offset: const Offset(0, 3),
),
],
),
alignment: Alignment.center,
child: Text(
'$count',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w800,
fontSize: 14,
),
),
);
}
}
enum _ClusterStatus { contactsOnly, onlineOnly, both, mixed }
class _RepeaterCluster {
final List<_MapRepeater> members;
final LatLng center;
final _ClusterStatus status;
const _RepeaterCluster({
required this.members,
required this.center,
required this.status,
});
}
class _MapRepeater {
final String publicKey;
final String name;
final double latitude;
final double longitude;
final bool isFromContacts;
final bool isFromOnline;
const _MapRepeater({
required this.publicKey,
required this.name,
required this.latitude,
required this.longitude,
required this.isFromContacts,
required this.isFromOnline,
});
String get sourceLabel {
if (isFromContacts && isFromOnline) return 'Contacts and online';
if (isFromContacts) return 'Contacts';
return 'Online';
}
_ClusterStatus get clusterStatus {
if (isFromContacts && isFromOnline) return _ClusterStatus.both;
if (isFromContacts) return _ClusterStatus.contactsOnly;
return _ClusterStatus.onlineOnly;
}
}

View File

@@ -371,6 +371,7 @@ class _SensorCard extends StatelessWidget {
final l10n = AppLocalizations.of(context)!;
final telemetry = contact?.telemetry;
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final metrics = contact == null || telemetry == null
? const <_MetricCardData>[]
: _buildMetricCards(l10n, telemetry, contact!);
@@ -378,16 +379,23 @@ class _SensorCard extends StatelessWidget {
return Container(
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(28),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
colorScheme.surfaceContainerLow,
colorScheme.surfaceContainerHighest.withValues(alpha: 0.9),
],
),
border: Border.all(
color: theme.colorScheme.outline.withValues(alpha: 0.14),
color: colorScheme.outlineVariant.withValues(alpha: 0.35),
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 18,
offset: const Offset(0, 8),
color: Colors.black.withValues(alpha: 0.045),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),

View File

@@ -1,5 +1,6 @@
import 'dart:io' show Platform;
import 'dart:typed_data';
import 'package:flutter/foundation.dart'
show TargetPlatform, defaultTargetPlatform, kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter_avif/flutter_avif.dart';
import 'package:image_picker/image_picker.dart';
@@ -13,14 +14,18 @@ import '../providers/contacts_provider.dart';
import '../providers/messages_provider.dart';
import '../providers/app_provider.dart';
import '../providers/connection_provider.dart';
import '../providers/drawing_provider.dart';
import '../providers/map_provider.dart';
import '../services/location_tracking_service.dart';
import '../services/locale_preferences.dart';
import '../services/mesh_map_nodes_service.dart';
import '../services/update_checker_service.dart';
import '../services/voice_codec_service.dart';
import '../services/voice_bitrate_preferences.dart';
import '../services/image_preferences.dart';
import '../services/route_hash_preferences.dart';
import '../services/image_codec_service.dart';
import '../services/developer_mode_service.dart';
import '../services/notification_service.dart';
import '../utils/sample_data_generator.dart';
import '../utils/image_message_parser.dart';
import '../utils/voice_message_parser.dart';
@@ -70,6 +75,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _fastLocationUpdatesEnabled = false;
double _fastLocationMovementThresholdMeters = 10.0;
int _fastLocationActiveCadenceSeconds = 10;
bool _rotateMapWithHeading = false;
bool _showMapDebugInfo = false;
bool _openMapInFullscreen = false;
bool _messageNotificationsEnabled = true;
bool _sarNotificationsEnabled = true;
bool _updateNotificationsEnabled = true;
bool _muteForegroundNotifications = true;
bool _isDeveloperModeEnabled = false;
DateTime? _onlineTraceCacheUpdatedAt;
bool _isClearingOnlineTraceCache = false;
int _versionTapCount = 0;
final ImagePicker _imagePicker = ImagePicker();
final LocationTrackingService _locationService = LocationTrackingService();
@@ -81,10 +97,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
_loadPackageInfo();
_initializeLocationService();
_loadRxTxPreference();
_loadVoiceBitratePreference();
_loadVoicePreferences();
_loadRouteHashSizePreference();
_loadImagePreferences();
_loadFastLocationSettings();
_loadDeveloperMode();
_loadOnlineTraceCacheStatus();
_loadMapPreferences();
_loadNotificationPreferences();
}
@override
@@ -114,12 +134,89 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
}
Future<void> _loadDeveloperMode() async {
final isEnabled = await DeveloperModeService.isEnabled();
if (!mounted) return;
setState(() {
_isDeveloperModeEnabled = isEnabled;
});
}
Future<void> _loadNotificationPreferences() async {
final service = NotificationService();
await service.initialize();
if (!mounted) return;
setState(() {
_messageNotificationsEnabled = service.messageNotificationsEnabled;
_sarNotificationsEnabled = service.sarNotificationsEnabled;
_updateNotificationsEnabled = service.updateNotificationsEnabled;
_muteForegroundNotifications = service.muteForegroundNotifications;
});
}
Future<void> _loadOnlineTraceCacheStatus() async {
final cachedAt = await MeshMapNodesService.cachedAt();
if (!mounted) return;
setState(() {
_onlineTraceCacheUpdatedAt = cachedAt;
});
}
Future<void> _handleVersionTap() async {
if (_isDeveloperModeEnabled) {
await DeveloperModeService.setEnabled(false);
if (!mounted) return;
setState(() {
_isDeveloperModeEnabled = false;
_versionTapCount = 0;
});
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Developer mode disabled')));
return;
}
final nextTapCount = _versionTapCount + 1;
if (nextTapCount >= 3) {
await DeveloperModeService.setEnabled(true);
if (!mounted) return;
setState(() {
_isDeveloperModeEnabled = true;
_versionTapCount = 0;
});
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Developer mode enabled')));
return;
}
if (!mounted) return;
setState(() {
_versionTapCount = nextTapCount;
});
}
Future<void> _saveRxTxPreference(bool value) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('show_rx_tx_indicators', value);
}
Future<void> _loadVoiceBitratePreference() async {
Future<void> _loadMapPreferences() async {
final prefs = await SharedPreferences.getInstance();
if (!mounted) return;
setState(() {
_rotateMapWithHeading = prefs.getBool('map_rotate_with_heading') ?? false;
_showMapDebugInfo = prefs.getBool('map_show_debug_info') ?? false;
_openMapInFullscreen = prefs.getBool('map_fullscreen') ?? false;
});
}
Future<void> _saveMapPreference(String key, bool value) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(key, value);
}
Future<void> _loadVoicePreferences() async {
final value = await VoiceBitratePreferences.getBitrate();
if (!mounted) return;
setState(() {
@@ -424,7 +521,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
/// Check for app updates and show notification or dialog
Future<void> _checkForUpdates() async {
// Only on Android
if (!Platform.isAndroid) {
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
@@ -783,6 +880,66 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
Future<void> _clearOnlineTraceCache() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear online trace database'),
content: const Text(
'This removes the cached online node database used as a trace fallback.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(AppLocalizations.of(context)!.cancel),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: Text(AppLocalizations.of(context)!.clear),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() {
_isClearingOnlineTraceCache = true;
});
await MeshMapNodesService.clearCache();
if (!mounted) return;
setState(() {
_onlineTraceCacheUpdatedAt = null;
_isClearingOnlineTraceCache = false;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Online trace database cleared'),
backgroundColor: Colors.orange,
),
);
}
String _onlineTraceCacheSubtitle() {
final cachedAt = _onlineTraceCacheUpdatedAt;
if (cachedAt == null) {
return 'No cached online database. Refresh runs in background when internet is available.';
}
final expiresAt = cachedAt.add(MeshMapNodesService.traceCacheTtl);
return 'Last synced ${_formatDateTime(cachedAt)}. Cached for 24 hours until ${_formatDateTime(expiresAt)}.';
}
String _formatDateTime(DateTime value) {
final local = value.toLocal();
String two(int part) => part.toString().padLeft(2, '0');
return '${local.year}-${two(local.month)}-${two(local.day)} ${two(local.hour)}:${two(local.minute)}';
}
Future<void> _showRouteHashSizeDialog() async {
final selected = await showDialog<int>(
context: context,
@@ -862,6 +1019,72 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
]),
_buildSectionHeader('Notifications'),
_buildSettingsCard([
SwitchListTile(
secondary: const Icon(Icons.chat_bubble_outline),
title: const Text('Message notifications'),
subtitle: const Text(
'Notify for incoming direct and channel messages',
),
value: _messageNotificationsEnabled,
onChanged: (value) async {
setState(() {
_messageNotificationsEnabled = value;
});
await NotificationService().setMessageNotificationsEnabled(
value,
);
},
),
SwitchListTile(
secondary: const Icon(Icons.warning_amber_outlined),
title: const Text('SAR alerts'),
subtitle: const Text(
'Notify for incoming SAR markers such as found person or fire',
),
value: _sarNotificationsEnabled,
onChanged: (value) async {
setState(() {
_sarNotificationsEnabled = value;
});
await NotificationService().setSarNotificationsEnabled(value);
},
),
SwitchListTile(
secondary: const Icon(Icons.system_update),
title: const Text('Update notifications'),
subtitle: const Text(
'Notify when a newer app version is available',
),
value: _updateNotificationsEnabled,
onChanged: (value) async {
setState(() {
_updateNotificationsEnabled = value;
});
await NotificationService().setUpdateNotificationsEnabled(
value,
);
},
),
SwitchListTile(
secondary: const Icon(Icons.visibility_off_outlined),
title: const Text('Mute while app is open'),
subtitle: const Text(
'Do not show local notifications while the app is in the foreground',
),
value: _muteForegroundNotifications,
onChanged: (value) async {
setState(() {
_muteForegroundNotifications = value;
});
await NotificationService().setMuteForegroundNotifications(
value,
);
},
),
]),
_buildSectionHeader('Navigation'),
_buildSettingsCard([
Consumer<AppProvider>(
@@ -907,19 +1130,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
_buildSectionHeader('Messaging'),
_buildSettingsCard([
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.person_add_alt_1),
title: const Text('Auto-add discovered contacts'),
subtitle: const Text(
'Automatically fetch and add new contacts when they are discovered',
),
value: appProvider.autoAddDiscoveredContacts,
onChanged: (value) async {
await appProvider.toggleAutoAddDiscoveredContacts(value);
},
),
),
ListTile(
leading: const Icon(Icons.alt_route),
title: const Text('Route path byte size'),
@@ -929,6 +1139,45 @@ class _SettingsScreenState extends State<SettingsScreen> {
trailing: const Icon(Icons.chevron_right),
onTap: _showRouteHashSizeDialog,
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.swap_horiz),
title: const Text('Auto route rotation'),
subtitle: const Text(
'Rotate between best known direct paths and flood mode for room/contact sends',
),
value: appProvider.autoRouteRotationEnabled,
onChanged: (value) async {
await appProvider.toggleAutoRouteRotationEnabled(value);
},
),
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.route),
title: const Text('Nearest repeater fallback'),
subtitle: const Text(
'After normal retries fail, try one final resend through the nearest repeater',
),
value: appProvider.nearestRelayFallbackEnabled,
onChanged: (value) async {
await appProvider.toggleNearestRelayFallbackEnabled(value);
},
),
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.route),
title: const Text('Clear path on max retry'),
subtitle: const Text(
'Clear the route only after all retries and final router fallback fail',
),
value: appProvider.clearPathOnMaxRetry,
onChanged: (value) async {
await appProvider.toggleClearPathOnMaxRetry(value);
},
),
),
ListTile(
leading: const Icon(Icons.delete_sweep, color: Colors.red),
title: const Text(
@@ -938,8 +1187,147 @@ class _SettingsScreenState extends State<SettingsScreen> {
subtitle: const Text('Delete all stored message history'),
onTap: _clearMessages,
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => ListTile(
leading: const Icon(Icons.format_size),
title: const Text('Message font size'),
subtitle: Text(
'${(appProvider.messageFontScale * 100).round()}% of default',
),
trailing: SizedBox(
width: 150,
child: Slider(
value: appProvider.messageFontScale,
min: 0.85,
max: 1.4,
divisions: 11,
label: '${(appProvider.messageFontScale * 100).round()}%',
onChanged: (value) {
appProvider.setMessageFontScale(value);
},
),
),
),
),
]),
_buildSectionHeader('Tracing'),
_buildSettingsCard([
ListTile(
leading: const Icon(Icons.cloud_sync),
title: const Text('Online trace database'),
subtitle: Text(_onlineTraceCacheSubtitle()),
),
ListTile(
leading: Icon(
Icons.delete_sweep,
color: _isClearingOnlineTraceCache ? null : Colors.red,
),
title: Text(
'Clear online trace database',
style: TextStyle(
color: _isClearingOnlineTraceCache ? null : Colors.red,
),
),
subtitle: const Text(
'Remove the 24-hour cached fallback used when local route matches are incomplete',
),
enabled: !_isClearingOnlineTraceCache,
trailing: _isClearingOnlineTraceCache
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: null,
onTap: _isClearingOnlineTraceCache
? null
: _clearOnlineTraceCache,
),
]),
_buildSectionHeader('Map'),
_buildSettingsCard([
SwitchListTile(
secondary: const Icon(Icons.explore),
title: const Text('Rotate map with heading'),
subtitle: const Text(
'Rotate the map based on your compass or movement heading',
),
value: _rotateMapWithHeading,
onChanged: (value) async {
setState(() {
_rotateMapWithHeading = value;
});
await _saveMapPreference('map_rotate_with_heading', value);
},
),
SwitchListTile(
secondary: const Icon(Icons.bug_report_outlined),
title: const Text('Show map debug info'),
subtitle: const Text(
'Display extra map diagnostics and internal state overlays',
),
value: _showMapDebugInfo,
onChanged: (value) async {
setState(() {
_showMapDebugInfo = value;
});
await _saveMapPreference('map_show_debug_info', value);
},
),
SwitchListTile(
secondary: const Icon(Icons.fullscreen),
title: const Text('Open map in fullscreen'),
subtitle: const Text(
'Start the map tab in fullscreen mode by default',
),
value: _openMapInFullscreen,
onChanged: (value) async {
setState(() {
_openMapInFullscreen = value;
});
await _saveMapPreference('map_fullscreen', value);
},
),
Consumer<DrawingProvider>(
builder: (context, drawingProvider, child) => SwitchListTile(
secondary: const Icon(Icons.fmd_good_outlined),
title: const Text('Show SAR markers'),
subtitle: const Text('Display SAR markers on the main map'),
value: drawingProvider.showSarMarkers,
onChanged: (value) {
drawingProvider.toggleSarMarkers();
},
),
),
Consumer<MapProvider>(
builder: (context, mapProvider, child) => SwitchListTile(
secondary: const Icon(Icons.timeline),
title: const Text('Show all contact trails'),
subtitle: const Text(
'Display location trails for all contacts that have history',
),
value: mapProvider.showAllContactTrails,
onChanged: (value) async {
await mapProvider.toggleAllContactTrails();
},
),
),
Consumer<MapProvider>(
builder: (context, mapProvider, child) => SwitchListTile(
secondary: const Icon(Icons.router_outlined),
title: const Text('Hide repeaters on map'),
subtitle: const Text(
'Hide repeater contacts from the main map view',
),
value: mapProvider.hideRepeatersOnMap,
onChanged: (value) async {
await mapProvider.setHideRepeatersOnMap(value);
},
),
),
]),
_buildSectionHeader('Voice'),
Consumer2<AppProvider, ConnectionProvider>(
builder: (context, appProvider, connectionProvider, child) =>
@@ -949,6 +1337,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
bandPassEnabled: appProvider.isVoiceBandPassFilterEnabled,
compressorEnabled: appProvider.isVoiceCompressorEnabled,
limiterEnabled: appProvider.isVoiceLimiterEnabled,
autoGainEnabled: appProvider.isVoiceAutoGainEnabled,
echoCancellationEnabled:
appProvider.isVoiceEchoCancellationEnabled,
noiseSuppressionEnabled:
appProvider.isVoiceNoiseSuppressionEnabled,
silenceTrimEnabled: appProvider.isVoiceSilenceTrimmingEnabled,
),
),
@@ -995,6 +1388,43 @@ class _SettingsScreenState extends State<SettingsScreen> {
},
),
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.auto_fix_high),
title: const Text('Mic auto gain'),
subtitle: const Text('Lets the recorder adjust input level'),
value: appProvider.isVoiceAutoGainEnabled,
onChanged: (value) async {
await appProvider.toggleVoiceAutoGainEnabled(value);
},
),
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.hearing_disabled),
title: const Text('Echo cancellation'),
subtitle: const Text(
'Uses recorder echo cancellation if available',
),
value: appProvider.isVoiceEchoCancellationEnabled,
onChanged: (value) async {
await appProvider.toggleVoiceEchoCancellationEnabled(value);
},
),
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.noise_control_off),
title: const Text('Noise suppression'),
subtitle: const Text(
'Uses recorder noise suppression if available',
),
value: appProvider.isVoiceNoiseSuppressionEnabled,
onChanged: (value) async {
await appProvider.toggleVoiceNoiseSuppressionEnabled(value);
},
),
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.content_cut),
@@ -1106,8 +1536,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
]),
_buildSectionHeader('Network Sharing'),
const ConnectionModeSelector(),
if (!kIsWeb) ...[
_buildSectionHeader('Network Sharing'),
const ConnectionModeSelector(),
],
_buildSectionHeader(AppLocalizations.of(context)!.permissionsSection),
_buildSettingsCard([
@@ -1197,6 +1629,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
? '${_packageInfo!.version} (${_packageInfo!.buildNumber})'
: 'Loading...',
),
onTap: _handleVersionTap,
),
ListTile(
leading: const Icon(Icons.badge),
@@ -1212,7 +1645,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
onTap: () => _showAboutDialog(),
),
]),
if (Platform.isAndroid)
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android)
Padding(
padding: const EdgeInsets.only(top: 8),
child: FilledButton.icon(
@@ -1511,6 +1944,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
required bool bandPassEnabled,
required bool compressorEnabled,
required bool limiterEnabled,
required bool autoGainEnabled,
required bool echoCancellationEnabled,
required bool noiseSuppressionEnabled,
required bool silenceTrimEnabled,
}) {
final supported = VoiceBitratePreferences.supportedBitrates;
@@ -1523,6 +1959,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
(bandPassEnabled ? 1 : 0) +
(compressorEnabled ? 1 : 0) +
(limiterEnabled ? 1 : 0) +
(autoGainEnabled ? 1 : 0) +
(echoCancellationEnabled ? 1 : 0) +
(noiseSuppressionEnabled ? 1 : 0) +
(silenceTrimEnabled ? 1 : 0);
final radioBw = connectionProvider.deviceInfo.radioBw;
final radioSf = connectionProvider.deviceInfo.radioSf;
@@ -1530,7 +1969,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
final bwHz = _resolveBandwidthHz(radioBw);
final voiceMode = VoiceBitratePreferences.toVoiceMode(bitrate);
const voicePreviewMs = 10000; // 10-second reference clip
final packetDurationMs = codec2ModeFor(voiceMode).packetDurationMs;
final packetDurationMs = voiceMode.packetDurationMs;
final voicePacketCount =
(voicePreviewMs + packetDurationMs - 1) ~/ packetDurationMs;
final voiceDirect = estimateVoiceTransmitDuration(
@@ -1565,7 +2004,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
const SizedBox(height: 8),
Text(
'Bitrate: $bitrate bps',
'Codec: ${voiceMode.label} · $bitrate bps',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 4),
@@ -1612,6 +2051,31 @@ class _SettingsScreenState extends State<SettingsScreen> {
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _voiceStatChip(
label: 'Auto gain',
enabled: autoGainEnabled,
),
),
const SizedBox(width: 8),
Expanded(
child: _voiceStatChip(
label: 'Echo cancel',
enabled: echoCancellationEnabled,
),
),
const SizedBox(width: 8),
Expanded(
child: _voiceStatChip(
label: 'Noise suppress',
enabled: noiseSuppressionEnabled,
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
@@ -1624,7 +2088,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
const SizedBox(height: 8),
Text(
'Processing enabled: $enabledCount/4',
'Processing enabled: $enabledCount/7',
style: Theme.of(context).textTheme.bodySmall,
),
],

View File

@@ -1,6 +1,9 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../providers/connection_provider.dart';
import '../services/wizard_preferences.dart';
import '../widgets/connection_dialog.dart';
/// Welcome wizard screen to introduce new users to the app
class WelcomeWizardScreen extends StatefulWidget {
@@ -14,12 +17,171 @@ class WelcomeWizardScreen extends StatefulWidget {
class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
final PageController _pageController = PageController();
final TextEditingController _deviceNameController = TextEditingController();
int _currentPage = 0;
static const int _totalPages = 5;
bool _isApplyingDeviceSetup = false;
static const int _totalPages = 6;
static const List<_RadioPreset> _radioPresets = [
// Official MeshCore config presets from https://api.meshcore.nz/api/v1/config
_RadioPreset(
id: 'australia',
label: 'Australia',
summary: '915.800 MHz, BW 250 kHz, SF10, CR5',
frequencyKhz: 915800,
bandwidth: 8,
spreadingFactor: 10,
codingRate: 5,
),
_RadioPreset(
id: 'australia_narrow',
label: 'Australia (Narrow)',
summary: '916.575 MHz, BW 62.5 kHz, SF7, CR8',
frequencyKhz: 916575,
bandwidth: 6,
spreadingFactor: 7,
codingRate: 8,
),
_RadioPreset(
id: 'australia_sa_wa',
label: 'Australia: SA, WA',
summary: '923.125 MHz, BW 62.5 kHz, SF8, CR8',
frequencyKhz: 923125,
bandwidth: 6,
spreadingFactor: 8,
codingRate: 8,
),
_RadioPreset(
id: 'australia_qld',
label: 'Australia: QLD',
summary: '923.125 MHz, BW 62.5 kHz, SF8, CR5',
frequencyKhz: 923125,
bandwidth: 6,
spreadingFactor: 8,
codingRate: 5,
),
_RadioPreset(
id: 'eu_uk_narrow',
label: 'EU/UK (Narrow)',
summary: '869.618 MHz, BW 62.5 kHz, SF8, CR8',
frequencyKhz: 869618,
bandwidth: 6,
spreadingFactor: 8,
codingRate: 8,
),
_RadioPreset(
id: 'eu_uk_deprecated',
label: 'EU/UK (Deprecated)',
summary: '869.525 MHz, BW 250 kHz, SF11, CR5',
frequencyKhz: 869525,
bandwidth: 8,
spreadingFactor: 11,
codingRate: 5,
),
_RadioPreset(
id: 'czech_republic_narrow',
label: 'Czech Republic (Narrow)',
summary: '869.432 MHz, BW 62.5 kHz, SF7, CR5',
frequencyKhz: 869432,
bandwidth: 6,
spreadingFactor: 7,
codingRate: 5,
),
_RadioPreset(
id: 'eu_433_long_range',
label: 'EU 433MHz (Long Range)',
summary: '433.650 MHz, BW 250 kHz, SF11, CR5',
frequencyKhz: 433650,
bandwidth: 8,
spreadingFactor: 11,
codingRate: 5,
),
_RadioPreset(
id: 'new_zealand',
label: 'New Zealand',
summary: '917.375 MHz, BW 250 kHz, SF11, CR5',
frequencyKhz: 917375,
bandwidth: 8,
spreadingFactor: 11,
codingRate: 5,
),
_RadioPreset(
id: 'new_zealand_narrow',
label: 'New Zealand (Narrow)',
summary: '917.375 MHz, BW 62.5 kHz, SF7, CR5',
frequencyKhz: 917375,
bandwidth: 6,
spreadingFactor: 7,
codingRate: 5,
),
_RadioPreset(
id: 'portugal_433',
label: 'Portugal 433',
summary: '433.375 MHz, BW 62.5 kHz, SF9, CR6',
frequencyKhz: 433375,
bandwidth: 6,
spreadingFactor: 9,
codingRate: 6,
),
_RadioPreset(
id: 'portugal_868',
label: 'Portugal 868',
summary: '869.618 MHz, BW 62.5 kHz, SF7, CR6',
frequencyKhz: 869618,
bandwidth: 6,
spreadingFactor: 7,
codingRate: 6,
),
_RadioPreset(
id: 'switzerland',
label: 'Switzerland',
summary: '869.618 MHz, BW 62.5 kHz, SF8, CR8',
frequencyKhz: 869618,
bandwidth: 6,
spreadingFactor: 8,
codingRate: 8,
),
_RadioPreset(
id: 'usa_canada_recommended',
label: 'USA/Canada (Recommended)',
summary: '910.525 MHz, BW 62.5 kHz, SF7, CR5',
frequencyKhz: 910525,
bandwidth: 6,
spreadingFactor: 7,
codingRate: 5,
),
_RadioPreset(
id: 'vietnam_narrow',
label: 'Vietnam (Narrow)',
summary: '920.250 MHz, BW 62.5 kHz, SF8, CR5',
frequencyKhz: 920250,
bandwidth: 6,
spreadingFactor: 8,
codingRate: 5,
),
_RadioPreset(
id: 'vietnam_deprecated',
label: 'Vietnam (Deprecated)',
summary: '920.250 MHz, BW 250 kHz, SF11, CR5',
frequencyKhz: 920250,
bandwidth: 8,
spreadingFactor: 11,
codingRate: 5,
),
];
_RadioPreset _selectedPreset = _radioPresets[4];
@override
void initState() {
super.initState();
final deviceInfo = context.read<ConnectionProvider>().deviceInfo;
_deviceNameController.text =
deviceInfo.selfName ?? deviceInfo.deviceName ?? '';
}
@override
void dispose() {
_pageController.dispose();
_deviceNameController.dispose();
super.dispose();
}
@@ -30,7 +192,9 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
}
void _nextPage() {
if (_currentPage < _totalPages - 1) {
if (_currentPage == 1) {
_handleDeviceSetupAction();
} else if (_currentPage < _totalPages - 1) {
_pageController.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
@@ -56,6 +220,98 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
}
}
Future<void> _openConnectionDialog() async {
await showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => const ConnectionDialog(),
);
if (!mounted) return;
final connectionProvider = context.read<ConnectionProvider>();
if (connectionProvider.deviceInfo.isConnected) {
await connectionProvider.refreshDeviceInfo();
}
if (!mounted) return;
final deviceInfo = connectionProvider.deviceInfo;
final fetchedName = deviceInfo.selfName ?? deviceInfo.deviceName ?? '';
if (fetchedName.isNotEmpty) {
setState(() {
_deviceNameController.text = fetchedName;
});
} else {
setState(() {});
}
}
Future<void> _handleDeviceSetupAction() async {
final l10n = AppLocalizations.of(context)!;
final connectionProvider = context.read<ConnectionProvider>();
final deviceInfo = connectionProvider.deviceInfo;
if (!deviceInfo.isConnected) {
await _openConnectionDialog();
return;
}
final trimmedName = _deviceNameController.text.trim();
if (trimmedName.isEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.wizardEnterDeviceName)));
return;
}
setState(() {
_isApplyingDeviceSetup = true;
});
try {
await connectionProvider.setAdvertName(trimmedName);
await connectionProvider.setRadioParams(
frequency: _selectedPreset.frequencyKhz,
bandwidth: _selectedPreset.bandwidth,
spreadingFactor: _selectedPreset.spreadingFactor,
codingRate: _selectedPreset.codingRate,
);
await connectionProvider.refreshDeviceInfo();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
l10n.wizardDeviceSetupSaved(trimmedName, _selectedPreset.label),
),
backgroundColor: Colors.green,
),
);
_pageController.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.failedToSave(e.toString())),
backgroundColor: Colors.red,
),
);
} finally {
if (mounted) {
setState(() {
_isApplyingDeviceSetup = false;
});
}
}
}
void _skipDeviceSetupStep() {
if (_isApplyingDeviceSetup) return;
_pageController.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
@@ -68,7 +324,7 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
children: [
// Top bar with skip button
Padding(
padding: const EdgeInsets.all(16.0),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@@ -96,8 +352,10 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
child: PageView(
controller: _pageController,
onPageChanged: _onPageChanged,
physics: const NeverScrollableScrollPhysics(),
children: [
_buildWelcomePage(context, l10n, colorScheme),
_buildConnectDevicePage(context, l10n, colorScheme),
_buildConnectingPage(context, l10n, colorScheme),
_buildChannelPage(context, l10n, colorScheme),
_buildContactsPage(context, l10n, colorScheme),
@@ -108,7 +366,7 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
// Page indicators
Padding(
padding: const EdgeInsets.all(16.0),
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(
@@ -130,11 +388,11 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
// Next/Get Started button
Padding(
padding: const EdgeInsets.all(16.0),
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _nextPage,
onPressed: _isApplyingDeviceSetup ? null : _nextPage,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16.0),
shape: RoundedRectangleBorder(
@@ -142,7 +400,9 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
),
),
child: Text(
_currentPage < _totalPages - 1
_currentPage == 1
? _deviceSetupButtonLabel(context)
: _currentPage < _totalPages - 1
? l10n.wizardNext
: l10n.wizardGetStarted,
style: const TextStyle(fontSize: 16),
@@ -165,7 +425,21 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
icon: Icons.waving_hand,
iconColor: Colors.orange,
title: l10n.wizardWelcomeTitle,
description: l10n.wizardWelcomeDescription,
description: l10n.wizardOverviewDescription,
features: [
_FeatureItem(
icon: Icons.chat_bubble_outline,
text: l10n.wizardOverviewFeature1,
),
_FeatureItem(
icon: Icons.emergency_share,
text: l10n.wizardOverviewFeature2,
),
_FeatureItem(
icon: Icons.settings_input_antenna,
text: l10n.wizardOverviewFeature3,
),
],
colorScheme: colorScheme,
);
}
@@ -176,33 +450,225 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
ColorScheme colorScheme,
) {
return _buildPage(
icon: Icons.bluetooth_searching,
icon: Icons.forum_rounded,
iconColor: Colors.blue,
title: l10n.wizardConnectingTitle,
description: l10n.wizardConnectingDescription,
title: l10n.wizardMessagingTitle,
description: l10n.wizardMessagingDescription,
features: [
_FeatureItem(icon: Icons.radio, text: l10n.wizardConnectingFeature1),
_FeatureItem(icon: Icons.link, text: l10n.wizardConnectingFeature2),
_FeatureItem(icon: Icons.wifi_off, text: l10n.wizardConnectingFeature3),
_FeatureItem(icon: Icons.chat, text: l10n.wizardMessagingFeature1),
_FeatureItem(icon: Icons.campaign, text: l10n.wizardMessagingFeature2),
_FeatureItem(icon: Icons.mic, text: l10n.wizardMessagingFeature3),
],
colorScheme: colorScheme,
);
}
Widget _buildConnectDevicePage(
BuildContext context,
AppLocalizations l10n,
ColorScheme colorScheme,
) {
final connectionProvider = context.watch<ConnectionProvider>();
final deviceInfo = connectionProvider.deviceInfo;
final isConnected = deviceInfo.isConnected;
final connectedName = deviceInfo.selfName ?? deviceInfo.deviceName;
return LayoutBuilder(
builder: (context, constraints) => Center(
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: 600,
maxHeight: constraints.maxHeight,
),
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildPageHeader(
context: context,
colorScheme: colorScheme,
icon: Icons.bluetooth_connected,
iconColor: Colors.green,
title: l10n.wizardConnectDeviceTitle,
description: l10n.wizardConnectDeviceDescription,
badge: l10n.wizardSetupBadge,
),
const SizedBox(height: 16),
_buildContentCard(
colorScheme: colorScheme,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(
isConnected
? Icons.check_circle
: Icons.bluetooth_searching,
color: isConnected
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 12),
Expanded(
child: Text(
isConnected
? l10n.wizardConnectedToDevice(
connectedName ?? l10n.device,
)
: l10n.wizardNoDeviceConnected,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(fontWeight: FontWeight.w600),
),
),
TextButton(
onPressed: _openConnectionDialog,
child: Text(
isConnected ? l10n.change : l10n.connect,
),
),
],
),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: _skipDeviceSetupStep,
child: Text(l10n.wizardSkipForNow),
),
),
],
),
),
const SizedBox(height: 12),
_buildContentCard(
colorScheme: colorScheme,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _deviceNameController,
textInputAction: TextInputAction.done,
decoration: InputDecoration(
labelText: l10n.wizardDeviceNameLabel,
border: OutlineInputBorder(),
helperText: l10n.wizardDeviceNameHelp,
),
),
const SizedBox(height: 12),
DropdownButtonFormField<_RadioPreset>(
initialValue: _selectedPreset,
isExpanded: true,
decoration: InputDecoration(
labelText: l10n.wizardConfigRegionLabel,
border: OutlineInputBorder(),
helperText: l10n.wizardConfigRegionHelp,
),
items: _radioPresets
.map(
(preset) => DropdownMenuItem<_RadioPreset>(
value: preset,
child: Text(
preset.label,
overflow: TextOverflow.ellipsis,
),
),
)
.toList(),
onChanged: (preset) {
if (preset == null) return;
setState(() {
_selectedPreset = preset;
});
},
),
],
),
),
const SizedBox(height: 16),
_buildContentCard(
colorScheme: colorScheme,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_selectedPreset.label,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 4),
Text(
_selectedPreset.summary,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 14),
...[
_FeatureItem(
icon: Icons.rule,
text: l10n.wizardPresetNote1,
),
_FeatureItem(
icon: Icons.settings_input_antenna,
text: l10n.wizardPresetNote2,
),
_FeatureItem(
icon: Icons.public,
text: l10n.wizardPresetNote3,
),
].map(
(feature) => Padding(
padding: const EdgeInsets.symmetric(vertical: 5),
child: _buildFeatureRow(
context,
colorScheme,
feature,
iconSize: 20,
gap: 12,
),
),
),
],
),
),
],
),
),
),
),
);
}
String _deviceSetupButtonLabel(BuildContext context) {
final isConnected = context
.watch<ConnectionProvider>()
.deviceInfo
.isConnected;
if (_isApplyingDeviceSetup) {
return AppLocalizations.of(context)!.wizardSaving;
}
final l10n = AppLocalizations.of(context)!;
return isConnected
? l10n.wizardSaveAndContinue
: l10n.wizardConnectDeviceTitle;
}
Widget _buildChannelPage(
BuildContext context,
AppLocalizations l10n,
ColorScheme colorScheme,
) {
return _buildPage(
icon: Icons.campaign,
icon: Icons.groups_rounded,
iconColor: Colors.purple,
title: l10n.wizardChannelTitle,
description: l10n.wizardChannelDescription,
title: l10n.wizardNetworkTitle,
description: l10n.wizardNetworkDescription,
features: [
_FeatureItem(icon: Icons.public, text: l10n.wizardChannelFeature1),
_FeatureItem(icon: Icons.groups, text: l10n.wizardChannelFeature2),
_FeatureItem(icon: Icons.send, text: l10n.wizardChannelFeature3),
_FeatureItem(
icon: Icons.person_add_alt_1,
text: l10n.wizardNetworkFeature1,
),
_FeatureItem(icon: Icons.route, text: l10n.wizardNetworkFeature2),
_FeatureItem(icon: Icons.hub, text: l10n.wizardNetworkFeature3),
],
colorScheme: colorScheme,
);
@@ -214,17 +680,14 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
ColorScheme colorScheme,
) {
return _buildPage(
icon: Icons.people,
icon: Icons.map_rounded,
iconColor: Colors.teal,
title: l10n.wizardContactsTitle,
description: l10n.wizardContactsDescription,
title: l10n.wizardMapOpsTitle,
description: l10n.wizardMapOpsDescription,
features: [
_FeatureItem(icon: Icons.person_add, text: l10n.wizardContactsFeature1),
_FeatureItem(icon: Icons.chat, text: l10n.wizardContactsFeature2),
_FeatureItem(
icon: Icons.battery_std,
text: l10n.wizardContactsFeature3,
),
_FeatureItem(icon: Icons.location_on, text: l10n.wizardMapOpsFeature1),
_FeatureItem(icon: Icons.draw, text: l10n.wizardMapOpsFeature2),
_FeatureItem(icon: Icons.router, text: l10n.wizardMapOpsFeature3),
],
colorScheme: colorScheme,
);
@@ -236,18 +699,17 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
ColorScheme colorScheme,
) {
return _buildPage(
icon: Icons.map,
icon: Icons.tune_rounded,
iconColor: Colors.red,
title: l10n.wizardMapTitle,
description: l10n.wizardMapDescription,
title: l10n.wizardToolsTitle,
description: l10n.wizardToolsDescription,
features: [
_FeatureItem(icon: Icons.location_on, text: l10n.wizardMapFeature1),
_FeatureItem(icon: Icons.settings, text: l10n.wizardToolsFeature1),
_FeatureItem(icon: Icons.sensors, text: l10n.wizardToolsFeature2),
_FeatureItem(
icon: Icons.person_pin_circle,
text: l10n.wizardMapFeature2,
icon: Icons.analytics_outlined,
text: l10n.wizardToolsFeature3,
),
_FeatureItem(icon: Icons.offline_pin, text: l10n.wizardMapFeature3),
_FeatureItem(icon: Icons.draw, text: l10n.wizardMapFeature4),
],
colorScheme: colorScheme,
);
@@ -261,69 +723,149 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
List<_FeatureItem>? features,
required ColorScheme colorScheme,
}) {
return SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 20),
// Icon
Container(
padding: const EdgeInsets.all(24.0),
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(icon, size: 80, color: iconColor),
final l10n = AppLocalizations.of(context)!;
return LayoutBuilder(
builder: (context, constraints) => Center(
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: 560,
maxHeight: constraints.maxHeight,
),
const SizedBox(height: 32),
// Title
Text(
title,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
// Description
Text(
description,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.7),
height: 1.5,
),
textAlign: TextAlign.center,
),
if (features != null && features.isNotEmpty) ...[
const SizedBox(height: 32),
// Features list
...features.map(
(feature) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
children: [
Icon(feature.icon, color: colorScheme.primary, size: 24),
const SizedBox(width: 16),
Expanded(
child: Text(
feature.text,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface,
),
),
),
],
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 12),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildPageHeader(
context: context,
colorScheme: colorScheme,
icon: icon,
iconColor: iconColor,
title: title,
description: description,
badge: l10n.wizardOverviewBadge,
),
),
if (features != null && features.isNotEmpty) ...[
const SizedBox(height: 18),
_buildContentCard(
colorScheme: colorScheme,
child: Column(
children: features
.map(
(feature) => Padding(
padding: const EdgeInsets.symmetric(vertical: 7),
child: _buildFeatureRow(
context,
colorScheme,
feature,
),
),
)
.toList(),
),
),
],
],
),
],
const SizedBox(height: 20),
],
),
),
),
);
}
Widget _buildPageHeader({
required BuildContext context,
required ColorScheme colorScheme,
required IconData icon,
required Color iconColor,
required String title,
required String description,
required String badge,
}) {
return Column(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(999),
),
child: Text(
badge,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(icon, size: 56, color: iconColor),
),
const SizedBox(height: 18),
Text(
title,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 10),
Text(
description,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.72),
height: 1.4,
),
textAlign: TextAlign.center,
),
],
);
}
Widget _buildContentCard({
required ColorScheme colorScheme,
required Widget child,
}) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(18),
),
child: child,
);
}
Widget _buildFeatureRow(
BuildContext context,
ColorScheme colorScheme,
_FeatureItem feature, {
double iconSize = 22,
double gap = 14,
}) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(feature.icon, color: colorScheme.primary, size: iconSize),
SizedBox(width: gap),
Expanded(
child: Text(
feature.text,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurface),
),
),
],
);
}
}
class _FeatureItem {
@@ -332,3 +874,23 @@ class _FeatureItem {
_FeatureItem({required this.icon, required this.text});
}
class _RadioPreset {
final String id;
final String label;
final String summary;
final int frequencyKhz;
final int bandwidth;
final int spreadingFactor;
final int codingRate;
const _RadioPreset({
required this.id,
required this.label,
required this.summary,
required this.frequencyKhz,
required this.bandwidth,
required this.spreadingFactor,
required this.codingRate,
});
}

View File

@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/widgets.dart';
import 'package:geolocator/geolocator.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -58,6 +59,16 @@ class BackgroundLocationService {
return false;
}
if (Platform.isIOS && permission != LocationPermission.always) {
permission = await Geolocator.requestPermission();
if (permission != LocationPermission.always) {
debugPrint(
'⚠️ [BackgroundLocation] iOS background tracking requires Always permission',
);
return false;
}
}
// Save settings
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefKeyEnabled, true);
@@ -68,8 +79,7 @@ class BackgroundLocationService {
try {
_positionSubscription =
Geolocator.getPositionStream(
locationSettings: LocationSettings(
accuracy: LocationAccuracy.best,
locationSettings: _buildLocationSettings(
distanceFilter: distanceThreshold.toInt(),
),
).listen((Position position) async {
@@ -171,4 +181,22 @@ class BackgroundLocationService {
await startTracking(distanceThreshold: distance);
}
}
LocationSettings _buildLocationSettings({int distanceFilter = 10}) {
if (Platform.isIOS) {
return AppleSettings(
accuracy: LocationAccuracy.best,
activityType: ActivityType.fitness,
allowBackgroundLocationUpdates: true,
distanceFilter: distanceFilter,
pauseLocationUpdatesAutomatically: false,
showBackgroundLocationIndicator: true,
);
}
return LocationSettings(
accuracy: LocationAccuracy.best,
distanceFilter: distanceFilter,
);
}
}

View File

@@ -1,6 +1,5 @@
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show TargetPlatform, defaultTargetPlatform, debugPrint, kIsWeb;
import 'package:flutter/services.dart';
import 'package:flutter/foundation.dart';
/// Service for accessing build information from native platform code
/// Currently supports Android only - returns "unknown" for other platforms
@@ -25,7 +24,7 @@ class BuildInfoService {
}
// Only Android has the platform channel implementation
if (!Platform.isAndroid) {
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
debugPrint('[BuildInfoService] Not on Android platform, returning "unknown"');
_cachedCommitHash = 'unknown';
return _cachedCommitHash!;

View File

@@ -0,0 +1,336 @@
import 'dart:math' as math;
import 'package:latlong2/latlong.dart';
import '../models/contact.dart';
class ResolvedContactRoutePlan {
final List<String> tokens;
final List<Contact> selectedContacts;
final String summary;
const ResolvedContactRoutePlan({
required this.tokens,
required this.selectedContacts,
required this.summary,
});
String get canonicalText => tokens.join(',');
}
class ContactRouteResolver {
static const Distance _distance = Distance();
const ContactRouteResolver._();
static ResolvedContactRoutePlan? resolveAutomaticRoute({
required LatLng senderLocation,
required Contact recipient,
required List<Contact> availableContacts,
required int hashSize,
}) {
final recipientLocation = recipient.displayLocation;
if (recipientLocation == null) return null;
final repeaters = availableContacts
.where(
(contact) =>
contact.isRepeater &&
contact.displayLocation != null &&
contact.publicKeyHex != recipient.publicKeyHex,
)
.toList();
if (repeaters.isEmpty) return null;
final recipientLatLng = LatLng(
recipientLocation.latitude,
recipientLocation.longitude,
);
final routedCandidates =
repeaters
.where(
(contact) =>
contact.routeHasPath && contact.routeHashSize == hashSize,
)
.toList()
..sort(
(a, b) =>
_scoreKnownRouteRepeater(
senderLocation: senderLocation,
recipientLocation: recipientLatLng,
repeater: a,
availableContacts: repeaters,
hashSize: hashSize,
).compareTo(
_scoreKnownRouteRepeater(
senderLocation: senderLocation,
recipientLocation: recipientLatLng,
repeater: b,
availableContacts: repeaters,
hashSize: hashSize,
),
),
);
if (routedCandidates.isNotEmpty) {
final anchor = routedCandidates.first;
final tokens = <String>[
...anchor.routeCanonicalText
.split(',')
.where((token) => token.isNotEmpty)
.map((token) => token.toUpperCase()),
_tokenFor(anchor, hashSize),
];
final selectedContacts = _matchContactsForTokens(
tokens,
availableContacts: repeaters,
);
return ResolvedContactRoutePlan(
tokens: _dedupeTokens(tokens),
selectedContacts: selectedContacts,
summary: 'Resolved via known repeater route',
);
}
final corridorRepeaters = List<Contact>.from(repeaters)
..sort((a, b) {
final progressA = _progressAlongSegment(
point: LatLng(
a.displayLocation!.latitude,
a.displayLocation!.longitude,
),
start: senderLocation,
end: recipientLatLng,
);
final progressB = _progressAlongSegment(
point: LatLng(
b.displayLocation!.latitude,
b.displayLocation!.longitude,
),
start: senderLocation,
end: recipientLatLng,
);
final progressCompare = progressA.compareTo(progressB);
if (progressCompare != 0) return progressCompare;
return _scoreRepeater(
senderLocation: senderLocation,
recipientLocation: recipientLatLng,
repeater: a,
).compareTo(
_scoreRepeater(
senderLocation: senderLocation,
recipientLocation: recipientLatLng,
repeater: b,
),
);
});
final selected = <Contact>[];
var currentPoint = senderLocation;
var currentDistanceToRecipient = _distance.as(
LengthUnit.Meter,
senderLocation,
recipientLatLng,
);
final maxCorridorDistance = math.max(
1500.0,
currentDistanceToRecipient * 0.22,
);
for (final repeater in corridorRepeaters) {
if (selected.length >= 4) break;
final repeaterPoint = LatLng(
repeater.displayLocation!.latitude,
repeater.displayLocation!.longitude,
);
final distanceToSegment = _distanceToSegmentMeters(
repeaterPoint,
senderLocation,
recipientLatLng,
);
if (distanceToSegment > maxCorridorDistance) {
continue;
}
final nextDistanceToRecipient = _distance.as(
LengthUnit.Meter,
repeaterPoint,
recipientLatLng,
);
if (nextDistanceToRecipient >= currentDistanceToRecipient - 300) {
continue;
}
final distanceFromCurrent = _distance.as(
LengthUnit.Meter,
currentPoint,
repeaterPoint,
);
if (distanceFromCurrent < 100) {
continue;
}
selected.add(repeater);
currentPoint = repeaterPoint;
currentDistanceToRecipient = nextDistanceToRecipient;
if (currentDistanceToRecipient < 2500) {
break;
}
}
if (selected.isEmpty) {
return null;
}
return ResolvedContactRoutePlan(
tokens: selected.map((contact) => _tokenFor(contact, hashSize)).toList(),
selectedContacts: selected,
summary: 'Resolved from repeater locations',
);
}
static List<Contact> _matchContactsForTokens(
List<String> tokens, {
required List<Contact> availableContacts,
}) {
final matches = <Contact>[];
final seen = <String>{};
for (final token in tokens) {
final match = availableContacts
.where(
(contact) => contact.publicKeyHex.toUpperCase().startsWith(token),
)
.firstOrNull;
if (match != null && seen.add(match.publicKeyHex)) {
matches.add(match);
}
}
return matches;
}
static List<String> _dedupeTokens(List<String> tokens) {
final result = <String>[];
for (final token in tokens) {
if (result.isEmpty || result.last != token) {
result.add(token);
}
}
return result;
}
static String _tokenFor(Contact contact, int hashSize) {
final hex = contact.publicKeyHex.toUpperCase();
final length = hashSize * 2;
return hex.length < length ? hex : hex.substring(0, length);
}
static double _scoreRepeater({
required LatLng senderLocation,
required LatLng recipientLocation,
required Contact repeater,
}) {
final point = LatLng(
repeater.displayLocation!.latitude,
repeater.displayLocation!.longitude,
);
final toRecipient = _distance.as(
LengthUnit.Meter,
point,
recipientLocation,
);
final corridor = _distanceToSegmentMeters(
point,
senderLocation,
recipientLocation,
);
return (toRecipient * 0.75) + (corridor * 0.25);
}
static double _scoreKnownRouteRepeater({
required LatLng senderLocation,
required LatLng recipientLocation,
required Contact repeater,
required List<Contact> availableContacts,
required int hashSize,
}) {
final baseScore = _scoreRepeater(
senderLocation: senderLocation,
recipientLocation: recipientLocation,
repeater: repeater,
);
final repeaterPoint = LatLng(
repeater.displayLocation!.latitude,
repeater.displayLocation!.longitude,
);
final chainContacts = repeater.routeCanonicalText
.split(',')
.where((token) => token.isNotEmpty)
.map(
(token) => availableContacts
.where(
(contact) =>
contact.displayLocation != null &&
contact.publicKeyHex.toUpperCase().startsWith(
token.toUpperCase(),
),
)
.firstOrNull,
)
.whereType<Contact>()
.toList();
final chainEnd = chainContacts.isNotEmpty
? LatLng(
chainContacts.last.displayLocation!.latitude,
chainContacts.last.displayLocation!.longitude,
)
: senderLocation;
final chainGap = _distance.as(LengthUnit.Meter, chainEnd, repeaterPoint);
final senderGap = _distance.as(
LengthUnit.Meter,
senderLocation,
repeaterPoint,
);
return (chainGap * 0.55) + (baseScore * 0.35) + (senderGap * 0.10);
}
static double _progressAlongSegment({
required LatLng point,
required LatLng start,
required LatLng end,
}) {
final dx = end.longitude - start.longitude;
final dy = end.latitude - start.latitude;
final lengthSquared = (dx * dx) + (dy * dy);
if (lengthSquared == 0) return 0;
return (((point.longitude - start.longitude) * dx) +
((point.latitude - start.latitude) * dy)) /
lengthSquared;
}
static double _distanceToSegmentMeters(LatLng p, LatLng a, LatLng b) {
final ax = a.longitude;
final ay = a.latitude;
final bx = b.longitude;
final by = b.latitude;
final px = p.longitude;
final py = p.latitude;
final abx = bx - ax;
final aby = by - ay;
final apx = px - ax;
final apy = py - ay;
final ab2 = abx * abx + aby * aby;
if (ab2 == 0) {
return _distance.as(LengthUnit.Meter, a, p);
}
var t = (apx * abx + apy * aby) / ab2;
t = t.clamp(0.0, 1.0);
final closest = LatLng(ay + aby * t, ax + abx * t);
return _distance.as(LengthUnit.Meter, closest, p);
}
}

View File

@@ -2,13 +2,17 @@ import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart';
import '../models/contact_group.dart';
import '../utils/key_comparison.dart';
import 'package:latlong2/latlong.dart';
/// Service for persisting contacts to local storage
class ContactStorageService {
static const String _contactsKey = 'stored_contacts';
static const String _contactGroupsKey = 'stored_contact_groups';
static const String _pendingAdvertsKey = 'stored_pending_adverts';
static const int _maxStoredContacts = 500; // Store up to 500 contacts
static const int _maxStoredPendingAdverts = 500;
/// Save contacts to persistent storage
Future<void> saveContacts(List<Contact> contacts) async {
@@ -90,6 +94,81 @@ class ContactStorageService {
}
}
Future<void> saveContactGroups(List<SavedContactGroup> groups) async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = jsonEncode(
groups.map((group) => _contactGroupToJson(group)).toList(),
);
await prefs.setString(_contactGroupsKey, jsonString);
debugPrint(
'✅ [ContactStorage] Saved ${groups.length} contact groups to storage',
);
} catch (e) {
debugPrint('❌ [ContactStorage] Error saving contact groups: $e');
}
}
Future<List<SavedContactGroup>> loadContactGroups() async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_contactGroupsKey);
if (jsonString == null || jsonString.isEmpty) {
return [];
}
final jsonList = jsonDecode(jsonString) as List<dynamic>;
return jsonList
.map((json) => _contactGroupFromJson(json as Map<String, dynamic>))
.whereType<SavedContactGroup>()
.toList();
} catch (e) {
debugPrint('❌ [ContactStorage] Error loading contact groups: $e');
return [];
}
}
Future<void> savePendingAdverts(List<Map<String, dynamic>> adverts) async {
try {
final prefs = await SharedPreferences.getInstance();
final limitedList = adverts.length > _maxStoredPendingAdverts
? adverts.sublist(adverts.length - _maxStoredPendingAdverts)
: adverts;
await prefs.setString(_pendingAdvertsKey, jsonEncode(limitedList));
debugPrint(
'✅ [ContactStorage] Saved ${limitedList.length} pending adverts to storage',
);
} catch (e) {
debugPrint('❌ [ContactStorage] Error saving pending adverts: $e');
}
}
Future<List<Map<String, dynamic>>> loadPendingAdverts() async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_pendingAdvertsKey);
if (jsonString == null || jsonString.isEmpty) {
return const [];
}
final jsonList = jsonDecode(jsonString) as List<dynamic>;
return jsonList.whereType<Map<String, dynamic>>().toList();
} catch (e) {
debugPrint('❌ [ContactStorage] Error loading pending adverts: $e');
return const [];
}
}
Future<void> clearPendingAdverts() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_pendingAdvertsKey);
debugPrint('✅ [ContactStorage] Cleared all stored pending adverts');
} catch (e) {
debugPrint('❌ [ContactStorage] Error clearing pending adverts: $e');
}
}
/// Get storage statistics
Future<Map<String, dynamic>> getStorageStats() async {
try {
@@ -123,6 +202,7 @@ class ContactStorageService {
'outPathLen': contact.outPathLen,
'outPath': base64Encode(contact.outPath),
'advName': contact.advName,
'nameOverride': contact.nameOverride,
'lastAdvert': contact.lastAdvert,
'advLat': contact.advLat,
'advLon': contact.advLon,
@@ -130,6 +210,15 @@ class ContactStorageService {
'telemetry': contact.telemetry != null
? _telemetryToJson(contact.telemetry!)
: null,
'advertHistory': contact.advertHistory
.map(
(point) => {
'lat': point.location.latitude,
'lon': point.location.longitude,
'tsMillis': point.timestamp.millisecondsSinceEpoch,
},
)
.toList(),
};
}
@@ -145,6 +234,7 @@ class ContactStorageService {
outPathLen: json['outPathLen'] as int,
outPath: Uint8List.fromList(base64Decode(json['outPath'] as String)),
advName: json['advName'] as String,
nameOverride: json['nameOverride'] as String?,
lastAdvert: json['lastAdvert'] as int,
advLat: json['advLat'] as int,
advLon: json['advLon'] as int,
@@ -152,6 +242,7 @@ class ContactStorageService {
telemetry: json['telemetry'] != null
? _telemetryFromJson(json['telemetry'] as Map<String, dynamic>)
: null,
advertHistory: _advertHistoryFromJson(json['advertHistory']),
);
} catch (e) {
debugPrint('❌ [ContactStorage] Error parsing contact from JSON: $e');
@@ -203,4 +294,56 @@ class ContactStorageService {
return null;
}
}
List<AdvertLocation> _advertHistoryFromJson(dynamic json) {
if (json is! List) return [];
final result = <AdvertLocation>[];
for (final item in json) {
if (item is! Map<String, dynamic>) continue;
final lat = item['lat'];
final lon = item['lon'];
final tsMillis = item['tsMillis'];
if (lat is! num || lon is! num || tsMillis is! int) continue;
result.add(
AdvertLocation(
location: LatLng(lat.toDouble(), lon.toDouble()),
timestamp: DateTime.fromMillisecondsSinceEpoch(tsMillis),
),
);
}
return result;
}
Map<String, dynamic> _contactGroupToJson(SavedContactGroup group) {
return {
'id': group.id,
'sectionKey': group.sectionKey,
'label': group.label,
'query': group.query,
'createdAtMillis': group.createdAt.millisecondsSinceEpoch,
'matchPrefixes': group.matchPrefixes,
'isAutoGroup': group.isAutoGroup,
};
}
SavedContactGroup? _contactGroupFromJson(Map<String, dynamic> json) {
try {
return SavedContactGroup(
id: json['id'] as String,
sectionKey: json['sectionKey'] as String,
label: json['label'] as String,
query: json['query'] as String,
createdAt: DateTime.fromMillisecondsSinceEpoch(
json['createdAtMillis'] as int,
),
matchPrefixes: (json['matchPrefixes'] as List<dynamic>?)
?.map((value) => value as String)
.toList(),
isAutoGroup: json['isAutoGroup'] as bool? ?? false,
);
} catch (e) {
debugPrint('❌ [ContactStorage] Error parsing contact group: $e');
return null;
}
}
}

View File

@@ -0,0 +1,15 @@
import 'package:shared_preferences/shared_preferences.dart';
class DeveloperModeService {
static const String _developerModeKey = 'developer_mode_enabled';
static Future<bool> isEnabled() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_developerModeKey) ?? false;
}
static Future<void> setEnabled(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_developerModeKey, enabled);
}
}

View File

@@ -0,0 +1,247 @@
import '../models/ble_packet_log.dart';
import '../utils/log_rx_route_decoder.dart';
enum LiveTrafficBusyness { quiet, active, busy }
class LiveTrafficEntry {
final BlePacketLog log;
final DecodedLogRxRoute? route;
const LiveTrafficEntry({required this.log, required this.route});
bool get isMultiHop => (route?.hopCount ?? 0) > 1;
int? get hopCount => route?.hopCount;
String get payloadLabel {
final decodedRoute = route;
if (decodedRoute == null) {
return log.responseCode != null ? log.opcodeName : 'Unknown';
}
return payloadTypeLabel(decodedRoute.payloadType);
}
String? get payloadMeaning {
final decodedRoute = route;
if (decodedRoute == null) return null;
return payloadTypeMeaning(decodedRoute.payloadType);
}
String get routePreview {
final decodedRoute = route;
if (decodedRoute == null || decodedRoute.hopHashes.isEmpty) {
return 'Direct packet';
}
return decodedRoute.hopHashes
.map((hashHex) => '0x${hashHex.toUpperCase()}')
.join(' -> ');
}
static String payloadTypeLabel(int payloadType) {
switch (payloadType) {
case 0x00:
return 'Request';
case 0x01:
return 'Response';
case 0x02:
return 'Text message';
case 0x03:
return 'Ack';
case 0x04:
return 'Advertisement';
case 0x05:
return 'Group text';
case 0x06:
return 'Group datagram';
case 0x07:
return 'Anonymous request';
case 0x08:
return 'Returned path';
case 0x09:
return 'Trace path';
case 0x0A:
return 'Multipart packet';
case 0x0B:
return 'Control packet';
default:
return '0x${payloadType.toRadixString(16).padLeft(2, '0')}';
}
}
static String payloadTypeMeaning(int payloadType) {
switch (payloadType) {
case 0x00:
return 'Request (destination/source hashes + MAC)';
case 0x01:
return 'Response to Request or Anonymous request';
case 0x02:
return 'Plain text message';
case 0x03:
return 'Simple acknowledgement';
case 0x04:
return 'Node advertisement';
case 0x05:
return 'Unverified group text message';
case 0x06:
return 'Unverified group datagram';
case 0x07:
return 'Generic anonymous request';
case 0x08:
return 'Returned path payload';
case 0x09:
return 'Trace path collecting hop SNR';
case 0x0A:
return 'One packet from a multipart set';
case 0x0B:
return 'Control or discovery packet';
default:
return 'protocol payload';
}
}
}
class LiveTrafficSnapshot {
final DateTime windowStart;
final Duration windowDuration;
final int packetsPerMinute;
final int rxCount;
final int txCount;
final int totalCount;
final double? avgSnrDb;
final double? latestSnrDb;
final double? avgRssiDbm;
final int? latestRssiDbm;
final int multiHopCount;
final double? avgHopCount;
final List<LiveTrafficEntry> visibleEntries;
final LiveTrafficBusyness busyness;
const LiveTrafficSnapshot({
required this.windowStart,
required this.windowDuration,
required this.packetsPerMinute,
required this.rxCount,
required this.txCount,
required this.totalCount,
required this.avgSnrDb,
required this.latestSnrDb,
required this.avgRssiDbm,
required this.latestRssiDbm,
required this.multiHopCount,
required this.avgHopCount,
required this.visibleEntries,
required this.busyness,
});
}
class LiveTrafficSummary {
static const Duration rollingWindow = Duration(seconds: 60);
static const int maxVisibleEntries = 120;
static const int logRxDataResponseCode = 0x88;
const LiveTrafficSummary._();
static LiveTrafficSnapshot fromLogs(
Iterable<BlePacketLog> logs, {
required DateTime now,
DateTime? clearedAt,
int? preferredHashSize,
Duration window = rollingWindow,
String? packetTypeFilter,
}) {
final windowStart = now.subtract(window);
final effectiveStart = clearedAt != null && clearedAt.isAfter(windowStart)
? clearedAt
: windowStart;
final recentLogs = logs
.where(
(log) =>
log.direction == PacketDirection.rx &&
log.responseCode == logRxDataResponseCode &&
!log.timestamp.isBefore(effectiveStart),
)
.toList()
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
final entries = <LiveTrafficEntry>[];
for (final log in recentLogs) {
final route = LogRxRouteDecoder.decode(
log.rawData,
preferredHashSize: preferredHashSize,
);
entries.add(LiveTrafficEntry(log: log, route: route));
}
final filteredEntries = packetTypeFilter == null
? entries
: entries
.where((entry) => entry.payloadLabel == packetTypeFilter)
.toList();
var rxCount = 0;
var snrCount = 0;
var snrSum = 0.0;
var rssiCount = 0;
var rssiSum = 0.0;
double? latestSnrDb;
int? latestRssiDbm;
var multiHopCount = 0;
var hopCountTotal = 0;
var hopCountSamples = 0;
for (final entry in filteredEntries) {
rxCount += 1;
final rxInfo = entry.log.logRxDataInfo;
if (rxInfo?.snrDb != null) {
snrCount += 1;
snrSum += rxInfo!.snrDb!;
latestSnrDb = rxInfo.snrDb!;
}
if (rxInfo?.rssiDbm != null) {
rssiCount += 1;
rssiSum += rxInfo!.rssiDbm!.toDouble();
latestRssiDbm = rxInfo.rssiDbm!;
}
final route = entry.route;
if (route != null && route.hopCount > 0) {
hopCountSamples += 1;
hopCountTotal += route.hopCount;
if (route.hopCount > 1) {
multiHopCount += 1;
}
}
}
final visibleEntries = filteredEntries.reversed.take(maxVisibleEntries).toList();
const txCount = 0;
final totalCount = rxCount;
final packetsPerMinute =
((totalCount * Duration.secondsPerMinute) / window.inSeconds).round();
return LiveTrafficSnapshot(
windowStart: effectiveStart,
windowDuration: window,
packetsPerMinute: packetsPerMinute,
rxCount: rxCount,
txCount: txCount,
totalCount: totalCount,
avgSnrDb: snrCount == 0 ? null : snrSum / snrCount,
latestSnrDb: latestSnrDb,
avgRssiDbm: rssiCount == 0 ? null : rssiSum / rssiCount,
latestRssiDbm: latestRssiDbm,
multiHopCount: multiHopCount,
avgHopCount: hopCountSamples == 0 ? null : hopCountTotal / hopCountSamples,
visibleEntries: visibleEntries,
busyness: _busynessForPacketsPerMinute(packetsPerMinute),
);
}
static LiveTrafficBusyness _busynessForPacketsPerMinute(int ppm) {
if (ppm <= 5) return LiveTrafficBusyness.quiet;
if (ppm <= 20) return LiveTrafficBusyness.active;
return LiveTrafficBusyness.busy;
}
}

View File

@@ -15,6 +15,10 @@ class LocalePreferences {
Locale('fr'), // French
Locale('it'), // Italian
Locale('el'), // Greek
Locale('pt'), // Portuguese
Locale('tr'), // Turkish
Locale('pl'), // Polish
Locale('uk'), // Ukrainian
Locale('ru'), // Russian
Locale('zh'), // Chinese
];
@@ -66,6 +70,14 @@ class LocalePreferences {
return 'Italiano';
case 'el':
return 'Greek';
case 'pt':
return 'Portuguese';
case 'tr':
return 'Turkish';
case 'pl':
return 'Polish';
case 'uk':
return 'Ukrainian';
case 'ru':
return 'Русский';
case 'zh':
@@ -94,6 +106,14 @@ class LocalePreferences {
return 'Italiano';
case 'el':
return 'Ελληνικά';
case 'pt':
return 'Português';
case 'tr':
return 'Türkçe';
case 'pl':
return 'Polski';
case 'uk':
return 'Українська';
case 'ru':
return 'Русский';
case 'zh':

View File

@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:geolocator/geolocator.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -148,6 +149,10 @@ class LocationTrackingService {
/// Check if location permissions are granted
Future<bool> checkPermissions() async {
final permission = await Geolocator.checkPermission();
if (Platform.isIOS) {
return permission == LocationPermission.always;
}
return permission == LocationPermission.always ||
permission == LocationPermission.whileInUse;
}
@@ -181,6 +186,16 @@ class LocationTrackingService {
return false;
}
if (Platform.isIOS && permission != LocationPermission.always) {
permission = await Geolocator.requestPermission();
if (permission != LocationPermission.always) {
onError?.call(
'Background tracking on iOS requires "Always" location access. Enable it in Settings.',
);
return false;
}
}
debugPrint('✅ [LocationTracking] Location permissions granted');
return true;
}
@@ -209,9 +224,9 @@ class LocationTrackingService {
}
final position = await Geolocator.getCurrentPosition(
locationSettings: LocationSettings(
accuracy: LocationAccuracy.best,
locationSettings: _buildLocationSettings(
timeLimit: timeLimit,
distanceFilter: 0,
),
);
@@ -255,13 +270,35 @@ class LocationTrackingService {
/// [distanceFilter] - Minimum distance in meters between position updates
Stream<Position> getPositionStream({double distanceFilter = 10.0}) {
return Geolocator.getPositionStream(
locationSettings: LocationSettings(
accuracy: LocationAccuracy.best,
locationSettings: _buildLocationSettings(
distanceFilter: distanceFilter.toInt(),
),
);
}
LocationSettings _buildLocationSettings({
int distanceFilter = 10,
Duration? timeLimit,
}) {
if (Platform.isIOS) {
return AppleSettings(
accuracy: LocationAccuracy.best,
activityType: ActivityType.fitness,
allowBackgroundLocationUpdates: true,
distanceFilter: distanceFilter,
pauseLocationUpdatesAutomatically: false,
showBackgroundLocationIndicator: true,
timeLimit: timeLimit,
);
}
return LocationSettings(
accuracy: LocationAccuracy.best,
distanceFilter: distanceFilter,
timeLimit: timeLimit,
);
}
// ============================================================================
// Tracking Control
// ============================================================================

View File

@@ -1,5 +1,7 @@
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
class MeshMapNode {
final int type;
@@ -33,25 +35,36 @@ class MeshMapNode {
class MeshMapNodesService {
static const String _nodesEndpoint =
'https://api.meshcore.nz/api/v1/map/nodes';
static const Duration _cacheTtl = Duration(minutes: 2);
static const Duration traceCacheTtl = Duration(minutes: 10);
static const int repeaterType = 1;
static const Duration _cacheTtl = Duration(hours: 24);
static const Duration traceCacheTtl = _cacheTtl;
static const Duration traceTimeout = Duration(seconds: 30);
static const String _cacheKey = 'mesh_map_nodes_cache_v1';
static const String _cacheTimestampKey = 'mesh_map_nodes_cache_timestamp_v1';
static List<MeshMapNode>? _cachedNodes;
static DateTime? _cachedAt;
static Future<void>? _ongoingSync;
static Future<List<MeshMapNode>> fetchNodes({
bool forceRefresh = false,
Duration cacheTtl = _cacheTtl,
bool allowNetwork = true,
http.Client? client,
}) async {
final now = DateTime.now();
final cached = await loadCachedNodes(cacheTtl: cacheTtl);
if (!forceRefresh &&
_cachedNodes != null &&
cached.isNotEmpty &&
_cachedAt != null &&
now.difference(_cachedAt!) < cacheTtl) {
return _cachedNodes!;
return cached;
}
final response = await http
if (!allowNetwork) {
return cached;
}
final response = await (client ?? http.Client())
.get(Uri.parse(_nodesEndpoint))
.timeout(traceTimeout);
if (response.statusCode < 200 || response.statusCode >= 300) {
@@ -69,8 +82,133 @@ class MeshMapNodesService {
)
.toList();
_cachedNodes = nodes;
_cachedAt = now;
await _storeCache(nodes, cachedAt: now);
return nodes;
}
static Future<List<MeshMapNode>> loadCachedNodes({
Duration cacheTtl = _cacheTtl,
}) async {
final now = DateTime.now();
if (_cachedNodes != null &&
_cachedAt != null &&
now.difference(_cachedAt!) < cacheTtl) {
return _cachedNodes!;
}
final prefs = await SharedPreferences.getInstance();
final cachedAtMs = prefs.getInt(_cacheTimestampKey);
final cachedJson = prefs.getString(_cacheKey);
if (cachedAtMs == null || cachedJson == null) {
_cachedNodes = null;
_cachedAt = null;
return const [];
}
final cachedAt = DateTime.fromMillisecondsSinceEpoch(cachedAtMs);
if (now.difference(cachedAt) >= cacheTtl) {
_cachedNodes = null;
_cachedAt = cachedAt;
return const [];
}
final decoded = jsonDecode(cachedJson) as List<dynamic>;
final nodes = decoded
.whereType<Map<String, dynamic>>()
.map(MeshMapNode.fromJson)
.where(
(n) => n.publicKey.isNotEmpty && n.latitude != 0 && n.longitude != 0,
)
.toList();
_cachedNodes = nodes;
_cachedAt = cachedAt;
return nodes;
}
static Future<bool> hasFreshCache({Duration cacheTtl = _cacheTtl}) async {
final nodes = await loadCachedNodes(cacheTtl: cacheTtl);
return nodes.isNotEmpty;
}
static Future<void> syncInBackgroundIfStale({
Duration cacheTtl = _cacheTtl,
http.Client? client,
}) async {
final now = DateTime.now();
if (_cachedAt != null && now.difference(_cachedAt!) < cacheTtl) {
return;
}
final cached = await loadCachedNodes(cacheTtl: cacheTtl);
if (cached.isNotEmpty && _cachedAt != null) {
return;
}
if (_ongoingSync != null) {
return _ongoingSync!;
}
_ongoingSync = () async {
try {
await fetchNodes(
forceRefresh: true,
cacheTtl: cacheTtl,
allowNetwork: true,
client: client,
);
} catch (_) {
// Background refresh is best-effort only.
} finally {
_ongoingSync = null;
}
}();
return _ongoingSync!;
}
static Future<void> clearCache() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_cacheKey);
await prefs.remove(_cacheTimestampKey);
_cachedNodes = null;
_cachedAt = null;
}
static Future<DateTime?> cachedAt() async {
if (_cachedAt != null) return _cachedAt;
final prefs = await SharedPreferences.getInstance();
final cachedAtMs = prefs.getInt(_cacheTimestampKey);
if (cachedAtMs == null) return null;
_cachedAt = DateTime.fromMillisecondsSinceEpoch(cachedAtMs);
return _cachedAt;
}
static bool isRepeater(MeshMapNode node) => node.type == repeaterType;
static Future<void> _storeCache(
List<MeshMapNode> nodes, {
required DateTime cachedAt,
}) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
_cacheKey,
jsonEncode(
nodes
.map(
(node) => {
'type': node.type,
'name': node.name,
'public_key': node.publicKey,
'latitude': node.latitude,
'longitude': node.longitude,
'updated_at': node.updatedAtMs,
},
)
.toList(),
),
);
await prefs.setInt(_cacheTimestampKey, cachedAt.millisecondsSinceEpoch);
_cachedNodes = nodes;
_cachedAt = cachedAt;
}
}

View File

@@ -7,6 +7,7 @@ class MessageDestinationPreferences {
static const String _recipientPublicKeyKey = 'message_recipient_public_key';
/// Destination types
static const String destinationTypeAll = 'all';
static const String destinationTypeChannel = 'channel';
static const String destinationTypeContact = 'contact';
static const String destinationTypeRoom = 'room';
@@ -24,14 +25,11 @@ class MessageDestinationPreferences {
final publicKey = prefs.getString(_recipientPublicKeyKey);
return {
'type': type,
'publicKey': ?publicKey,
};
return {'type': type, 'publicKey': ?publicKey};
}
/// Save the selected destination
/// [type] - one of: destinationTypeChannel, destinationTypeContact, destinationTypeRoom
/// [type] - one of: destinationTypeAll, destinationTypeChannel, destinationTypeContact, destinationTypeRoom
/// [recipientPublicKey] - hex string of recipient's public key (required for contact/room)
static Future<void> setDestination(
String type, {
@@ -58,6 +56,8 @@ class MessageDestinationPreferences {
/// Get display name for destination type
static String getDestinationTypeName(String type) {
switch (type) {
case destinationTypeAll:
return 'All';
case destinationTypeChannel:
return 'Channel';
case destinationTypeContact:

View File

@@ -5,17 +5,23 @@ import '../models/message.dart';
import '../models/message_contact_location.dart';
import '../models/message_reception_details.dart';
import '../models/message_transfer_details.dart';
import '../models/message_route_metadata.dart';
import 'package:latlong2/latlong.dart';
/// Service for persisting messages to local storage
class MessageStorageService {
static const String _messagesKey = 'stored_messages';
static const String _removedSarMarkerIdsKey = 'removed_sar_marker_ids';
static const String _messageContactLocationsKey =
'stored_message_contact_locations';
static const String _messageReceptionDetailsKey =
'stored_message_reception_details';
static const String _messageTransferDetailsKey =
'stored_message_transfer_details';
static const String _messageRouteMetadataKey =
'stored_message_route_metadata';
static const String _embeddedReceptionDetailsKey = 'storedReceptionDetails';
static const String _legacyPathBytesKey = 'storedPathBytes';
static const int _maxStoredMessages = 1000; // Store up to 1000 messages
/// Save messages to persistent storage
@@ -24,12 +30,21 @@ class MessageStorageService {
Map<String, MessageContactLocation> messageContactLocations = const {},
Map<String, MessageReceptionDetails> messageReceptionDetails = const {},
Map<String, MessageTransferDetails> messageTransferDetails = const {},
Map<String, MessageRouteMetadata> messageRouteMetadata = const {},
}) async {
try {
final prefs = await SharedPreferences.getInstance();
// Convert messages to JSON
final jsonList = messages.map((msg) => _messageToJson(msg)).toList();
// Convert messages to JSON and embed path bytes as a fallback so they
// survive restore even if the sidecar reception-details entry is absent.
final jsonList = messages
.map(
(msg) => _messageToJson(
msg,
receptionDetails: messageReceptionDetails[msg.id],
),
)
.toList();
// Limit to max stored messages (keep most recent)
final limitedList = jsonList.length > _maxStoredMessages
@@ -44,6 +59,7 @@ class MessageStorageService {
final locationJson = <String, dynamic>{};
final receptionJson = <String, dynamic>{};
final transferJson = <String, dynamic>{};
final routeMetadataJson = <String, dynamic>{};
for (final entry in messageContactLocations.entries) {
if (retainedMessageIds.contains(entry.key)) {
locationJson[entry.key] = entry.value.toJson();
@@ -59,6 +75,11 @@ class MessageStorageService {
transferJson[entry.key] = entry.value.toJson();
}
}
for (final entry in messageRouteMetadata.entries) {
if (retainedMessageIds.contains(entry.key)) {
routeMetadataJson[entry.key] = entry.value.toJson();
}
}
await prefs.setString(
_messageContactLocationsKey,
jsonEncode(locationJson),
@@ -71,6 +92,10 @@ class MessageStorageService {
_messageTransferDetailsKey,
jsonEncode(transferJson),
);
await prefs.setString(
_messageRouteMetadataKey,
jsonEncode(routeMetadataJson),
);
debugPrint(
'✅ [MessageStorage] Saved ${limitedList.length} messages to storage',
@@ -115,24 +140,36 @@ class MessageStorageService {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messageReceptionDetailsKey);
if (jsonString == null || jsonString.isEmpty) {
return const {};
}
final decoded = jsonDecode(jsonString);
if (decoded is! Map<String, dynamic>) {
return const {};
}
final result = <String, MessageReceptionDetails>{};
for (final entry in decoded.entries) {
final value = entry.value;
if (value is! Map<String, dynamic>) continue;
final snapshot = MessageReceptionDetails.fromJson(value);
if (snapshot != null) {
result[entry.key] = snapshot;
if (jsonString != null && jsonString.isNotEmpty) {
final decoded = jsonDecode(jsonString);
if (decoded is Map<String, dynamic>) {
for (final entry in decoded.entries) {
final value = entry.value;
if (value is! Map<String, dynamic>) continue;
final snapshot = MessageReceptionDetails.fromJson(value);
if (snapshot != null) {
result[entry.key] = snapshot;
}
}
}
}
final embeddedReceptionDetails = await _loadEmbeddedReceptionDetails();
embeddedReceptionDetails.forEach((messageId, snapshot) {
result.putIfAbsent(messageId, () => snapshot);
});
final fallbackPathBytes = await _loadLegacyPathBytesFromMessages();
fallbackPathBytes.forEach((messageId, pathBytes) {
result.putIfAbsent(
messageId,
() => MessageReceptionDetails(
capturedAt: DateTime.fromMillisecondsSinceEpoch(0),
pathBytes: pathBytes,
),
);
});
return result;
} catch (e) {
debugPrint('❌ [MessageStorage] Error loading reception details: $e');
@@ -170,6 +207,32 @@ class MessageStorageService {
}
}
Future<Map<String, MessageRouteMetadata>> loadMessageRouteMetadata() async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messageRouteMetadataKey);
if (jsonString == null || jsonString.isEmpty) {
return const {};
}
final decoded = jsonDecode(jsonString);
if (decoded is! Map<String, dynamic>) {
return const {};
}
final result = <String, MessageRouteMetadata>{};
for (final entry in decoded.entries) {
final value = entry.value;
if (value is! Map<String, dynamic>) continue;
result[entry.key] = MessageRouteMetadata.fromJson(value);
}
return result;
} catch (e) {
debugPrint('❌ [MessageStorage] Error loading route metadata: $e');
return const {};
}
}
/// Load messages from persistent storage
Future<List<Message>> loadMessages() async {
try {
@@ -206,12 +269,37 @@ class MessageStorageService {
await prefs.remove(_messageContactLocationsKey);
await prefs.remove(_messageReceptionDetailsKey);
await prefs.remove(_messageTransferDetailsKey);
await prefs.remove(_messageRouteMetadataKey);
await prefs.remove(_removedSarMarkerIdsKey);
debugPrint('✅ [MessageStorage] Cleared all stored messages');
} catch (e) {
debugPrint('❌ [MessageStorage] Error clearing messages: $e');
}
}
Future<Set<String>> loadRemovedSarMarkerIds() async {
try {
final prefs = await SharedPreferences.getInstance();
final ids = prefs.getStringList(_removedSarMarkerIdsKey) ?? const [];
return ids.toSet();
} catch (e) {
debugPrint('❌ [MessageStorage] Error loading removed SAR marker IDs: $e');
return const <String>{};
}
}
Future<void> saveRemovedSarMarkerIds(Set<String> ids) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setStringList(_removedSarMarkerIdsKey, ids.toList()..sort());
debugPrint(
'✅ [MessageStorage] Saved ${ids.length} removed SAR marker IDs',
);
} catch (e) {
debugPrint('❌ [MessageStorage] Error saving removed SAR marker IDs: $e');
}
}
/// Get storage statistics
Future<Map<String, dynamic>> getStorageStats() async {
try {
@@ -237,7 +325,12 @@ class MessageStorageService {
}
/// Convert Message to JSON
Map<String, dynamic> _messageToJson(Message message) {
Map<String, dynamic> _messageToJson(
Message message, {
MessageReceptionDetails? receptionDetails,
}) {
final persistedPathBytes =
message.pathBytes ?? _pathBytesFromSnapshot(receptionDetails);
return {
'id': message.id,
'messageType': message.messageType.name,
@@ -246,6 +339,7 @@ class MessageStorageService {
: null,
'channelIdx': message.channelIdx,
'pathLen': message.pathLen,
'pathBytes': persistedPathBytes?.toList(),
'textType': message.textType.value,
'senderTimestamp': message.senderTimestamp,
'text': message.text,
@@ -297,9 +391,67 @@ class MessageStorageService {
},
)
.toList(),
if (receptionDetails != null)
_embeddedReceptionDetailsKey: receptionDetails.toJson(),
if (receptionDetails?.pathBytes case final pathBytes?)
_legacyPathBytesKey: List<int>.from(pathBytes),
};
}
Future<Map<String, MessageReceptionDetails>>
_loadEmbeddedReceptionDetails() async {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messagesKey);
if (jsonString == null || jsonString.isEmpty) {
return const {};
}
final decoded = jsonDecode(jsonString);
if (decoded is! List) {
return const {};
}
final result = <String, MessageReceptionDetails>{};
for (final entry in decoded) {
if (entry is! Map<String, dynamic>) continue;
final messageId = entry['id'];
final embedded = entry[_embeddedReceptionDetailsKey];
if (messageId is! String || embedded is! Map<String, dynamic>) continue;
final snapshot = MessageReceptionDetails.fromJson(embedded);
if (snapshot == null) continue;
result[messageId] = snapshot;
}
return result;
}
Future<Map<String, List<int>>> _loadLegacyPathBytesFromMessages() async {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messagesKey);
if (jsonString == null || jsonString.isEmpty) {
return const {};
}
final decoded = jsonDecode(jsonString);
if (decoded is! List) {
return const {};
}
final result = <String, List<int>>{};
for (final entry in decoded) {
if (entry is! Map<String, dynamic>) continue;
final messageId = entry['id'];
final pathBytes = entry[_legacyPathBytesKey];
if (messageId is! String || pathBytes is! List) continue;
final normalized = pathBytes
.whereType<num>()
.map((b) => b.toInt())
.toList();
if (normalized.isEmpty) continue;
result[messageId] = normalized;
}
return result;
}
/// Convert JSON to Message
Message? _messageFromJson(Map<String, dynamic> json) {
try {
@@ -316,6 +468,14 @@ class MessageStorageService {
: null,
channelIdx: json['channelIdx'] as int?,
pathLen: json['pathLen'] as int,
pathBytes: json['pathBytes'] is List
? Uint8List.fromList(
(json['pathBytes'] as List<dynamic>)
.whereType<num>()
.map((b) => b.toInt())
.toList(),
)
: null,
textType: MessageTextType.fromValue(json['textType'] as int),
senderTimestamp: json['senderTimestamp'] as int,
text: json['text'] as String,
@@ -413,4 +573,12 @@ class MessageStorageService {
return null;
}
}
Uint8List? _pathBytesFromSnapshot(MessageReceptionDetails? receptionDetails) {
final pathBytes = receptionDetails?.pathBytes;
if (pathBytes == null || pathBytes.isEmpty) {
return null;
}
return Uint8List.fromList(pathBytes);
}
}

View File

@@ -0,0 +1,46 @@
import 'package:shared_preferences/shared_preferences.dart';
class MessagingRoutePreferences {
static const bool defaultAutoRouteRotationEnabled = false;
static const bool defaultClearPathOnMaxRetry = false;
static const bool defaultNearestRelayFallbackEnabled = true;
static const String _autoRouteRotationKey =
'messaging_auto_route_rotation_enabled';
static const String _clearPathOnMaxRetryKey =
'messaging_clear_path_on_max_retry';
static const String _nearestRelayFallbackKey =
'messaging_nearest_relay_fallback_enabled';
static Future<bool> getAutoRouteRotationEnabled() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_autoRouteRotationKey) ??
defaultAutoRouteRotationEnabled;
}
static Future<void> setAutoRouteRotationEnabled(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_autoRouteRotationKey, enabled);
}
static Future<bool> getClearPathOnMaxRetry() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_clearPathOnMaxRetryKey) ?? defaultClearPathOnMaxRetry;
}
static Future<void> setClearPathOnMaxRetry(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_clearPathOnMaxRetryKey, enabled);
}
static Future<bool> getNearestRelayFallbackEnabled() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_nearestRelayFallbackKey) ??
defaultNearestRelayFallbackEnabled;
}
static Future<void> setNearestRelayFallbackEnabled(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_nearestRelayFallbackKey, enabled);
}
}

View File

@@ -0,0 +1,69 @@
import 'package:geolocator/geolocator.dart';
import '../models/contact.dart';
class NearestRouterSelector {
const NearestRouterSelector();
Contact? select({
required Position? senderPosition,
required List<Contact> repeaters,
required Contact recipient,
}) {
if (senderPosition == null) {
return null;
}
final eligible = repeaters.where((contact) {
if (contact.publicKeyHex == recipient.publicKeyHex) {
return false;
}
if (!contact.isRecentlySeen) {
return false;
}
return contact.displayLocation != null;
}).toList();
if (eligible.isEmpty) {
return null;
}
eligible.sort((a, b) {
final locationA = a.displayLocation!;
final locationB = b.displayLocation!;
final distanceA = Geolocator.distanceBetween(
senderPosition.latitude,
senderPosition.longitude,
locationA.latitude,
locationA.longitude,
);
final distanceB = Geolocator.distanceBetween(
senderPosition.latitude,
senderPosition.longitude,
locationB.latitude,
locationB.longitude,
);
final distanceCompare = distanceA.compareTo(distanceB);
if (distanceCompare != 0) {
return distanceCompare;
}
final advertCompare = b.lastAdvert.compareTo(a.lastAdvert);
if (advertCompare != 0) {
return advertCompare;
}
final hopCompare = a.routeHopCount.compareTo(b.routeHopCount);
if (hopCompare != 0) {
return hopCompare;
}
final nameCompare = a.advName.compareTo(b.advName);
if (nameCompare != 0) {
return nameCompare;
}
return a.publicKeyHex.compareTo(b.publicKeyHex);
});
return eligible.first;
}
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:timezone/data/latest_all.dart' as tz;
import '../models/sar_marker.dart';
import '../l10n/app_localizations.dart';
@@ -13,14 +14,26 @@ class NotificationService {
final FlutterLocalNotificationsPlugin _notificationsPlugin =
FlutterLocalNotificationsPlugin();
static const String _prefMessagesEnabled = 'notifications_messages_enabled';
static const String _prefSarEnabled = 'notifications_sar_enabled';
static const String _prefUpdatesEnabled = 'notifications_updates_enabled';
static const String _prefMuteForeground = 'notifications_mute_foreground';
bool _isInitialized = false;
bool _permissionGranted = false;
bool _messageNotificationsEnabled = true;
bool _sarNotificationsEnabled = true;
bool _updateNotificationsEnabled = true;
bool _muteForegroundNotifications = true;
AppLifecycleState _lifecycleState = AppLifecycleState.resumed;
String? _launchPayload;
// Notification IDs
static const int _sarNotificationId = 1000;
static const int _messageNotificationId = 2000;
static const int _updateNotificationId = 3000;
static const int _batteryNotificationId = 4000;
static const int _discoveryNotificationId = 5000;
// Notification channels
static const String _urgentChannelId = 'sar_urgent';
@@ -38,12 +51,31 @@ class NotificationService {
static const String _updateChannelDescription =
'Notifications for available app updates';
static const String _batteryChannelId = 'battery_alerts';
static const String _batteryChannelName = 'Battery Alerts';
static const String _batteryChannelDescription =
'Notifications when a device or contact battery is low';
static const String _discoveryChannelId = 'discovery_alerts';
static const String _discoveryChannelName = 'Discovery Alerts';
static const String _discoveryChannelDescription =
'Notifications when new contacts are discovered';
bool get messageNotificationsEnabled => _messageNotificationsEnabled;
bool get sarNotificationsEnabled => _sarNotificationsEnabled;
bool get updateNotificationsEnabled => _updateNotificationsEnabled;
bool get muteForegroundNotifications => _muteForegroundNotifications;
bool get isAppInForeground => _lifecycleState == AppLifecycleState.resumed;
/// Initialize notification service
Future<void> initialize() async {
if (_isInitialized) return;
try {
debugPrint('📬 [NotificationService] Initializing...');
WidgetsBinding.instance.addObserver(_LifecycleObserver(this));
_lifecycleState =
WidgetsBinding.instance.lifecycleState ?? AppLifecycleState.resumed;
// Initialize timezone data
tz.initializeTimeZones();
@@ -72,8 +104,15 @@ class NotificationService {
onDidReceiveNotificationResponse: _onNotificationResponse,
);
final launchDetails = await _notificationsPlugin
.getNotificationAppLaunchDetails();
if (launchDetails?.didNotificationLaunchApp ?? false) {
_launchPayload = launchDetails?.notificationResponse?.payload;
}
// Request permissions
await _requestPermissions();
await _loadPreferences();
// Create notification channels (Android)
await _createNotificationChannels();
@@ -132,6 +171,46 @@ class NotificationService {
}
}
Future<void> _loadPreferences() async {
final prefs = await SharedPreferences.getInstance();
_messageNotificationsEnabled = prefs.getBool(_prefMessagesEnabled) ?? true;
_sarNotificationsEnabled = prefs.getBool(_prefSarEnabled) ?? true;
_updateNotificationsEnabled = prefs.getBool(_prefUpdatesEnabled) ?? true;
_muteForegroundNotifications = prefs.getBool(_prefMuteForeground) ?? true;
}
Future<void> setMessageNotificationsEnabled(bool value) async {
_messageNotificationsEnabled = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefMessagesEnabled, value);
}
Future<void> setSarNotificationsEnabled(bool value) async {
_sarNotificationsEnabled = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefSarEnabled, value);
}
Future<void> setUpdateNotificationsEnabled(bool value) async {
_updateNotificationsEnabled = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefUpdatesEnabled, value);
}
Future<void> setMuteForegroundNotifications(bool value) async {
_muteForegroundNotifications = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefMuteForeground, value);
}
void handleAppLifecycleStateChanged(AppLifecycleState state) {
_lifecycleState = state;
}
bool _shouldSuppressForegroundNotifications() {
return _muteForegroundNotifications && isAppInForeground;
}
/// Create notification channels for Android
Future<void> _createNotificationChannels() async {
try {
@@ -177,9 +256,31 @@ class NotificationService {
showBadge: true,
);
const batteryChannel = AndroidNotificationChannel(
_batteryChannelId,
_batteryChannelName,
description: _batteryChannelDescription,
importance: Importance.high,
playSound: true,
enableVibration: true,
showBadge: true,
);
const discoveryChannel = AndroidNotificationChannel(
_discoveryChannelId,
_discoveryChannelName,
description: _discoveryChannelDescription,
importance: Importance.high,
playSound: true,
enableVibration: true,
showBadge: true,
);
await androidPlugin.createNotificationChannel(urgentChannel);
await androidPlugin.createNotificationChannel(messagesChannel);
await androidPlugin.createNotificationChannel(updateChannel);
await androidPlugin.createNotificationChannel(batteryChannel);
await androidPlugin.createNotificationChannel(discoveryChannel);
debugPrint('✅ [NotificationService] Created notification channels');
} catch (e) {
debugPrint('⚠️ [NotificationService] Error creating channels: $e');
@@ -201,6 +302,12 @@ class NotificationService {
}
}
String? consumeLaunchPayload() {
final payload = _launchPayload;
_launchPayload = null;
return payload;
}
/// Show urgent notification for SAR marker
Future<void> showSarNotification({
required SarMarkerType type,
@@ -222,6 +329,16 @@ class NotificationService {
);
return;
}
if (!_sarNotificationsEnabled) {
debugPrint(' [NotificationService] SAR notifications disabled');
return;
}
if (_shouldSuppressForegroundNotifications()) {
debugPrint(
' [NotificationService] App in foreground, skipping SAR notification',
);
return;
}
try {
// Generate unique notification ID based on timestamp
@@ -394,6 +511,16 @@ class NotificationService {
);
return;
}
if (!_messageNotificationsEnabled) {
debugPrint(' [NotificationService] Message notifications disabled');
return;
}
if (_shouldSuppressForegroundNotifications()) {
debugPrint(
' [NotificationService] App in foreground, skipping message notification',
);
return;
}
try {
// Generate unique notification ID based on timestamp
@@ -402,10 +529,13 @@ class NotificationService {
(DateTime.now().millisecondsSinceEpoch % 1000);
// Build notification title and body
final resolvedChannelName = channelName?.trim().isNotEmpty == true
? channelName!.trim()
: (localizations?.publicChannel ?? 'Public');
final title = isChannelMessage
? (localizations != null
? '${localizations.channel}: ${channelName ?? "Public"}'
: 'Channel: ${channelName ?? "Public"}')
? '${localizations.channel}: $resolvedChannelName'
: 'Channel: $resolvedChannelName')
: (localizations != null
? '${localizations.newMessage} ${localizations.from} $senderName'
: 'New message from $senderName');
@@ -544,13 +674,23 @@ class NotificationService {
);
return;
}
if (!_updateNotificationsEnabled) {
debugPrint(' [NotificationService] Update notifications disabled');
return;
}
if (_shouldSuppressForegroundNotifications()) {
debugPrint(
' [NotificationService] App in foreground, skipping update notification',
);
return;
}
try {
// Build notification title and body
final title = localizations?.updateAvailable ?? 'App Update Available';
final body = localizations != null
? '${localizations.currentVersion}: $currentVersion\n'
'${localizations.latestVersion}: $latestVersion'
'${localizations.latestVersion}: $latestVersion'
: 'Current: $currentVersion\nLatest: $latestVersion';
// Android notification details
@@ -614,4 +754,169 @@ class NotificationService {
);
}
}
Future<bool> showLowBatteryNotification({
required String nodeId,
required String nodeName,
required double batteryPercent,
required bool isCurrentDevice,
}) async {
if (!_isInitialized) {
debugPrint(
'⚠️ [NotificationService] Not initialized, skipping notification',
);
return false;
}
if (!_permissionGranted) {
debugPrint(
'⚠️ [NotificationService] Permission not granted, skipping notification',
);
return false;
}
if (!_messageNotificationsEnabled) {
debugPrint(' [NotificationService] Message notifications disabled');
return false;
}
if (_shouldSuppressForegroundNotifications()) {
debugPrint(
' [NotificationService] App in foreground, skipping low battery notification',
);
return false;
}
final roundedPercent = batteryPercent.round().clamp(0, 100);
final title = isCurrentDevice
? 'Device battery low'
: 'Contact battery low';
final body = isCurrentDevice
? '$nodeName battery is at $roundedPercent%.'
: '$nodeName is at $roundedPercent% battery.';
final notificationId =
_batteryNotificationId + ((nodeId.hashCode & 0x7fffffff) % 1000);
try {
final androidDetails = AndroidNotificationDetails(
_batteryChannelId,
_batteryChannelName,
channelDescription: _batteryChannelDescription,
importance: Importance.high,
priority: Priority.high,
ticker: title,
playSound: true,
enableVibration: true,
showWhen: true,
when: DateTime.now().millisecondsSinceEpoch,
styleInformation: BigTextStyleInformation(
body,
contentTitle: title,
summaryText: '$roundedPercent%',
),
);
final darwinDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
sound: 'default',
threadIdentifier: 'battery_alerts',
subtitle: '$roundedPercent%',
);
final notificationDetails = NotificationDetails(
android: androidDetails,
iOS: darwinDetails,
);
await _notificationsPlugin.show(
id: notificationId,
title: title,
body: body,
notificationDetails: notificationDetails,
payload: 'battery:$nodeId',
);
debugPrint(
'✅ [NotificationService] Showed low battery notification for $nodeName ($roundedPercent%)',
);
return true;
} catch (e) {
debugPrint(
'❌ [NotificationService] Error showing low battery notification: $e',
);
return false;
}
}
Future<bool> showContactDiscoveredNotification({
required String contactKey,
String? contactName,
}) async {
if (!_isInitialized) return false;
if (!_permissionGranted) return false;
if (!_messageNotificationsEnabled) return false;
if (_shouldSuppressForegroundNotifications()) return false;
final shortKey = contactKey.length > 12
? contactKey.substring(0, 12).toUpperCase()
: contactKey.toUpperCase();
final title = 'New contact discovered';
final resolvedName = contactName?.trim();
final body = resolvedName != null && resolvedName.isNotEmpty
? '$resolvedName is available in Discovery.'
: 'New contact $shortKey is available in Discovery.';
final notificationId =
_discoveryNotificationId + ((contactKey.hashCode & 0x7fffffff) % 1000);
try {
final androidDetails = AndroidNotificationDetails(
_discoveryChannelId,
_discoveryChannelName,
channelDescription: _discoveryChannelDescription,
importance: Importance.high,
priority: Priority.high,
ticker: title,
playSound: true,
enableVibration: true,
showWhen: true,
when: DateTime.now().millisecondsSinceEpoch,
);
final darwinDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
sound: 'default',
threadIdentifier: 'discovery_alerts',
);
await _notificationsPlugin.show(
id: notificationId,
title: title,
body: body,
notificationDetails: NotificationDetails(
android: androidDetails,
iOS: darwinDetails,
),
payload: 'discovery:$contactKey',
);
return true;
} catch (e) {
debugPrint(
'❌ [NotificationService] Error showing discovery notification: $e',
);
return false;
}
}
}
class _LifecycleObserver with WidgetsBindingObserver {
_LifecycleObserver(this._service);
final NotificationService _service;
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
_service.handleAppLifecycleStateChanged(state);
}
}

View File

@@ -61,6 +61,9 @@ class PacketCaptureStorageService {
File? _file;
Future<File> _resolveFile() async {
if (kIsWeb) {
throw UnsupportedError('Packet capture file storage is not supported on web');
}
if (_file != null) return _file!;
final dir = await getApplicationSupportDirectory();
final file = File('${dir.path}/$_fileName');
@@ -73,6 +76,7 @@ class PacketCaptureStorageService {
Future<void> appendLogs(List<BlePacketLog> logs) async {
if (logs.isEmpty) return;
if (kIsWeb) return;
try {
final file = await _resolveFile();
final sink = file.openWrite(mode: FileMode.append);
@@ -98,6 +102,7 @@ class PacketCaptureStorageService {
}
Future<List<StoredPacketCapture>> loadRecent({int limit = 500}) async {
if (kIsWeb) return const [];
try {
final file = await _resolveFile();
if (!await file.exists()) return const [];
@@ -116,6 +121,7 @@ class PacketCaptureStorageService {
}
Future<int> count() async {
if (kIsWeb) return 0;
try {
final file = await _resolveFile();
if (!await file.exists()) return 0;
@@ -127,6 +133,7 @@ class PacketCaptureStorageService {
}
Future<void> clear() async {
if (kIsWeb) return;
try {
final file = await _resolveFile();
if (await file.exists()) {

View File

@@ -0,0 +1,282 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart';
import '../models/path_history.dart';
import '../models/path_selection.dart';
import '../utils/log_rx_route_decoder.dart';
class PathHistoryService {
static const String _storageKey = 'contact_path_history_v1';
static const int _maxDirectPaths = 20;
static const int _topRotationCount = 3;
final Map<String, ContactPathHistory> _cache = {};
bool _isLoaded = false;
Future<void> initialize() async {
if (_isLoaded) return;
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_storageKey);
if (raw == null || raw.isEmpty) {
_isLoaded = true;
return;
}
try {
final decoded = jsonDecode(raw);
if (decoded is Map<String, dynamic>) {
for (final entry in decoded.entries) {
final value = entry.value;
if (value is Map<String, dynamic>) {
_cache[entry.key] = ContactPathHistory.fromJson(entry.key, value);
}
}
}
} catch (error) {
debugPrint('⚠️ [PathHistoryService] Failed to load history: $error');
}
_isLoaded = true;
}
Future<void> recordLearnedPath(Contact contact) async {
await initialize();
if (!contact.routeHasPath || contact.routeHopCount <= 0) {
return;
}
final history = _historyFor(contact.publicKeyHex);
final signature = _signature(contact.routePathBytes);
final existing = _findDirectPath(history.directPaths, signature);
final updated = PathRecord(
pathBytes: contact.routePathBytes.toList(),
hopCount: contact.routeHopCount,
hashSize: contact.routeHashSize,
source: existing?.source ?? PathRecordSource.learned,
successCount: existing?.successCount ?? 0,
failureCount: existing?.failureCount ?? 0,
lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0,
lastUsedAt: DateTime.now(),
);
await _saveHistory(
contact.publicKeyHex,
history.copyWith(
directPaths: _upsertDirectPath(history.directPaths, updated),
),
);
}
Future<void> recordReceivedBytePath(
String contactPublicKeyHex,
List<int> pathBytes,
int hashSize,
) async {
await initialize();
if (pathBytes.isEmpty) {
return;
}
if (hashSize < 1 || hashSize > 3) {
return;
}
if (pathBytes.length % hashSize != 0) {
return;
}
final normalizedPathBytes = LogRxRouteDecoder.reverseHopBytes(
pathBytes,
hashSize: hashSize,
);
final history = _historyFor(contactPublicKeyHex);
final signature = normalizedPathBytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join();
final existing = _findDirectPath(history.directPaths, signature);
final updated = PathRecord(
pathBytes: normalizedPathBytes,
hopCount: normalizedPathBytes.length ~/ hashSize,
hashSize: hashSize,
source: PathRecordSource.observed,
successCount: existing?.successCount ?? 0,
failureCount: existing?.failureCount ?? 0,
lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0,
lastUsedAt: DateTime.now(),
);
await _saveHistory(
contactPublicKeyHex,
history.copyWith(
directPaths: _upsertDirectPath(history.directPaths, updated),
),
);
}
Future<PathSelection> getSelectionForContact(
Contact contact, {
required bool autoRouteRotationEnabled,
}) async {
await initialize();
await recordLearnedPath(contact);
if (contact.routeHasPath && contact.routeHopCount > 0) {
return PathSelection(
mode: PathSelectionMode.directCurrent,
pathBytes: Uint8List.fromList(contact.routePathBytes),
hopCount: contact.routeHopCount,
hashSize: contact.routeHashSize,
);
}
if (!autoRouteRotationEnabled) {
return PathSelection.flood();
}
final history = _historyFor(contact.publicKeyHex);
final ranked = List<PathRecord>.from(history.directPaths)
..sort(_comparePathRecords);
final topPaths = ranked.take(_topRotationCount).toList();
if (topPaths.isEmpty) {
final nextFloodHistory = history.copyWith(
rotationIndex: history.rotationIndex + 1,
);
await _saveHistory(contact.publicKeyHex, nextFloodHistory);
return PathSelection.flood();
}
final selections =
topPaths
.map(
(record) => PathSelection(
mode: PathSelectionMode.directHistorical,
pathBytes: Uint8List.fromList(record.pathBytes),
hopCount: record.hopCount,
hashSize: record.hashSize,
),
)
.toList()
..add(PathSelection.flood());
final index = history.rotationIndex % selections.length;
final updatedHistory = history.copyWith(
rotationIndex: history.rotationIndex + 1,
);
await _saveHistory(contact.publicKeyHex, updatedHistory);
return selections[index];
}
Future<void> recordPathResult(
String contactPublicKeyHex,
PathSelection selection, {
required bool success,
int? roundTripTimeMs,
}) async {
await initialize();
final history = _historyFor(contactPublicKeyHex);
if (selection.usesFlood) {
final current = history.floodStats;
await _saveHistory(
contactPublicKeyHex,
history.copyWith(
floodStats: current.copyWith(
successCount: current.successCount + (success ? 1 : 0),
failureCount: current.failureCount + (success ? 0 : 1),
lastRoundTripTimeMs: success
? (roundTripTimeMs ?? current.lastRoundTripTimeMs)
: current.lastRoundTripTimeMs,
lastUsedAt: DateTime.now(),
),
),
);
return;
}
final signature = _signature(selection.pathBytes);
final existing = _findDirectPath(history.directPaths, signature);
final updated = PathRecord(
pathBytes: selection.pathBytes.toList(),
hopCount: selection.hopCount,
hashSize: selection.hashSize,
source: existing?.source ?? PathRecordSource.learned,
successCount: (existing?.successCount ?? 0) + (success ? 1 : 0),
failureCount: (existing?.failureCount ?? 0) + (success ? 0 : 1),
lastRoundTripTimeMs: success
? (roundTripTimeMs ?? existing?.lastRoundTripTimeMs ?? 0)
: (existing?.lastRoundTripTimeMs ?? 0),
lastUsedAt: DateTime.now(),
);
await _saveHistory(
contactPublicKeyHex,
history.copyWith(
directPaths: _upsertDirectPath(history.directPaths, updated),
),
);
}
ContactPathHistory historyFor(String contactPublicKeyHex) {
return _cache[contactPublicKeyHex] ??
ContactPathHistory.empty(contactPublicKeyHex);
}
ContactPathHistory _historyFor(String contactPublicKeyHex) {
return _cache.putIfAbsent(
contactPublicKeyHex,
() => ContactPathHistory.empty(contactPublicKeyHex),
);
}
Future<void> _saveHistory(
String contactPublicKeyHex,
ContactPathHistory history,
) async {
_cache[contactPublicKeyHex] = history;
final prefs = await SharedPreferences.getInstance();
final payload = <String, dynamic>{};
for (final entry in _cache.entries) {
payload[entry.key] = entry.value.toJson();
}
await prefs.setString(_storageKey, jsonEncode(payload));
}
List<PathRecord> _upsertDirectPath(
List<PathRecord> existing,
PathRecord updatedRecord,
) {
final updated = List<PathRecord>.from(existing)
..removeWhere((record) => record.signature == updatedRecord.signature)
..insert(0, updatedRecord);
if (updated.length > _maxDirectPaths) {
return updated.take(_maxDirectPaths).toList();
}
return updated;
}
int _comparePathRecords(PathRecord a, PathRecord b) {
final successRateCompare = b.successRate.compareTo(a.successRate);
if (successRateCompare != 0) return successRateCompare;
final successCountCompare = b.successCount.compareTo(a.successCount);
if (successCountCompare != 0) return successCountCompare;
final aRtt = a.lastRoundTripTimeMs == 0 ? 1 << 30 : a.lastRoundTripTimeMs;
final bRtt = b.lastRoundTripTimeMs == 0 ? 1 << 30 : b.lastRoundTripTimeMs;
final rttCompare = aRtt.compareTo(bRtt);
if (rttCompare != 0) return rttCompare;
return b.lastUsedAt.compareTo(a.lastUsedAt);
}
PathRecord? _findDirectPath(List<PathRecord> records, String signature) {
for (final record in records) {
if (record.signature == signature) {
return record;
}
}
return null;
}
String _signature(Uint8List bytes) =>
bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
}

View File

@@ -3,18 +3,22 @@ import 'package:codec2_flutter/codec2_flutter.dart';
import 'package:flutter/foundation.dart';
import '../utils/voice_message_parser.dart';
export 'package:codec2_flutter/codec2_flutter.dart' show Codec2Mode;
/// Maps [VoicePacketMode] to the [Codec2Mode] enum from the FFI plugin.
Codec2Mode codec2ModeFor(VoicePacketMode pktMode) {
switch (pktMode) {
case VoicePacketMode.mode3200: return Codec2Mode.mode3200;
case VoicePacketMode.mode1600: return Codec2Mode.mode1600;
case VoicePacketMode.mode1400: return Codec2Mode.mode1400;
case VoicePacketMode.mode700c: return Codec2Mode.mode700c;
case VoicePacketMode.mode1200: return Codec2Mode.mode1200;
case VoicePacketMode.mode1300: return Codec2Mode.mode1300;
case VoicePacketMode.mode2400: return Codec2Mode.mode2400;
case VoicePacketMode.mode3200:
return Codec2Mode.mode3200;
case VoicePacketMode.mode1600:
return Codec2Mode.mode1600;
case VoicePacketMode.mode1400:
return Codec2Mode.mode1400;
case VoicePacketMode.mode700c:
return Codec2Mode.mode700c;
case VoicePacketMode.mode1200:
return Codec2Mode.mode1200;
case VoicePacketMode.mode1300:
return Codec2Mode.mode1300;
case VoicePacketMode.mode2400:
return Codec2Mode.mode2400;
}
}
@@ -39,14 +43,11 @@ class VoiceCodecService {
}
}
/// Encode [pcm] (Int16 samples, 8000 Hz mono) with [mode].
/// Returns the raw Codec2-encoded bytes.
Future<Uint8List> encode(Int16List pcm, VoicePacketMode mode) {
_ensureCodec2Supported();
return Codec2.encodeInIsolate(pcm, codec2ModeFor(mode));
}
/// Decode [codec2Bytes] back to Int16 PCM (8000 Hz mono) with [mode].
Future<Int16List> decode(Uint8List codec2Bytes, VoicePacketMode mode) {
_ensureCodec2Supported();
return Codec2.decodeInIsolate(codec2Bytes, codec2ModeFor(mode));
@@ -59,24 +60,14 @@ class VoiceCodecService {
VoicePacketMode mode,
) async {
_ensureCodec2Supported();
final c2Mode = codec2ModeFor(mode);
final c2 = Codec2.create(c2Mode);
final spf = c2.samplesPerFrame;
c2.destroy();
// Estimate total samples (use actual data or silence per missing packet)
final all = <Int16List>[];
for (final pkt in packets) {
if (pkt == null || pkt.codec2Data.isEmpty) {
// Silence for missing packet — duration approximated by mode
final silenceSamples = (codec2ModeFor(mode).framesPerSecond) * spf;
all.add(Int16List(silenceSamples));
all.add(Int16List(mode.samplesPerPacket));
} else {
final decoded = await Codec2.decodeInIsolate(pkt.codec2Data, c2Mode);
all.add(decoded);
all.add(await decode(pkt.codec2Data, mode));
}
}
final total = all.fold<int>(0, (sum, l) => sum + l.length);
final result = Int16List(total);
var offset = 0;

View File

@@ -1,7 +1,6 @@
import 'dart:typed_data';
import '../utils/voice_message_parser.dart';
/// Web/unsupported platform stub — Codec2 FFI is not available.
enum Codec2Mode {
mode3200(0),
mode2400(1),
@@ -19,13 +18,20 @@ enum Codec2Mode {
int get bytesPerSecond {
switch (this) {
case mode3200: return 400;
case mode700c: return 100;
case mode1200: return 150;
case mode1300: return 175;
case mode1400: return 175;
case mode1600: return 200;
case mode2400: return 300;
case mode3200:
return 400;
case mode700c:
return 100;
case mode1200:
return 150;
case mode1300:
return 175;
case mode1400:
return 175;
case mode1600:
return 200;
case mode2400:
return 300;
}
}
@@ -40,13 +46,20 @@ enum Codec2Mode {
Codec2Mode codec2ModeFor(VoicePacketMode pktMode) {
switch (pktMode) {
case VoicePacketMode.mode3200: return Codec2Mode.mode3200;
case VoicePacketMode.mode1600: return Codec2Mode.mode1600;
case VoicePacketMode.mode1400: return Codec2Mode.mode1400;
case VoicePacketMode.mode700c: return Codec2Mode.mode700c;
case VoicePacketMode.mode1200: return Codec2Mode.mode1200;
case VoicePacketMode.mode1300: return Codec2Mode.mode1300;
case VoicePacketMode.mode2400: return Codec2Mode.mode2400;
case VoicePacketMode.mode3200:
return Codec2Mode.mode3200;
case VoicePacketMode.mode1600:
return Codec2Mode.mode1600;
case VoicePacketMode.mode1400:
return Codec2Mode.mode1400;
case VoicePacketMode.mode700c:
return Codec2Mode.mode700c;
case VoicePacketMode.mode1200:
return Codec2Mode.mode1200;
case VoicePacketMode.mode1300:
return Codec2Mode.mode1300;
case VoicePacketMode.mode2400:
return Codec2Mode.mode2400;
}
}
@@ -66,6 +79,5 @@ class VoiceCodecService {
Future<Int16List> decodePackets(
List<VoicePacket?> packets,
VoicePacketMode mode,
) =>
Future.error(UnsupportedError('Voice not supported on web'));
) => Future.error(UnsupportedError('Voice not supported on web'));
}

View File

@@ -5,7 +5,7 @@ import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
/// Plays decoded 8000 Hz / 16-bit mono PCM samples by writing a WAV file
/// Plays decoded mono PCM samples by writing a WAV file
/// to the system temp directory and using [AudioPlayer].
class VoicePlayerService {
final AudioPlayer _player = AudioPlayer();
@@ -49,16 +49,17 @@ class VoicePlayerService {
});
}
/// Play [pcmSamples] (Int16, 8000 Hz, mono).
Future<void> play(Int16List pcmSamples) async {
Future<void> play(Int16List pcmSamples, {required int sampleRateHz}) async {
debugPrint('🔊 [VoicePlayer] play() called, ${pcmSamples.length} samples');
if (_isPlaying) await stop();
_position = Duration.zero;
_duration = Duration(milliseconds: (pcmSamples.length * 1000) ~/ 8000);
_duration = Duration(
milliseconds: (pcmSamples.length * 1000) ~/ sampleRateHz,
);
_playbackStartedAt = DateTime.now();
_events.add(null);
final wavBytes = _buildWav(pcmSamples, sampleRate: 8000);
final wavBytes = _buildWav(pcmSamples, sampleRate: sampleRateHz);
final tmpDir = await getTemporaryDirectory();
final file = File('${tmpDir.path}/vc_voice.wav');
await file.writeAsBytes(wavBytes);

View File

@@ -4,7 +4,7 @@ import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:record/record.dart';
/// Captures raw PCM audio at 8000 Hz, 16-bit mono.
/// Captures raw PCM audio at a codec-selected sample rate, 16-bit mono.
///
/// [startCapture] returns a [Stream<Int16List>] that emits chunks of PCM
/// samples every [chunkDuration]. Call [stopCapture] to end recording.
@@ -27,12 +27,16 @@ class VoiceRecorderService {
/// [enableBandPassFilter] applies voice-tuned band-pass filtering when true.
/// [enableCompressor] normalizes speech dynamics before encoding.
/// [enableLimiter] protects against clipping peaks before encoding.
/// The returned stream emits [Int16List] chunks that are ready for Codec2 encoding.
/// The returned stream emits [Int16List] chunks that are ready for voice encoding.
Stream<Int16List> startCapture({
Duration chunkDuration = const Duration(seconds: 1),
int sampleRateHz = 8000,
bool enableBandPassFilter = true,
bool enableCompressor = true,
bool enableLimiter = true,
bool enableAutoGain = false,
bool enableEchoCancellation = false,
bool enableNoiseSuppression = false,
}) {
if (_isRecording) {
throw StateError('VoiceRecorderService: already recording');
@@ -43,39 +47,59 @@ class VoiceRecorderService {
_startRecording(
chunkDuration,
sampleRateHz: sampleRateHz,
enableBandPassFilter: enableBandPassFilter,
enableCompressor: enableCompressor,
enableLimiter: enableLimiter,
enableAutoGain: enableAutoGain,
enableEchoCancellation: enableEchoCancellation,
enableNoiseSuppression: enableNoiseSuppression,
);
return _controller!.stream;
}
Future<void> _startRecording(
Duration chunkDuration, {
required int sampleRateHz,
required bool enableBandPassFilter,
required bool enableCompressor,
required bool enableLimiter,
required bool enableAutoGain,
required bool enableEchoCancellation,
required bool enableNoiseSuppression,
}) async {
final config = const RecordConfig(
final useBandPassFilter = enableBandPassFilter;
final useCompressor = enableCompressor;
final useLimiter = enableLimiter;
final config = RecordConfig(
encoder: AudioEncoder.pcm16bits,
sampleRate: 8000,
sampleRate: sampleRateHz,
numChannels: 1,
bitRate: 128000, // ignored for PCM, but required by API
autoGain: enableAutoGain,
echoCancel: enableEchoCancellation,
noiseSuppress: enableNoiseSuppression,
);
try {
final stream = await _recorder.startStream(config);
final voiceFilter = _VoiceBandPassFilter(
sampleRate: 8000,
sampleRate: sampleRateHz,
lowCutHz: 250.0,
highCutHz: 3400.0,
);
final dynamics = _VoiceDynamicsProcessor(
sampleRate: 8000,
enableCompressor: enableCompressor,
enableLimiter: enableLimiter,
sampleRate: sampleRateHz,
thresholdDb: -18.0,
ratio: 2.5,
attackMs: 8.0,
releaseMs: 120.0,
makeupGainDb: 4.0,
enableCompressor: useCompressor,
enableLimiter: useLimiter,
);
final chunkBytes = 8000 * 2 * chunkDuration.inMilliseconds ~/ 1000;
final chunkBytes =
sampleRateHz * 2 * chunkDuration.inMilliseconds ~/ 1000;
final buffer = <int>[];
_sub = stream.listen(
@@ -85,9 +109,7 @@ class VoiceRecorderService {
final chunk = buffer.sublist(0, chunkBytes);
buffer.removeRange(0, chunkBytes);
final pcm = _bytesToInt16(Uint8List.fromList(chunk));
final filtered = enableBandPassFilter
? voiceFilter.process(pcm)
: pcm;
final filtered = useBandPassFilter ? voiceFilter.process(pcm) : pcm;
_controller?.add(dynamics.process(filtered));
}
},
@@ -95,9 +117,7 @@ class VoiceRecorderService {
if (buffer.isNotEmpty) {
final padded = _padToEven(buffer);
final pcm = _bytesToInt16(Uint8List.fromList(padded));
final filtered = enableBandPassFilter
? voiceFilter.process(pcm)
: pcm;
final filtered = useBandPassFilter ? voiceFilter.process(pcm) : pcm;
_controller?.add(dynamics.process(filtered));
}
_controller?.close();
@@ -163,17 +183,22 @@ class _VoiceDynamicsProcessor {
_VoiceDynamicsProcessor({
required int sampleRate,
required double thresholdDb,
required double ratio,
required double attackMs,
required double releaseMs,
required double makeupGainDb,
required bool enableCompressor,
required bool enableLimiter,
}) : _enableCompressor = enableCompressor,
_enableLimiter = enableLimiter,
_compressor = _SimpleCompressor(
sampleRate: sampleRate.toDouble(),
thresholdDb: -18.0,
ratio: 2.5,
attackMs: 8.0,
releaseMs: 120.0,
makeupGainDb: 4.0,
thresholdDb: thresholdDb,
ratio: ratio,
attackMs: attackMs,
releaseMs: releaseMs,
makeupGainDb: makeupGainDb,
),
_limiter = _PeakLimiter(ceilingDb: -1.0);

View File

@@ -1,4 +1,6 @@
class AvatarLabelHelper {
static final RegExp _alnumChunks = RegExp(r'[A-Za-z0-9]+');
static String buildLabel(String name) {
final trimmed = name.trim();
if (trimmed.isEmpty) return '?';
@@ -11,18 +13,23 @@ class AvatarLabelHelper {
return '#${_take(hashBody, 2)}'.toUpperCase();
}
final parts = trimmed
.split(RegExp(r'[\s_-]+'))
final parts = _alnumChunks
.allMatches(trimmed)
.map((match) => match.group(0)!)
.where((part) => part.isNotEmpty)
.toList();
if (parts.isEmpty) {
return '?';
}
if (parts.length >= 2) {
final first = _take(parts[0], 1);
final second = _take(parts[1], 1);
return '$first$second'.toUpperCase();
}
return _take(trimmed, 2).toUpperCase();
return _take(parts.first, 2).toUpperCase();
}
static String _take(String value, int count) {

View File

@@ -0,0 +1,174 @@
import '../models/contact.dart';
class InferredContactGroup {
final String key;
final String label;
final List<Contact> contacts;
final List<String> matchPrefixes;
const InferredContactGroup({
required this.key,
required this.label,
required this.contacts,
List<String>? matchPrefixes,
}) : matchPrefixes = matchPrefixes ?? const <String>[];
DateTime get latestSeen => contacts.first.lastSeenTime;
}
class ContactGrouping {
static final RegExp _prefixedNamePattern = RegExp(
r'^([A-Za-z0-9]{2,})([-_/:])',
);
static String? inferredGroupLabelForContact(Contact contact) {
return _extractPrefix(contact.displayName)?.label;
}
static bool contactMatchesInferredGroupLabel(Contact contact, String query) {
final normalizedQuery = query.trim().toLowerCase();
if (normalizedQuery.isEmpty) {
return false;
}
final groupLabel = inferredGroupLabelForContact(contact)?.toLowerCase();
return groupLabel?.contains(normalizedQuery) ?? false;
}
static String? sharedInferredGroupLabel(List<Contact> contacts) {
String? sharedLabel;
for (final contact in contacts) {
final label = inferredGroupLabelForContact(contact);
if (label == null) {
return null;
}
if (sharedLabel == null) {
sharedLabel = label;
continue;
}
if (sharedLabel != label) {
return null;
}
}
return sharedLabel;
}
static String? sharedParentGroupLabel(List<String> labels) {
if (labels.isEmpty) {
return null;
}
var commonPrefix = labels.first;
for (final label in labels.skip(1)) {
final maxLength = commonPrefix.length < label.length
? commonPrefix.length
: label.length;
var matchLength = 0;
while (matchLength < maxLength &&
commonPrefix.codeUnitAt(matchLength) ==
label.codeUnitAt(matchLength)) {
matchLength++;
}
commonPrefix = commonPrefix.substring(0, matchLength);
if (commonPrefix.isEmpty) {
return null;
}
}
final separatorIndex = commonPrefix.lastIndexOf(RegExp(r'[-_/:]'));
if (separatorIndex < 1) {
return null;
}
final parentLabel = commonPrefix.substring(0, separatorIndex + 1);
return parentLabel.length >= 3 ? parentLabel : null;
}
static List<Contact> sortByLastSeen(List<Contact> contacts) {
return List<Contact>.from(contacts)
..sort((a, b) => b.lastSeenTime.compareTo(a.lastSeenTime));
}
static List<InferredContactGroup> inferGroups(
List<Contact> contacts, {
int minGroupSize = 4,
int? maxNamedGroups,
String? overflowGroupLabel,
}) {
final sortedContacts = sortByLastSeen(contacts);
final groupedContacts = <String, List<Contact>>{};
final groupLabels = <String, String>{};
for (final contact in sortedContacts) {
final prefix = _extractPrefix(contact.displayName);
if (prefix == null) continue;
groupedContacts.putIfAbsent(prefix.key, () => <Contact>[]).add(contact);
groupLabels.putIfAbsent(prefix.key, () => prefix.label);
}
final rankedGroups = <InferredContactGroup>[];
for (final entry in groupedContacts.entries) {
if (entry.value.length < minGroupSize) continue;
rankedGroups.add(
InferredContactGroup(
key: entry.key,
label: groupLabels[entry.key] ?? entry.key,
contacts: entry.value,
matchPrefixes: [groupLabels[entry.key] ?? entry.key],
),
);
}
rankedGroups.sort((a, b) => b.latestSeen.compareTo(a.latestSeen));
if (maxNamedGroups != null &&
overflowGroupLabel != null &&
rankedGroups.length > maxNamedGroups) {
final retainedGroups = rankedGroups.take(maxNamedGroups).toList();
final overflowGroups = rankedGroups.skip(maxNamedGroups).toList();
final parentLabel = sharedParentGroupLabel([
for (final group in overflowGroups) ...group.matchPrefixes,
]);
if (parentLabel != null) {
final overflowContacts = overflowGroups
.expand((group) => group.contacts)
.toList();
return [
...retainedGroups,
InferredContactGroup(
key: parentLabel,
label: parentLabel,
contacts: sortByLastSeen(overflowContacts),
matchPrefixes: [parentLabel],
),
];
}
return rankedGroups;
}
return rankedGroups;
}
static _GroupPrefix? _extractPrefix(String name) {
final trimmed = name.trim();
if (trimmed.isEmpty) return null;
final match = _prefixedNamePattern.firstMatch(trimmed);
if (match == null) return null;
final rawPrefix = match.group(1);
final separator = match.group(2);
if (rawPrefix == null || separator == null) return null;
return _GroupPrefix(
key: rawPrefix.toUpperCase(),
label: '$rawPrefix$separator',
);
}
}
class _GroupPrefix {
final String key;
final String label;
const _GroupPrefix({required this.key, required this.label});
}

View File

@@ -4,38 +4,55 @@ import '../models/contact.dart';
class DecodedLogRxRoute {
final int payloadType;
final List<int> pathHashes;
final int pathDescriptor;
final List<int> pathBytes;
final int hashSize;
const DecodedLogRxRoute({
required this.payloadType,
required this.pathHashes,
required this.pathDescriptor,
required this.pathBytes,
required this.hashSize,
});
int? get originalSenderHash => pathHashes.isEmpty ? null : pathHashes.first;
List<String> get hopHashes =>
LogRxRouteDecoder.splitHopHashes(pathBytes, hashSize: hashSize);
int get hopCount => hopHashes.length;
String? get originalSenderHashHex =>
hopHashes.isEmpty ? null : hopHashes.first;
}
class ResolvedNodeHash {
final int hash;
final String hashHex;
final String label;
final bool isOwnNode;
final bool isUniqueMatch;
final int matchCount;
final double? latitude;
final double? longitude;
const ResolvedNodeHash({
required this.hash,
required this.hashHex,
required this.label,
required this.isOwnNode,
required this.isUniqueMatch,
required this.matchCount,
this.latitude,
this.longitude,
});
String get hexLabel => '0x${hash.toRadixString(16).padLeft(2, '0')}';
String get hexLabel => '0x${hashHex.toUpperCase()}';
}
class LogRxRouteDecoder {
const LogRxRouteDecoder._();
static DecodedLogRxRoute? decode(Uint8List rawData) {
static DecodedLogRxRoute? decode(
Uint8List rawData, {
int? preferredHashSize,
}) {
if (rawData.length < 5 || rawData[0] != 0x88) return null;
final rawPacketData = rawData.sublist(3);
@@ -52,44 +69,147 @@ class LogRxRouteDecoder {
}
if (rawPacketData.length <= index) return null;
final pathLen = rawPacketData[index++];
if (rawPacketData.length < index + pathLen) return null;
final pathDescriptor = rawPacketData[index++];
final pathMode = (pathDescriptor & 0xFF) >> 6;
final pathByteLen = pathMode == 0
? pathDescriptor
: descriptorByteLength(pathDescriptor);
if (pathByteLen == null || rawPacketData.length < index + pathByteLen) {
return null;
}
final pathBytes = rawPacketData.sublist(index, index + pathByteLen);
final hashSize = pathMode == 0
? inferHashSize(pathBytes, preferredHashSize: preferredHashSize)
: (descriptorHashSize(pathDescriptor) ??
inferHashSize(pathBytes, preferredHashSize: preferredHashSize));
return DecodedLogRxRoute(
payloadType: payloadType,
pathHashes: rawPacketData.sublist(index, index + pathLen),
pathDescriptor: pathDescriptor,
pathBytes: pathBytes,
hashSize: hashSize,
);
}
static int? descriptorHashSize(int pathDescriptor) {
final normalized = pathDescriptor & 0xFF;
final mode = normalized >> 6;
if (mode == 3) return null;
return mode + 1;
}
static int? descriptorHopCount(int pathDescriptor) {
final hashSize = descriptorHashSize(pathDescriptor);
if (hashSize == null) return null;
return (pathDescriptor & 0xFF) & 0x3F;
}
static int? descriptorByteLength(int pathDescriptor) {
final hashSize = descriptorHashSize(pathDescriptor);
final hopCount = descriptorHopCount(pathDescriptor);
if (hashSize == null || hopCount == null) return null;
final byteLen = hopCount * hashSize;
return byteLen <= 64 ? byteLen : null;
}
static int inferHashSize(List<int> pathBytes, {int? preferredHashSize}) {
if (pathBytes.isEmpty) return 1;
final normalizedPreferred =
preferredHashSize != null &&
preferredHashSize >= 1 &&
preferredHashSize <= 3
? preferredHashSize
: null;
if (normalizedPreferred != null &&
pathBytes.length % normalizedPreferred == 0) {
return normalizedPreferred;
}
for (final candidate in const [3, 2, 1]) {
if (pathBytes.length % candidate == 0) {
return candidate;
}
}
return 1;
}
static List<String> splitHopHashes(
List<int> pathBytes, {
required int hashSize,
}) {
if (pathBytes.isEmpty) return const [];
if (hashSize < 1 || hashSize > 3 || pathBytes.length % hashSize != 0) {
return pathBytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.toList();
}
final hops = <String>[];
for (var index = 0; index < pathBytes.length; index += hashSize) {
hops.add(
pathBytes
.sublist(index, index + hashSize)
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join(),
);
}
return hops;
}
static List<int> reverseHopBytes(
List<int> pathBytes, {
required int hashSize,
}) {
if (pathBytes.isEmpty) return const [];
if (hashSize < 1 || hashSize > 3 || pathBytes.length % hashSize != 0) {
return List<int>.from(pathBytes.reversed);
}
final reversed = <int>[];
for (
var index = pathBytes.length - hashSize;
index >= 0;
index -= hashSize
) {
reversed.addAll(pathBytes.sublist(index, index + hashSize));
}
return reversed;
}
static ResolvedNodeHash resolveHash(
int hash, {
String hashHex, {
required Iterable<Contact> contacts,
Uint8List? ownPublicKey,
String? ownName,
double? ownLatitude,
double? ownLongitude,
}) {
final ownHash = ownPublicKey != null && ownPublicKey.isNotEmpty
? ownPublicKey.first
: null;
if (ownHash == hash) {
final normalizedHashHex = hashHex.toLowerCase();
final ownKeyHex = _bytesToHex(ownPublicKey);
if (ownKeyHex != null && ownKeyHex.startsWith(normalizedHashHex)) {
final ownLabel = (ownName != null && ownName.trim().isNotEmpty)
? '$ownName (you)'
: 'You';
return ResolvedNodeHash(
hash: hash,
hashHex: normalizedHashHex,
label: ownLabel,
isOwnNode: true,
isUniqueMatch: true,
matchCount: 1,
latitude: ownLatitude,
longitude: ownLongitude,
);
}
final matches = contacts.where((contact) {
return contact.publicKey.isNotEmpty && contact.publicKey.first == hash;
return contact.publicKeyHex.toLowerCase().startsWith(normalizedHashHex);
}).toList();
if (matches.isEmpty) {
return ResolvedNodeHash(
hash: hash,
hashHex: normalizedHashHex,
label: 'Unknown',
isOwnNode: false,
isUniqueMatch: false,
@@ -98,12 +218,15 @@ class LogRxRouteDecoder {
}
if (matches.length == 1) {
final location = matches.first.displayLocation;
return ResolvedNodeHash(
hash: hash,
hashHex: normalizedHashHex,
label: matches.first.displayName,
isOwnNode: false,
isUniqueMatch: true,
matchCount: 1,
latitude: location?.latitude,
longitude: location?.longitude,
);
}
@@ -119,11 +242,19 @@ class LogRxRouteDecoder {
? '$candidateNames +$extraCount'
: candidateNames;
return ResolvedNodeHash(
hash: hash,
hashHex: normalizedHashHex,
label: label,
isOwnNode: false,
isUniqueMatch: false,
matchCount: matches.length,
);
}
static String? _bytesToHex(Uint8List? bytes) {
if (bytes == null || bytes.isEmpty) return null;
return bytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join()
.toLowerCase();
}
}

View File

@@ -1,17 +1,24 @@
import 'package:flutter/widgets.dart';
import 'package:provider/provider.dart';
import '../models/message.dart';
import '../l10n/app_localizations.dart';
import '../providers/messages_provider.dart';
/// Extension for Message to provide localized delivery status
extension MessageLocalization on Message {
/// Get localized delivery status text
String getLocalizedDeliveryStatus(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final messagesProvider = context.read<MessagesProvider>();
final routeMetadata = messagesProvider.getMessageRouteMetadata(id);
// For channel messages, show echo count instead of delivery status
if (isChannelMessage && deliveryStatus == MessageDeliveryStatus.sent) {
final latestMeta = _formatEchoMeta(context);
if (echoCount == 0) {
if (messagesProvider.hasChannelSendWarning(id)) {
return 'Broadcast may have failed';
}
return l10n.broadcast; // "Broadcast (no echoes yet)"
} else if (echoCount == 1) {
return latestMeta == null ? '1 node' : '1 node • $latestMeta';
@@ -26,29 +33,51 @@ extension MessageLocalization on Message {
case MessageDeliveryStatus.sending:
if (isContactMessage) {
if (retryAttempt > 0) {
return '${l10n.pending}${l10n.retryAttempt} $retryAttempt/3';
final routeSuffix = routeMetadata != null
? '${routeMetadata.modeLabel}'
: '';
return '${l10n.pending}${l10n.retryAttempt} $retryAttempt/4$routeSuffix';
}
return l10n.pending;
return routeMetadata == null
? l10n.pending
: '${l10n.pending}${routeMetadata.modeLabel}';
}
return l10n.sending;
case MessageDeliveryStatus.sent:
return l10n.sent;
return routeMetadata == null
? l10n.sent
: '${l10n.sent}${routeMetadata.modeLabel}';
case MessageDeliveryStatus.delivered:
if (retryAttempt > 0 && roundTripTimeMs != null) {
return '${l10n.deliveredWithTime(roundTripTimeMs!)}${l10n.retryAttempt} $retryAttempt/3';
final routeSuffix = routeMetadata != null
? '${routeMetadata.modeLabel}'
: '';
return '${l10n.deliveredWithTime(roundTripTimeMs!)}${l10n.retryAttempt} $retryAttempt/4$routeSuffix';
}
if (retryAttempt > 0) {
return '${l10n.delivered}${l10n.retryAttempt} $retryAttempt/3';
final routeSuffix = routeMetadata != null
? '${routeMetadata.modeLabel}'
: '';
return '${l10n.delivered}${l10n.retryAttempt} $retryAttempt/4$routeSuffix';
}
if (roundTripTimeMs != null) {
return l10n.deliveredWithTime(roundTripTimeMs!);
return routeMetadata == null
? l10n.deliveredWithTime(roundTripTimeMs!)
: '${l10n.deliveredWithTime(roundTripTimeMs!)}${routeMetadata.modeLabel}';
}
return l10n.delivered;
return routeMetadata == null
? l10n.delivered
: '${l10n.delivered}${routeMetadata.modeLabel}';
case MessageDeliveryStatus.failed:
if (retryAttempt > 0) {
return '${l10n.failed}${l10n.retryAttempt} $retryAttempt/3';
final routeSuffix = routeMetadata != null
? '${routeMetadata.modeLabel}'
: '';
return '${l10n.failed}${l10n.retryAttempt} $retryAttempt/4$routeSuffix';
}
return l10n.failed;
return routeMetadata == null
? l10n.failed
: '${l10n.failed}${routeMetadata.modeLabel}';
case MessageDeliveryStatus.received:
return '';
}

View File

@@ -0,0 +1,239 @@
import 'package:latlong2/latlong.dart';
import '../services/mesh_map_nodes_service.dart';
class ResolvedTraceNode {
final List<MeshMapNode> candidates;
final int matchCount;
final bool usedOnlineFallback;
final int selectedIndex;
const ResolvedTraceNode({
required this.candidates,
required this.matchCount,
required this.usedOnlineFallback,
this.selectedIndex = 0,
});
MeshMapNode? get node =>
candidates.isEmpty ? null : candidates[selectedIndex];
bool get hasMatch => node != null;
bool get isAmbiguous => matchCount > 1;
bool get canCycle => candidates.length > 1;
String? get matchSummary {
if (matchCount <= 1) return null;
final source = usedOnlineFallback ? 'online' : 'local';
return '$matchCount $source matches';
}
String? get cycleSummary =>
canCycle ? 'tap to cycle ${selectedIndex + 1}/$matchCount' : null;
ResolvedTraceNode cycle() {
if (!canCycle) return this;
return ResolvedTraceNode(
candidates: candidates,
matchCount: matchCount,
usedOnlineFallback: usedOnlineFallback,
selectedIndex: (selectedIndex + 1) % candidates.length,
);
}
}
class TraceNodeResolver {
static const Distance _distance = Distance();
const TraceNodeResolver._();
static ResolvedTraceNode resolveBest({
required List<MeshMapNode> nodes,
required Set<String> localPublicKeys,
required String? prefixHex,
LatLng? referenceA,
LatLng? referenceB,
String? preferredPrefix,
}) {
if (prefixHex == null || prefixHex.isEmpty) {
return const ResolvedTraceNode(
candidates: <MeshMapNode>[],
matchCount: 0,
usedOnlineFallback: false,
);
}
final allMatches = nodes
.where((n) => n.publicKey.startsWith(prefixHex))
.toList();
if (allMatches.isEmpty) {
return const ResolvedTraceNode(
candidates: <MeshMapNode>[],
matchCount: 0,
usedOnlineFallback: false,
);
}
final localMatches = allMatches
.where((node) => localPublicKeys.contains(node.publicKey))
.toList();
var pool = localMatches.isNotEmpty ? localMatches : allMatches;
final usedOnlineFallback = localMatches.isEmpty;
if (preferredPrefix != null && preferredPrefix.isNotEmpty) {
final preferredMatches = pool
.where((node) => node.publicKey.startsWith(preferredPrefix))
.toList();
if (preferredMatches.isNotEmpty) {
pool = preferredMatches;
}
}
pool.sort((a, b) {
final distanceCompare =
_scoreNode(
a,
referenceA: referenceA,
referenceB: referenceB,
).compareTo(
_scoreNode(b, referenceA: referenceA, referenceB: referenceB),
);
if (distanceCompare != 0) return distanceCompare;
return b.updatedAtMs.compareTo(a.updatedAtMs);
});
return ResolvedTraceNode(
candidates: List<MeshMapNode>.unmodifiable(pool),
matchCount: pool.length,
usedOnlineFallback: usedOnlineFallback,
);
}
static List<ResolvedTraceNode> alignPathSelections({
required List<ResolvedTraceNode> nodes,
MeshMapNode? startNode,
MeshMapNode? endNode,
}) {
if (nodes.isEmpty || nodes.any((node) => node.candidates.isEmpty)) {
return nodes;
}
final candidateCosts = List.generate(
nodes.length,
(_) => <double>[],
growable: false,
);
final previousChoice = List.generate(
nodes.length,
(_) => <int>[],
growable: false,
);
for (var i = 0; i < nodes.length; i++) {
final currentCandidates = nodes[i].candidates;
candidateCosts[i] = List<double>.filled(
currentCandidates.length,
double.infinity,
);
previousChoice[i] = List<int>.filled(currentCandidates.length, -1);
for (var j = 0; j < currentCandidates.length; j++) {
final current = currentCandidates[j];
if (i == 0) {
candidateCosts[i][j] = startNode == null
? 0
: _distanceBetweenNodes(startNode, current);
continue;
}
final previousCandidates = nodes[i - 1].candidates;
for (var k = 0; k < previousCandidates.length; k++) {
final candidateCost =
candidateCosts[i - 1][k] +
_distanceBetweenNodes(previousCandidates[k], current);
if (candidateCost < candidateCosts[i][j]) {
candidateCosts[i][j] = candidateCost;
previousChoice[i][j] = k;
}
}
}
}
var bestLastIndex = 0;
var bestLastCost = double.infinity;
final lastCandidates = nodes.last.candidates;
for (var i = 0; i < lastCandidates.length; i++) {
final endCost = endNode == null
? 0
: _distanceBetweenNodes(lastCandidates[i], endNode);
final totalCost = candidateCosts.last[i] + endCost;
if (totalCost < bestLastCost) {
bestLastCost = totalCost;
bestLastIndex = i;
}
}
final selectedIndices = List<int>.filled(nodes.length, 0);
selectedIndices[nodes.length - 1] = bestLastIndex;
for (var i = nodes.length - 1; i > 0; i--) {
selectedIndices[i - 1] = previousChoice[i][selectedIndices[i]];
}
return List<ResolvedTraceNode>.generate(nodes.length, (index) {
final resolved = nodes[index];
return ResolvedTraceNode(
candidates: resolved.candidates,
matchCount: resolved.matchCount,
usedOnlineFallback: resolved.usedOnlineFallback,
selectedIndex: selectedIndices[index],
);
}, growable: false);
}
static double _scoreNode(
MeshMapNode node, {
LatLng? referenceA,
LatLng? referenceB,
}) {
final point = LatLng(node.latitude, node.longitude);
if (referenceA != null && referenceB != null) {
return _distanceToSegmentMeters(point, referenceA, referenceB);
}
if (referenceA != null) {
return _distance.as(LengthUnit.Meter, point, referenceA);
}
if (referenceB != null) {
return _distance.as(LengthUnit.Meter, point, referenceB);
}
return double.maxFinite;
}
static double _distanceBetweenNodes(MeshMapNode a, MeshMapNode b) {
return _distance.as(
LengthUnit.Meter,
LatLng(a.latitude, a.longitude),
LatLng(b.latitude, b.longitude),
);
}
static double _distanceToSegmentMeters(LatLng p, LatLng a, LatLng b) {
final ax = a.longitude;
final ay = a.latitude;
final bx = b.longitude;
final by = b.latitude;
final px = p.longitude;
final py = p.latitude;
final abx = bx - ax;
final aby = by - ay;
final apx = px - ax;
final apy = py - ay;
final ab2 = abx * abx + aby * aby;
if (ab2 == 0) {
return _distance.as(LengthUnit.Meter, a, p);
}
var t = (apx * abx + apy * aby) / ab2;
t = t.clamp(0.0, 1.0);
final closest = LatLng(ay + aby * t, ax + abx * t);
return _distance.as(LengthUnit.Meter, closest, p);
}
}

View File

@@ -11,20 +11,31 @@ const int _defaultLoRaCrcEnabled = 1;
const int _defaultLoRaExplicitHeader = 1;
const double _defaultAirtimeBudgetFactor = 1.0; // one half duty-cycle
/// Identifies which Codec2 mode was used for a voice packet.
/// Identifies which voice codec/mode was used for a packet.
/// Matches the modeId byte in the text/binary packet header.
enum VoicePacketMode {
mode700c(0, '700C'),
mode1200(1, '1200'),
mode2400(2, '2400'),
mode1300(3, '1300'),
mode1400(4, '1400'),
mode1600(5, '1600'),
mode3200(6, '3200');
mode700c(0, '700C', 8000, 100, 1600),
mode1200(1, '1200', 8000, 150, 1040),
mode2400(2, '2400', 8000, 300, 520),
mode1300(3, '1300', 8000, 175, 880),
mode1400(4, '1400', 8000, 175, 880),
mode1600(5, '1600', 8000, 200, 800),
mode3200(6, '3200', 8000, 400, 400);
const VoicePacketMode(this.id, this.label);
const VoicePacketMode(
this.id,
this.label,
this.sampleRateHz,
this.bytesPerSecond,
this.packetDurationMs,
);
final int id;
final String label;
final int sampleRateHz;
final int bytesPerSecond;
final int packetDurationMs;
int get samplesPerPacket => sampleRateHz * packetDurationMs ~/ 1000;
static VoicePacketMode fromId(int id) => VoicePacketMode.values.firstWhere(
(m) => m.id == id,
@@ -153,23 +164,21 @@ class VoicePacket {
/// Estimated audio duration of this packet in milliseconds.
int get durationMs {
// bytesPerSecond for each mode
final bps = switch (mode) {
VoicePacketMode.mode700c => 100,
VoicePacketMode.mode1200 => 150,
VoicePacketMode.mode1300 => 175,
VoicePacketMode.mode1400 => 175,
VoicePacketMode.mode1600 => 200,
VoicePacketMode.mode2400 => 300,
VoicePacketMode.mode3200 => 400,
};
if (bps == 0) return 0;
return (codec2Data.length * 1000 ~/ bps).clamp(0, 1500);
try {
final bytesPerSecond = voiceModeBytesPerSecond(mode);
if (bytesPerSecond <= 0) return 0;
return (codec2Data.length * 1000 ~/ bytesPerSecond).clamp(0, 1500);
} catch (_) {
// Be permissive with stale or malformed persisted voice metadata.
return 0;
}
}
@override
String toString() {
final suffix = total > 0 ? ' ${mode.label} [$index/${total - 1}]' : ' [$index]';
final suffix = total > 0
? ' ${mode.label} [$index/${total - 1}]'
: ' [$index]';
return 'VoicePacket($sessionId$suffix ${codec2Data.length}B)';
}
}
@@ -244,10 +253,10 @@ class VoiceEnvelope {
int voiceModeBytesPerSecond(VoicePacketMode mode) => switch (mode) {
VoicePacketMode.mode700c => 100,
VoicePacketMode.mode1200 => 150,
VoicePacketMode.mode2400 => 300,
VoicePacketMode.mode1300 => 175,
VoicePacketMode.mode1400 => 175,
VoicePacketMode.mode1600 => 200,
VoicePacketMode.mode2400 => 300,
VoicePacketMode.mode3200 => 400,
};
@@ -419,8 +428,7 @@ class VoiceFetchRequest {
this.version = 3,
});
static bool isVoiceFetchRequestText(String text) =>
text.startsWith(_prefix);
static bool isVoiceFetchRequestText(String text) => text.startsWith(_prefix);
static bool isVoiceFetchRequestBinary(Uint8List payload) =>
payload.isNotEmpty && payload[0] == _binaryMagic;
@@ -473,9 +481,7 @@ class VoiceFetchRequest {
final requesterKey6 = parts[2];
final normalizedWant = wantToken == 'a'
? 'all'
: ((wantToken.startsWith('m'))
? 'missing'
: wantToken);
: ((wantToken.startsWith('m')) ? 'missing' : wantToken);
if (sid == null) {
return null;
@@ -602,7 +608,11 @@ String _toBase36(int value) => value.toRadixString(36);
String _encodeSessionId(String sessionIdHex) {
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionIdHex)) {
throw ArgumentError.value(sessionIdHex, 'sessionIdHex', 'Expected 8 hex chars');
throw ArgumentError.value(
sessionIdHex,
'sessionIdHex',
'Expected 8 hex chars',
);
}
final value = int.parse(sessionIdHex, radix: 16);
return value.toRadixString(36);
@@ -616,10 +626,7 @@ String? _decodeSessionId(String token) {
}
String _encodeMissingIndicesCompact(List<int> indices) {
final sorted = indices
.where((v) => v >= 0 && v <= 254)
.toSet()
.toList()
final sorted = indices.where((v) => v >= 0 && v <= 254).toSet().toList()
..sort();
if (sorted.isEmpty) return '';
final chunks = <String>[];
@@ -632,7 +639,9 @@ String _encodeMissingIndicesCompact(List<int> indices) {
continue;
}
chunks.add(
start == prev ? _toBase36(start) : '${_toBase36(start)}-${_toBase36(prev)}',
start == prev
? _toBase36(start)
: '${_toBase36(start)}-${_toBase36(prev)}',
);
start = curr;
prev = curr;

View File

@@ -0,0 +1,175 @@
import 'package:flutter/material.dart';
enum _RefreshEdge { top, bottom }
class BidirectionalRefresh extends StatefulWidget {
static const double defaultTriggerDistance = 90;
final Widget child;
final Future<void> Function() onRefresh;
final double triggerDistance;
const BidirectionalRefresh({
super.key,
required this.child,
required this.onRefresh,
this.triggerDistance = defaultTriggerDistance,
});
@override
State<BidirectionalRefresh> createState() => _BidirectionalRefreshState();
}
class _BidirectionalRefreshState extends State<BidirectionalRefresh> {
double _topPullDistance = 0;
double _bottomPullDistance = 0;
bool _isRefreshing = false;
bool _handleScrollNotification(ScrollNotification notification) {
if (_isRefreshing) {
return false;
}
if (notification is ScrollStartNotification) {
_resetPullDistance();
return false;
}
if (notification is OverscrollNotification) {
final metrics = notification.metrics;
final isAtTop = metrics.extentBefore == 0;
final isAtBottom = metrics.extentAfter == 0;
if (!isAtTop && !isAtBottom) {
return false;
}
var shouldRefresh = false;
setState(() {
if (isAtTop) {
_topPullDistance += notification.overscroll.abs();
shouldRefresh = _topPullDistance >= widget.triggerDistance;
}
if (isAtBottom) {
_bottomPullDistance += notification.overscroll.abs();
shouldRefresh =
shouldRefresh || _bottomPullDistance >= widget.triggerDistance;
}
});
if (shouldRefresh) {
_runRefresh();
}
return false;
}
if (notification is ScrollEndNotification) {
_resetPullDistance();
}
return false;
}
Future<void> _runRefresh() async {
if (_isRefreshing) {
return;
}
setState(() {
_isRefreshing = true;
});
try {
await widget.onRefresh();
} finally {
if (mounted) {
setState(() {
_isRefreshing = false;
_topPullDistance = 0;
_bottomPullDistance = 0;
});
}
}
}
void _resetPullDistance() {
if (_topPullDistance == 0 && _bottomPullDistance == 0) {
return;
}
setState(() {
_topPullDistance = 0;
_bottomPullDistance = 0;
});
}
@override
Widget build(BuildContext context) {
final topProgress = (_topPullDistance / widget.triggerDistance).clamp(
0.0,
1.0,
);
final bottomProgress = (_bottomPullDistance / widget.triggerDistance).clamp(
0.0,
1.0,
);
return NotificationListener<ScrollNotification>(
onNotification: _handleScrollNotification,
child: Stack(
children: [
widget.child,
_RefreshIndicatorOverlay(
alignment: Alignment.topCenter,
progress: _isRefreshing ? 1 : topProgress,
visible: _isRefreshing || _topPullDistance > 0,
edge: _RefreshEdge.top,
),
_RefreshIndicatorOverlay(
alignment: Alignment.bottomCenter,
progress: _isRefreshing ? 1 : bottomProgress,
visible: _isRefreshing || _bottomPullDistance > 0,
edge: _RefreshEdge.bottom,
),
],
),
);
}
}
class _RefreshIndicatorOverlay extends StatelessWidget {
final Alignment alignment;
final double progress;
final bool visible;
final _RefreshEdge edge;
const _RefreshIndicatorOverlay({
required this.alignment,
required this.progress,
required this.visible,
required this.edge,
});
@override
Widget build(BuildContext context) {
if (!visible) {
return const SizedBox.shrink();
}
final indicator = RefreshProgressIndicator(value: progress);
final padding = edge == _RefreshEdge.top
? const EdgeInsets.only(top: 12)
: const EdgeInsets.only(bottom: 12);
return Positioned.fill(
child: IgnorePointer(
child: Align(
alignment: alignment,
child: Padding(
padding: padding,
child: SizedBox.square(dimension: 28, child: indicator),
),
),
),
);
}
}

View File

@@ -22,7 +22,7 @@ class ContactAvatar extends StatelessWidget {
final foregroundColor = _getForegroundColor(backgroundColor);
final emoji = contact.roleEmoji;
if (emoji != null && emoji.isNotEmpty) {
if (_showsLeadingEmoji && emoji != null && emoji.isNotEmpty) {
return _buildAvatarFrame(
backgroundColor: backgroundColor,
child: Text(emoji, style: TextStyle(fontSize: radius * 1.05)),
@@ -86,8 +86,16 @@ class ContactAvatar extends StatelessWidget {
bool get _usesSquareShape =>
contact.type == ContactType.channel || contact.type == ContactType.room;
bool get _showsLeadingEmoji {
final emoji = contact.roleEmoji;
if (emoji == null || emoji.isEmpty) return false;
final effectiveName = (displayName ?? contact.displayName).trimLeft();
return effectiveName.startsWith(emoji);
}
Color _getBackgroundColor(BuildContext context) {
if (_shouldUseLabelFallback || (contact.roleEmoji?.isNotEmpty ?? false)) {
if (_shouldUseLabelFallback || _showsLeadingEmoji) {
return TrailColorService.getTrailColor(contact);
}

View File

@@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import 'package:meshcore_client/meshcore_client.dart' show LogRxDataInfo;
class SignalMetric {
final String valueLabel;
final Color color;
final int activeBars;
const SignalMetric({
required this.valueLabel,
required this.color,
required this.activeBars,
});
static SignalMetric? fromRxInfo(LogRxDataInfo? rxInfo) {
if (rxInfo == null) return null;
return fromValues(rssiDbm: rxInfo.rssiDbm, snrDb: rxInfo.snrDb);
}
static SignalMetric? fromValues({int? rssiDbm, double? snrDb}) {
if (snrDb != null) {
return SignalMetric(
valueLabel: '${snrDb.toStringAsFixed(1)}dB',
color: snrDb >= 10
? Colors.green
: snrDb >= 0
? Colors.amber
: Colors.redAccent,
activeBars: snrDb >= 10
? 3
: snrDb >= 0
? 2
: 1,
);
}
if (rssiDbm != null) {
return SignalMetric(
valueLabel: '$rssiDbm dBm',
color: rssiDbm >= -80
? Colors.green
: rssiDbm >= -95
? Colors.amber
: Colors.redAccent,
activeBars: rssiDbm >= -80
? 3
: rssiDbm >= -95
? 2
: 1,
);
}
return null;
}
}
class CompactSignalIndicator extends StatelessWidget {
final SignalMetric metric;
final bool dense;
const CompactSignalIndicator({
super.key,
required this.metric,
this.dense = false,
});
@override
Widget build(BuildContext context) {
final inactive = Theme.of(context).colorScheme.outlineVariant;
final barWidth = dense ? 4.0 : 5.0;
final baseHeight = dense ? 8.0 : 10.0;
final barStep = dense ? 6.0 : 8.0;
final barSpacing = dense ? 2.0 : 3.0;
final labelGap = dense ? 4.0 : 6.0;
final labelFontSize = dense ? 10.0 : 12.0;
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (var index = 0; index < 3; index++) ...[
if (index > 0) SizedBox(width: barSpacing),
Container(
width: barWidth,
height: baseHeight + (index * barStep),
decoration: BoxDecoration(
color: index < metric.activeBars ? metric.color : inactive,
borderRadius: BorderRadius.circular(2),
),
),
],
],
),
SizedBox(height: labelGap),
Text(
metric.valueLabel,
style: TextStyle(
fontSize: labelFontSize,
fontWeight: FontWeight.w700,
color: Theme.of(context).colorScheme.onSurface,
),
),
],
);
}
}

View File

@@ -1,4 +1,5 @@
import 'dart:io';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
@@ -25,6 +26,10 @@ class _ConnectionModeSelectorState extends State<ConnectionModeSelector> {
}
Future<void> _loadLocalIPs() async {
if (kIsWeb) {
return;
}
final Set<String> ipsSet = {};
try {

View File

@@ -1,20 +1,17 @@
import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
/// Dialog for adding a new channel
class AddChannelDialog extends StatefulWidget {
/// Bottom sheet for adding a new channel
class AddChannelSheet extends StatefulWidget {
final Future<void> Function(String name, String secret) onCreateChannel;
const AddChannelDialog({
super.key,
required this.onCreateChannel,
});
const AddChannelSheet({super.key, required this.onCreateChannel});
@override
State<AddChannelDialog> createState() => _AddChannelDialogState();
State<AddChannelSheet> createState() => _AddChannelSheetState();
}
class _AddChannelDialogState extends State<AddChannelDialog> {
class _AddChannelSheetState extends State<AddChannelSheet> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _secretController = TextEditingController();
@@ -35,16 +32,17 @@ class _AddChannelDialogState extends State<AddChannelDialog> {
/// Validate channel name
String? _validateName(String? value) {
final l10n = AppLocalizations.of(context)!;
final trimmedValue = value?.trim() ?? '';
if (value == null || value.trim().isEmpty) {
if (trimmedValue.isEmpty) {
return l10n.channelNameRequired;
}
if (value.length > 31) {
if (trimmedValue.length > 31) {
return l10n.channelNameTooLong;
}
if (!_isAscii(value)) {
if (!_isAscii(trimmedValue)) {
return l10n.invalidAsciiCharacters;
}
@@ -83,7 +81,7 @@ class _AddChannelDialogState extends State<AddChannelDialog> {
try {
final channelName = _nameController.text.trim();
final isHashChannel = channelName.startsWith('#');
// For hash channels, pass empty secret (will be auto-generated)
// For private channels, use the provided secret
final secret = isHashChannel ? '' : _secretController.text;
@@ -105,147 +103,186 @@ class _AddChannelDialogState extends State<AddChannelDialog> {
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final isHashChannel = _nameController.text.startsWith('#');
final normalizedName = _nameController.text.trimLeft();
final isHashChannel = normalizedName.startsWith('#');
return AlertDialog(
title: Text(l10n.addChannel),
content: SingleChildScrollView(
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Info banner explaining channel types
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: theme.colorScheme.primary.withValues(alpha: 0.3),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
return ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.85,
),
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.info_outline,
size: 20,
color: theme.colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
l10n.channelTypesInfo,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
l10n.addChannel,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
IconButton(
onPressed: _isCreating
? null
: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
tooltip: l10n.close,
),
],
),
),
const SizedBox(height: 16),
// Channel Name Field
TextFormField(
controller: _nameController,
decoration: InputDecoration(
labelText: l10n.channelName,
hintText: l10n.channelNameHint,
border: const OutlineInputBorder(),
prefixIcon: Icon(
isHashChannel ? Icons.tag : Icons.lock_outline,
color: isHashChannel ? Colors.blue : Colors.orange,
),
),
enabled: !_isCreating,
maxLength: 31,
validator: _validateName,
textInputAction: TextInputAction.next,
onChanged: (_) => setState(() {}), // Rebuild to update icon
),
// Channel Secret Field (only show for private channels)
if (!isHashChannel) ...[
const SizedBox(height: 16),
TextFormField(
controller: _secretController,
decoration: InputDecoration(
labelText: l10n.channelSecret,
hintText: l10n.channelSecretHint,
border: const OutlineInputBorder(),
),
obscureText: true,
enabled: !_isCreating,
maxLength: 32,
validator: _validateSecret,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _handleCreate(),
),
const SizedBox(height: 8),
// Help Text for private channels
Text(
l10n.channelSecretHelp,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
// Help Text for hash channels
if (isHashChannel) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(8),
color: theme.colorScheme.primaryContainer.withValues(
alpha: 0.3,
),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: theme.colorScheme.primary.withValues(alpha: 0.3),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(
Icons.auto_awesome,
Icon(
Icons.info_outline,
size: 20,
color: Colors.blue,
color: theme.colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
l10n.hashChannelInfo,
l10n.channelTypesInfo,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w500,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
),
const SizedBox(height: 16),
TextFormField(
controller: _nameController,
decoration: InputDecoration(
labelText: l10n.channelName,
hintText: l10n.channelNameHint,
border: const OutlineInputBorder(),
prefixIcon: Icon(
isHashChannel ? Icons.tag : Icons.lock_outline,
color: isHashChannel ? Colors.blue : Colors.orange,
),
),
enabled: !_isCreating,
maxLength: 31,
validator: _validateName,
textInputAction: isHashChannel
? TextInputAction.done
: TextInputAction.next,
onChanged: (_) => setState(() {}),
onFieldSubmitted: (_) {
if (isHashChannel) {
_handleCreate();
}
},
),
if (!isHashChannel) ...[
const SizedBox(height: 16),
TextFormField(
controller: _secretController,
decoration: InputDecoration(
labelText: l10n.channelSecret,
hintText: l10n.channelSecretHint,
border: const OutlineInputBorder(),
),
obscureText: true,
enabled: !_isCreating,
maxLength: 32,
validator: _validateSecret,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _handleCreate(),
),
const SizedBox(height: 8),
Text(
l10n.channelSecretHelp,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
if (isHashChannel) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer.withValues(
alpha: 0.5,
),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
const Icon(
Icons.auto_awesome,
size: 20,
color: Colors.blue,
),
const SizedBox(width: 8),
Expanded(
child: Text(
l10n.hashChannelInfo,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
],
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: _isCreating
? null
: () => Navigator.of(context).pop(),
child: Text(l10n.cancel),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton(
onPressed: _isCreating ? null : _handleCreate,
child: _isCreating
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: Text(l10n.createChannel),
),
),
],
),
],
],
),
),
),
),
actions: [
// Cancel Button
TextButton(
onPressed: _isCreating ? null : () => Navigator.of(context).pop(),
child: Text(l10n.cancel),
),
// Create Button
FilledButton(
onPressed: _isCreating ? null : _handleCreate,
child: _isCreating
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(l10n.createChannel),
),
],
);
}
}

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More