Compare commits

..

13 Commits

Author SHA1 Message Date
Janez T
43eb857663 Bump iOS build to 133 2026-04-05 20:07:19 +02:00
Janez T
faa396769e feat: Add buzzer alerts toggle 2026-04-05 20:02:54 +02:00
Janez T
1a35b790a7 feat: Add GPS diagnostics #0 2026-04-05 19:58:34 +02:00
Janez T
573921e002 fix: Clarify shared location labels and onboarding copy #123 2026-04-05 19:54:48 +02:00
Janez T
14a41b210e feat: Add manual TCP IP entry 2026-04-05 19:45:16 +02:00
Janez T
56a7fa2fc6 fix: Use location pin icon 2026-04-05 19:39:40 +02:00
Janez T
7f4b4c4560 feat: Add message location details 2026-04-05 19:33:41 +02:00
Janez T
7f7f4aa975 fix: Widen worker charts 2026-04-05 18:01:05 +02:00
Janez T
1baab06627 fix: Widen worker stats graph 2026-04-04 20:20:36 +02:00
Janez T
5320fa1a2b fix: Guard null history decode #123 2026-04-04 15:58:21 +02:00
Janez T
512b64db9f fix: Preserve custom path priority 2026-04-04 15:57:41 +02:00
Janez T
4690ceead1 chore: Remove reporter map dependencies 2026-04-04 10:01:03 +02:00
Janez T
d431a79aa8 Fix: Limit stats retention to 7 days 2026-04-03 22:42:52 +02:00
46 changed files with 5975 additions and 2057 deletions

View File

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

View File

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

View File

@@ -5,22 +5,22 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000729"> <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000253">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="1.008455"> <testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.303267">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="2: build_app" time="224.694344"> <testcase classname="fastlane.lanes" name="2: build_app" time="131.526213">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="170.718455"> <testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="206.79174">
</testcase> </testcase>

View File

@@ -2317,7 +2317,7 @@
"@wizardOverviewFeature1": { "@wizardOverviewFeature1": {
"description": "Updated onboarding overview feature 1" "description": "Updated onboarding overview feature 1"
}, },
"wizardOverviewFeature2": "Share SAR markers, map drawings, voice clips, and images over the mesh.", "wizardOverviewFeature2": "Share live location to a private channel, plus SAR markers, map drawings, voice clips, and images over the mesh.",
"@wizardOverviewFeature2": { "@wizardOverviewFeature2": {
"description": "Updated onboarding overview feature 2" "description": "Updated onboarding overview feature 2"
}, },
@@ -2458,7 +2458,7 @@
"@wizardMapOpsDescription": { "@wizardMapOpsDescription": {
"description": "Updated onboarding map page description" "description": "Updated onboarding map page description"
}, },
"wizardMapOpsFeature1": "Track your own position, teammate locations, and movement trails on the map.", "wizardMapOpsFeature1": "Track your own position, teammate locations, and share live location to a private channel from the channel menu.",
"@wizardMapOpsFeature1": { "@wizardMapOpsFeature1": {
"description": "Updated onboarding map feature 1" "description": "Updated onboarding map feature 1"
}, },

View File

@@ -3267,7 +3267,7 @@ abstract class AppLocalizations {
/// Updated onboarding overview feature 2 /// Updated onboarding overview feature 2
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Share SAR markers, map drawings, voice clips, and images over the mesh.'** /// **'Share live location to a private channel, plus SAR markers, map drawings, voice clips, and images over the mesh.'**
String get wizardOverviewFeature2; String get wizardOverviewFeature2;
/// Updated onboarding overview feature 3 /// Updated onboarding overview feature 3
@@ -3459,7 +3459,7 @@ abstract class AppLocalizations {
/// Updated onboarding map feature 1 /// Updated onboarding map feature 1
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Track your own position, teammate locations, and movement trails on the map.'** /// **'Track your own position, teammate locations, and share live location to a private channel from the channel menu.'**
String get wizardMapOpsFeature1; String get wizardMapOpsFeature1;
/// Updated onboarding map feature 2 /// Updated onboarding map feature 2

View File

@@ -1743,7 +1743,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get wizardOverviewFeature2 => String get wizardOverviewFeature2 =>
'Share SAR markers, map drawings, voice clips, and images over the mesh.'; 'Share live location to a private channel, plus SAR markers, map drawings, voice clips, and images over the mesh.';
@override @override
String get wizardOverviewFeature3 => String get wizardOverviewFeature3 =>
@@ -1860,7 +1860,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get wizardMapOpsFeature1 => String get wizardMapOpsFeature1 =>
'Track your own position, teammate locations, and movement trails on the map.'; 'Track your own position, teammate locations, and share live location to a private channel from the channel menu.';
@override @override
String get wizardMapOpsFeature2 => String get wizardMapOpsFeature2 =>

View File

@@ -18,6 +18,7 @@ import 'providers/channels_provider.dart';
import 'providers/voice_provider.dart'; import 'providers/voice_provider.dart';
import 'providers/image_provider.dart' as ip; import 'providers/image_provider.dart' as ip;
import 'providers/app_provider.dart'; import 'providers/app_provider.dart';
import 'providers/offline_tiles_provider.dart';
import 'providers/sensors_provider.dart'; import 'providers/sensors_provider.dart';
import 'services/voice_codec_service.dart'; import 'services/voice_codec_service.dart';
import 'services/voice_player_service.dart'; import 'services/voice_player_service.dart';
@@ -285,6 +286,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
), ),
ChangeNotifierProvider(create: (_) => ChannelsProvider()), ChangeNotifierProvider(create: (_) => ChannelsProvider()),
ChangeNotifierProvider(create: (_) => SensorsProvider()), ChangeNotifierProvider(create: (_) => SensorsProvider()),
ChangeNotifierProvider(create: (_) => OfflineTilesProvider()),
ChangeNotifierProvider( ChangeNotifierProvider(
create: (_) { create: (_) {
final manager = ProfileManager(); final manager = ProfileManager();

View File

@@ -116,7 +116,6 @@ class AppSettingsProfileSection {
final bool? voiceEchoCancellationEnabled; final bool? voiceEchoCancellationEnabled;
final bool? voiceNoiseSuppressionEnabled; final bool? voiceNoiseSuppressionEnabled;
final double? messageFontScale; final double? messageFontScale;
final bool? autoRouteRotationEnabled;
final bool? clearPathOnMaxRetry; final bool? clearPathOnMaxRetry;
final bool? nearestRelayFallbackEnabled; final bool? nearestRelayFallbackEnabled;
final int? voiceBitrate; final int? voiceBitrate;
@@ -142,7 +141,6 @@ class AppSettingsProfileSection {
this.voiceEchoCancellationEnabled, this.voiceEchoCancellationEnabled,
this.voiceNoiseSuppressionEnabled, this.voiceNoiseSuppressionEnabled,
this.messageFontScale, this.messageFontScale,
this.autoRouteRotationEnabled,
this.clearPathOnMaxRetry, this.clearPathOnMaxRetry,
this.nearestRelayFallbackEnabled, this.nearestRelayFallbackEnabled,
this.voiceBitrate, this.voiceBitrate,
@@ -169,7 +167,6 @@ class AppSettingsProfileSection {
voiceEchoCancellationEnabled == null && voiceEchoCancellationEnabled == null &&
voiceNoiseSuppressionEnabled == null && voiceNoiseSuppressionEnabled == null &&
messageFontScale == null && messageFontScale == null &&
autoRouteRotationEnabled == null &&
clearPathOnMaxRetry == null && clearPathOnMaxRetry == null &&
nearestRelayFallbackEnabled == null && nearestRelayFallbackEnabled == null &&
voiceBitrate == null && voiceBitrate == null &&
@@ -195,7 +192,6 @@ class AppSettingsProfileSection {
'voiceEchoCancellationEnabled': voiceEchoCancellationEnabled, 'voiceEchoCancellationEnabled': voiceEchoCancellationEnabled,
'voiceNoiseSuppressionEnabled': voiceNoiseSuppressionEnabled, 'voiceNoiseSuppressionEnabled': voiceNoiseSuppressionEnabled,
'messageFontScale': messageFontScale, 'messageFontScale': messageFontScale,
'autoRouteRotationEnabled': autoRouteRotationEnabled,
'clearPathOnMaxRetry': clearPathOnMaxRetry, 'clearPathOnMaxRetry': clearPathOnMaxRetry,
'nearestRelayFallbackEnabled': nearestRelayFallbackEnabled, 'nearestRelayFallbackEnabled': nearestRelayFallbackEnabled,
'voiceBitrate': voiceBitrate, 'voiceBitrate': voiceBitrate,
@@ -225,7 +221,6 @@ class AppSettingsProfileSection {
voiceNoiseSuppressionEnabled: voiceNoiseSuppressionEnabled:
json['voiceNoiseSuppressionEnabled'] as bool?, json['voiceNoiseSuppressionEnabled'] as bool?,
messageFontScale: (json['messageFontScale'] as num?)?.toDouble(), messageFontScale: (json['messageFontScale'] as num?)?.toDouble(),
autoRouteRotationEnabled: json['autoRouteRotationEnabled'] as bool?,
clearPathOnMaxRetry: json['clearPathOnMaxRetry'] as bool?, clearPathOnMaxRetry: json['clearPathOnMaxRetry'] as bool?,
nearestRelayFallbackEnabled: json['nearestRelayFallbackEnabled'] as bool?, nearestRelayFallbackEnabled: json['nearestRelayFallbackEnabled'] as bool?,
voiceBitrate: json['voiceBitrate'] as int?, voiceBitrate: json['voiceBitrate'] as int?,

View File

@@ -15,10 +15,14 @@ class MessageContactLocation {
String get technicalSourceLabel { String get technicalSourceLabel {
switch (source) { switch (source) {
case 'shared':
return 'shared location';
case 'gps':
return 'shared location';
case 'telemetry': case 'telemetry':
return 'telemetry'; return 'live telemetry';
case 'advert': case 'advert':
return 'advert'; return 'shared advert';
default: default:
return source; return source;
} }

View File

@@ -1,229 +0,0 @@
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;
final DateTime? lastSucceededAt;
final double? senderLatitude;
final double? senderLongitude;
final double? recipientLatitude;
final double? recipientLongitude;
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,
required this.lastSucceededAt,
required this.senderLatitude,
required this.senderLongitude,
required this.recipientLatitude,
required this.recipientLongitude,
});
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,
DateTime? lastSucceededAt,
double? senderLatitude,
double? senderLongitude,
double? recipientLatitude,
double? recipientLongitude,
}) {
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,
lastSucceededAt: lastSucceededAt ?? this.lastSucceededAt,
senderLatitude: senderLatitude ?? this.senderLatitude,
senderLongitude: senderLongitude ?? this.senderLongitude,
recipientLatitude: recipientLatitude ?? this.recipientLatitude,
recipientLongitude: recipientLongitude ?? this.recipientLongitude,
);
}
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(),
'last_succeeded_at': lastSucceededAt?.toIso8601String(),
'sender_latitude': senderLatitude,
'sender_longitude': senderLongitude,
'recipient_latitude': recipientLatitude,
'recipient_longitude': recipientLongitude,
};
}
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),
lastSucceededAt: DateTime.tryParse(
json['last_succeeded_at'] as String? ?? '',
),
senderLatitude: (json['sender_latitude'] as num?)?.toDouble(),
senderLongitude: (json['sender_longitude'] as num?)?.toDouble(),
recipientLatitude: (json['recipient_latitude'] as num?)?.toDouble(),
recipientLongitude: (json['recipient_longitude'] as num?)?.toDouble(),
);
}
}
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

@@ -118,6 +118,34 @@ enum ContactsTabSection {
channels, channels,
} }
enum ChannelLocationSharingMode { hardware, appFallback }
class ChannelLocationSharingState {
final ChannelLocationSharingMode mode;
final bool isSharing;
final bool hardwareSupported;
final bool isConnected;
const ChannelLocationSharingState({
required this.mode,
required this.isSharing,
required this.hardwareSupported,
required this.isConnected,
});
bool get usesHardware => mode == ChannelLocationSharingMode.hardware;
}
class ChannelLocationSharingResult {
final ChannelLocationSharingState state;
final String message;
const ChannelLocationSharingResult({
required this.state,
required this.message,
});
}
/// Main App Provider - coordinates all other providers /// Main App Provider - coordinates all other providers
class AppProvider with ChangeNotifier { class AppProvider with ChangeNotifier {
static const int _maxDirectPayloadHops = 3; static const int _maxDirectPayloadHops = 3;
@@ -195,9 +223,6 @@ class AppProvider with ChangeNotifier {
bool get isVoiceNoiseSuppressionEnabled => _isVoiceNoiseSuppressionEnabled; bool get isVoiceNoiseSuppressionEnabled => _isVoiceNoiseSuppressionEnabled;
double _messageFontScale = 1.0; double _messageFontScale = 1.0;
double get messageFontScale => _messageFontScale; double get messageFontScale => _messageFontScale;
bool _autoRouteRotationEnabled =
MessagingRoutePreferences.defaultAutoRouteRotationEnabled;
bool get autoRouteRotationEnabled => _autoRouteRotationEnabled;
bool _clearPathOnMaxRetry = bool _clearPathOnMaxRetry =
MessagingRoutePreferences.defaultClearPathOnMaxRetry; MessagingRoutePreferences.defaultClearPathOnMaxRetry;
bool get clearPathOnMaxRetry => _clearPathOnMaxRetry; bool get clearPathOnMaxRetry => _clearPathOnMaxRetry;
@@ -209,6 +234,7 @@ class AppProvider with ChangeNotifier {
const NearestRouterSelector(); const NearestRouterSelector();
final Map<String, _DirectMessageRouteSession> _directMessageRouteSessions = final Map<String, _DirectMessageRouteSession> _directMessageRouteSessions =
{}; {};
final Set<String> _pendingDeliveredRouteRefreshContacts = <String>{};
static const Duration _packetRetryDelay = Duration(milliseconds: 1200); static const Duration _packetRetryDelay = Duration(milliseconds: 1200);
static const Duration _mediaSwarmResponseWindow = Duration(seconds: 10); static const Duration _mediaSwarmResponseWindow = Duration(seconds: 10);
@@ -221,6 +247,8 @@ class AppProvider with ChangeNotifier {
final Map<String, int> _voiceMissingRetryAttempts = {}; final Map<String, int> _voiceMissingRetryAttempts = {};
final Map<String, Timer> _imageMissingRetryTimers = {}; final Map<String, Timer> _imageMissingRetryTimers = {};
final Map<String, int> _imageMissingRetryAttempts = {}; final Map<String, int> _imageMissingRetryAttempts = {};
bool _hardwareChannelLocationSharingSupported = false;
int? _hardwareChannelLocationSharingChannelIdx;
final FragmentAckWaitRegistry _rawProbeWaiters = FragmentAckWaitRegistry(); final FragmentAckWaitRegistry _rawProbeWaiters = FragmentAckWaitRegistry();
final Map<String, Future<bool>> _pendingRawRouteProbes = {}; final Map<String, Future<bool>> _pendingRawRouteProbes = {};
final Map<String, Future<bool>> _pendingMediaSwarmFetches = {}; final Map<String, Future<bool>> _pendingMediaSwarmFetches = {};
@@ -869,8 +897,7 @@ class AppProvider with ChangeNotifier {
Future<void> _loadMessagingRouteSettings() async { Future<void> _loadMessagingRouteSettings() async {
try { try {
_autoRouteRotationEnabled = await MessagingRoutePreferences.cleanupLegacySettings();
await MessagingRoutePreferences.getAutoRouteRotationEnabled();
_clearPathOnMaxRetry = _clearPathOnMaxRetry =
await MessagingRoutePreferences.getClearPathOnMaxRetry(); await MessagingRoutePreferences.getClearPathOnMaxRetry();
_nearestRelayFallbackEnabled = _nearestRelayFallbackEnabled =
@@ -881,16 +908,6 @@ class AppProvider with ChangeNotifier {
} }
} }
Future<void> toggleAutoRouteRotationEnabled(bool enabled) async {
try {
_autoRouteRotationEnabled = enabled;
await MessagingRoutePreferences.setAutoRouteRotationEnabled(enabled);
notifyListeners();
} catch (e) {
debugPrint('Error saving auto route rotation setting: $e');
}
}
Future<void> toggleClearPathOnMaxRetry(bool enabled) async { Future<void> toggleClearPathOnMaxRetry(bool enabled) async {
try { try {
_clearPathOnMaxRetry = enabled; _clearPathOnMaxRetry = enabled;
@@ -1044,7 +1061,14 @@ class AppProvider with ChangeNotifier {
contact, contact,
devicePublicKey: devicePublicKey, devicePublicKey: devicePublicKey,
); );
unawaited(_pathHistoryService.recordLearnedPath(contact));
final updatedContact =
contactsProvider.findContactByKey(contact.publicKey) ?? contact;
if (_pendingDeliveredRouteRefreshContacts.remove(
updatedContact.publicKeyHex,
)) {
messagesProvider.applyDeliveredMessageRouteFromContact(updatedContact);
}
}; };
// When all contacts are received // When all contacts are received
@@ -1054,9 +1078,6 @@ class AppProvider with ChangeNotifier {
contacts, contacts,
devicePublicKey: connectionProvider.deviceInfo.publicKey, devicePublicKey: connectionProvider.deviceInfo.publicKey,
); );
for (final contact in contacts) {
unawaited(_pathHistoryService.recordLearnedPath(contact));
}
debugPrint('Received ${contacts.length} contacts'); debugPrint('Received ${contacts.length} contacts');
}; };
@@ -1254,18 +1275,6 @@ class AppProvider with ChangeNotifier {
enrichedMessage, enrichedMessage,
); );
final receivedPathBytes = receptionDetailsSnapshot?.pathBytes; final receivedPathBytes = receptionDetailsSnapshot?.pathBytes;
if (senderContact != null &&
enrichedMessage.isChannelMessage &&
receivedPathBytes != null &&
receivedPathBytes.isNotEmpty) {
unawaited(
_learnPathFromPublicMessage(
contact: senderContact,
pathBytes: receivedPathBytes,
),
);
}
// Estimate location for contacts without GPS using received path // Estimate location for contacts without GPS using received path
if (senderContact != null && if (senderContact != null &&
senderContact.displayLocation == null && senderContact.displayLocation == null &&
@@ -1685,6 +1694,7 @@ class AppProvider with ChangeNotifier {
// When a contact's routing path is updated in the mesh network // When a contact's routing path is updated in the mesh network
connectionProvider.onPathUpdated = (publicKey) { connectionProvider.onPathUpdated = (publicKey) {
_pendingDeliveredRouteRefreshContacts.add(_publicKeyHex(publicKey));
debugPrint( debugPrint(
'🔄 [AppProvider] Path updated for contact: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}...', '🔄 [AppProvider] Path updated for contact: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}...',
); );
@@ -1846,17 +1856,11 @@ class AppProvider with ChangeNotifier {
contactsProvider.findContactByKey(contact.publicKey) ?? contact; contactsProvider.findContactByKey(contact.publicKey) ?? contact;
var session = _directMessageRouteSessions[messageId]; var session = _directMessageRouteSessions[messageId];
if (session == null) { if (session == null) {
final selection = latestContact.routeHasPath && latestContact.routeHopCount > 0 final manualSelection = await _pathHistoryService
? PathSelection( .getManualSelectionForContact(latestContact);
mode: PathSelectionMode.directCurrent, final selection =
pathBytes: Uint8List.fromList(latestContact.routePathBytes), manualSelection ??
hopCount: latestContact.routeHopCount, await _pathHistoryService.getSelectionForContact(latestContact);
hashSize: latestContact.routeHashSize,
)
: await _pathHistoryService.getSelectionForContact(
latestContact,
autoRouteRotationEnabled: _autoRouteRotationEnabled,
);
session = _DirectMessageRouteSession( session = _DirectMessageRouteSession(
currentSelection: selection, currentSelection: selection,
originalRoute: ContactRouteCodec.fromContact(latestContact), originalRoute: ContactRouteCodec.fromContact(latestContact),
@@ -1865,16 +1869,9 @@ class AppProvider with ChangeNotifier {
} }
if (!session.routerFallbackAttempted) { if (!session.routerFallbackAttempted) {
final currentSignature =
latestContact.routeHasPath && latestContact.routeHopCount > 0
? latestContact.routePathBytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join()
: null;
final selection = await _resolveDirectMessageSelectionForRetry( final selection = await _resolveDirectMessageSelectionForRetry(
latestContact, latestContact,
retryAttempt: retryAttempt, retryAttempt: retryAttempt,
currentSignature: currentSignature,
fallbackSelection: session.currentSelection, fallbackSelection: session.currentSelection,
); );
session = session.copyWith(currentSelection: selection); session = session.copyWith(currentSelection: selection);
@@ -1894,35 +1891,17 @@ class AppProvider with ChangeNotifier {
Future<PathSelection> _resolveDirectMessageSelectionForRetry( Future<PathSelection> _resolveDirectMessageSelectionForRetry(
Contact contact, { Contact contact, {
required int retryAttempt, required int retryAttempt,
required String? currentSignature,
required PathSelection fallbackSelection, required PathSelection fallbackSelection,
}) async { }) async {
if (contact.routeHasPath && contact.routeHopCount > 0 && retryAttempt <= 1) {
return PathSelection(
mode: PathSelectionMode.directCurrent,
pathBytes: Uint8List.fromList(contact.routePathBytes),
hopCount: contact.routeHopCount,
hashSize: contact.routeHashSize,
);
}
if (retryAttempt == 2) { if (retryAttempt == 2) {
return PathSelection.flood(); return PathSelection.flood();
} }
if (retryAttempt >= 3) { if (retryAttempt < 2 &&
final historicalSelection = await _pathHistoryService !fallbackSelection.hasDirectPath &&
.getLastSuccessfulDirectSelection( contact.routeHasPath &&
contact, contact.routeHopCount > 0) {
excludeSignature: currentSignature, return _pathHistoryService.getSelectionForContact(contact);
senderLatitude: locationTrackingService.currentPosition?.latitude,
senderLongitude: locationTrackingService.currentPosition?.longitude,
recipientLatitude: contact.displayLocation?.latitude,
recipientLongitude: contact.displayLocation?.longitude,
);
if (historicalSelection != null) {
return historicalSelection;
}
} }
return fallbackSelection; return fallbackSelection;
@@ -2032,27 +2011,19 @@ class AppProvider with ChangeNotifier {
final latestContact = final latestContact =
contactsProvider.findContactByKey(contact.publicKey) ?? contact; contactsProvider.findContactByKey(contact.publicKey) ?? contact;
final manualSelection = await _pathHistoryService.getManualSelectionForContact(
latestContact,
);
final session = final session =
_directMessageRouteSessions[messageId] ?? _directMessageRouteSessions[messageId] ??
_DirectMessageRouteSession( _DirectMessageRouteSession(
currentSelection: latestContact.routeHasPath currentSelection:
? PathSelection( manualSelection ??
mode: PathSelectionMode.directCurrent, await _pathHistoryService.getSelectionForContact(latestContact),
pathBytes: Uint8List.fromList(latestContact.routePathBytes),
hopCount: latestContact.routeHopCount,
hashSize: latestContact.routeHashSize,
)
: PathSelection.flood(),
originalRoute: ContactRouteCodec.fromContact(latestContact), originalRoute: ContactRouteCodec.fromContact(latestContact),
routerFallbackAttempted: false, routerFallbackAttempted: false,
); );
await _pathHistoryService.recordPathResult(
latestContact.publicKeyHex,
session.currentSelection,
success: false,
);
final repeater = _nearestRouterSelector.select( final repeater = _nearestRouterSelector.select(
senderPosition: locationTrackingService.currentPosition, senderPosition: locationTrackingService.currentPosition,
repeaters: contactsProvider.repeaters, repeaters: contactsProvider.repeaters,
@@ -2096,18 +2067,10 @@ class AppProvider with ChangeNotifier {
return; return;
} }
unawaited( if (session.currentSelection.usesFlood ||
_pathHistoryService.recordPathResult( session.currentSelection.mode == PathSelectionMode.nearestRouter) {
contact.publicKeyHex, messagesProvider.queueDeliveredMessageRouteRefresh(messageId, contact);
session.currentSelection, }
success: true,
roundTripTimeMs: roundTripTimeMs,
senderLatitude: locationTrackingService.currentPosition?.latitude,
senderLongitude: locationTrackingService.currentPosition?.longitude,
recipientLatitude: contact.displayLocation?.latitude,
recipientLongitude: contact.displayLocation?.longitude,
),
);
} }
Future<void> _handleDirectMessageFinalFailure({ Future<void> _handleDirectMessageFinalFailure({
@@ -2118,11 +2081,6 @@ class AppProvider with ChangeNotifier {
contactsProvider.findContactByKey(contact.publicKey) ?? contact; contactsProvider.findContactByKey(contact.publicKey) ?? contact;
final session = _directMessageRouteSessions.remove(messageId); final session = _directMessageRouteSessions.remove(messageId);
if (session != null) { if (session != null) {
await _pathHistoryService.recordPathResult(
latestContact.publicKeyHex,
session.currentSelection,
success: false,
);
if (session.routerFallbackAttempted) { if (session.routerFallbackAttempted) {
await _restoreRouteOnDevice(latestContact, session.originalRoute); await _restoreRouteOnDevice(latestContact, session.originalRoute);
} }
@@ -2144,6 +2102,12 @@ class AppProvider with ChangeNotifier {
return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(); return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
} }
String _publicKeyHex(Uint8List publicKey) {
return publicKey
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join();
}
/// Estimate contact location from the received message path. /// Estimate contact location from the received message path.
/// ///
/// When we receive a message, the path bytes describe how it traveled: /// When we receive a message, the path bytes describe how it traveled:
@@ -2194,22 +2158,6 @@ class AppProvider with ChangeNotifier {
); );
} }
Future<void> _learnPathFromPublicMessage({
required Contact contact,
required List<int> pathBytes,
}) async {
final preferred = await RouteHashPreferences.getHashSize();
final inferredHashSize = _inferReceivedPathHashSize(
pathBytes,
preferredHashSize: preferred,
);
await _pathHistoryService.recordReceivedBytePath(
contact.publicKeyHex,
pathBytes,
inferredHashSize,
);
}
Future<void> _retainAdvertRxPath(Uint8List publicKey) async { Future<void> _retainAdvertRxPath(Uint8List publicKey) async {
final decoded = _findBestMatchingAdvertRxRoute(publicKey); final decoded = _findBestMatchingAdvertRxRoute(publicKey);
if (decoded == null || decoded.pathBytes.isEmpty) { if (decoded == null || decoded.pathBytes.isEmpty) {
@@ -2235,11 +2183,6 @@ class AppProvider with ChangeNotifier {
paddedPathBytes: parsedRoute.paddedPathBytes, paddedPathBytes: parsedRoute.paddedPathBytes,
devicePublicKey: connectionProvider.deviceInfo.publicKey, devicePublicKey: connectionProvider.deviceInfo.publicKey,
); );
await _pathHistoryService.recordReceivedBytePath(
publicKey.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(),
decoded.pathBytes,
decoded.hashSize,
);
} }
/// Handle pushAdvert (0x80) — matches the official MeshCore app flow: /// Handle pushAdvert (0x80) — matches the official MeshCore app flow:
@@ -2580,22 +2523,6 @@ class AppProvider with ChangeNotifier {
} }
} }
int _inferReceivedPathHashSize(
List<int> pathBytes, {
required int preferredHashSize,
}) {
final preferred = preferredHashSize;
final candidates = {preferred, 3, 2, 1}.toList();
for (final candidate in candidates) {
if (candidate >= 1 &&
candidate <= 3 &&
pathBytes.length % candidate == 0) {
return candidate;
}
}
return 1;
}
/// Initialize the app (load contacts, sync time, etc.) /// Initialize the app (load contacts, sync time, etc.)
Future<void> initialize() async { Future<void> initialize() async {
if (!connectionProvider.deviceInfo.isConnected) return; if (!connectionProvider.deviceInfo.isConnected) return;
@@ -2647,6 +2574,7 @@ class AppProvider with ChangeNotifier {
'📍 [AppProvider] Starting location tracking after successful initialization', '📍 [AppProvider] Starting location tracking after successful initialization',
); );
await _startLocationTracking(); await _startLocationTracking();
await refreshChannelLocationSharingState();
// Sync drawing messages with DrawingProvider // Sync drawing messages with DrawingProvider
// This restores any drawings that may be missing from storage // This restores any drawings that may be missing from storage
@@ -2690,6 +2618,7 @@ class AppProvider with ChangeNotifier {
await connectionProvider.syncChannels( await connectionProvider.syncChannels(
maxChannels: connectionProvider.deviceInfo.maxChannels, maxChannels: connectionProvider.deviceInfo.maxChannels,
); );
await refreshChannelLocationSharingState();
final messageCount = await connectionProvider.syncAllMessages( final messageCount = await connectionProvider.syncAllMessages(
force: true, force: true,
@@ -2847,6 +2776,229 @@ class AppProvider with ChangeNotifier {
]); ]);
} }
Future<void> refreshChannelLocationSharingState() async {
if (!connectionProvider.deviceInfo.isConnected) {
return;
}
final vars = await connectionProvider.getCustomVars();
final hardwareSupported = _supportsHardwareChannelLocationSharing(vars);
final rawChannelIdx = hardwareSupported
? int.tryParse(vars['fast_gps_channel'] ?? '')
: null;
final normalizedChannelIdx = rawChannelIdx != null && rawChannelIdx > 0
? rawChannelIdx
: null;
if (_hardwareChannelLocationSharingSupported == hardwareSupported &&
_hardwareChannelLocationSharingChannelIdx == normalizedChannelIdx) {
return;
}
_hardwareChannelLocationSharingSupported = hardwareSupported;
_hardwareChannelLocationSharingChannelIdx = normalizedChannelIdx;
notifyListeners();
}
ChannelLocationSharingMode? channelLocationSharingModeForChannel(
int channelIdx,
) {
if (channelIdx <= 0) {
return null;
}
if (_hardwareChannelLocationSharingChannelIdx == channelIdx) {
return ChannelLocationSharingMode.hardware;
}
if (_isAppFallbackLocationSharingActiveForChannel(channelIdx)) {
return ChannelLocationSharingMode.appFallback;
}
return null;
}
void notifyChannelLocationSharingChanged() {
notifyListeners();
}
bool _isAppFallbackLocationSharingActiveForChannel(int channelIdx) {
return locationTrackingService.fastLocationUpdatesEnabled &&
locationTrackingService.fastLocationChannelIdx == channelIdx;
}
bool _supportsHardwareChannelLocationSharing(Map<String, String> vars) {
return vars.containsKey('gps') && vars.containsKey('fast_gps_channel');
}
int? _hardwareChannelLocationSharingIdxFromVars(Map<String, String> vars) {
final rawChannelIdx = int.tryParse(vars['fast_gps_channel'] ?? '');
return rawChannelIdx != null && rawChannelIdx > 0 ? rawChannelIdx : null;
}
Future<void> _setDeviceCustomVarOrThrow(String key, String value) async {
connectionProvider.clearError();
await connectionProvider.setCustomVar(key, value);
final error = connectionProvider.error;
if (error != null) {
connectionProvider.clearError();
throw StateError(error);
}
}
Future<ChannelLocationSharingState> getChannelLocationSharingState(
int channelIdx,
) async {
if (channelIdx <= 0) {
return const ChannelLocationSharingState(
mode: ChannelLocationSharingMode.appFallback,
isSharing: false,
hardwareSupported: false,
isConnected: false,
);
}
if (connectionProvider.deviceInfo.isConnected) {
await refreshChannelLocationSharingState();
}
final sharingMode = channelLocationSharingModeForChannel(channelIdx);
return ChannelLocationSharingState(
mode: sharingMode ??
(_hardwareChannelLocationSharingSupported
? ChannelLocationSharingMode.hardware
: ChannelLocationSharingMode.appFallback),
isSharing: sharingMode != null,
hardwareSupported: _hardwareChannelLocationSharingSupported,
isConnected: connectionProvider.deviceInfo.isConnected,
);
}
Future<ChannelLocationSharingResult> setChannelLocationSharingEnabled(
int channelIdx,
bool enabled,
) async {
if (channelIdx <= 0) {
throw ArgumentError.value(
channelIdx,
'channelIdx',
'Location sharing requires a non-public channel',
);
}
if (!connectionProvider.deviceInfo.isConnected) {
throw StateError('Connect to a device first');
}
final vars = await connectionProvider.getCustomVars();
final hardwareSupported = _supportsHardwareChannelLocationSharing(vars);
if (enabled) {
if (hardwareSupported) {
await _setDeviceCustomVarOrThrow('gps', '1');
await _setDeviceCustomVarOrThrow(
'fast_gps_channel',
channelIdx.toString(),
);
_hardwareChannelLocationSharingSupported = true;
_hardwareChannelLocationSharingChannelIdx = channelIdx;
await locationTrackingService.updateFastLocationChannelIdx(null);
await locationTrackingService.setFastLocationUpdatesEnabled(false);
await refreshChannelLocationSharingState();
if (_hardwareChannelLocationSharingChannelIdx != channelIdx) {
throw StateError('Radio location sharing did not enable for this channel');
}
notifyListeners();
return const ChannelLocationSharingResult(
state: ChannelLocationSharingState(
mode: ChannelLocationSharingMode.hardware,
isSharing: true,
hardwareSupported: true,
isConnected: true,
),
message: 'Sharing location on this channel from the radio.',
);
}
_hardwareChannelLocationSharingSupported = false;
_hardwareChannelLocationSharingChannelIdx = null;
final previousEnabled = locationTrackingService.fastLocationUpdatesEnabled;
final previousChannelIdx = locationTrackingService.fastLocationChannelIdx;
try {
await locationTrackingService.updateFastLocationChannelIdx(channelIdx);
await locationTrackingService.setFastLocationUpdatesEnabled(true);
if (!locationTrackingService.isTracking) {
final started = await locationTrackingService.startTracking();
if (!started) {
throw StateError('Phone location tracking could not be started');
}
}
} catch (e) {
await locationTrackingService.updateFastLocationChannelIdx(
previousChannelIdx,
);
await locationTrackingService.setFastLocationUpdatesEnabled(
previousEnabled,
);
rethrow;
}
notifyListeners();
return const ChannelLocationSharingResult(
state: ChannelLocationSharingState(
mode: ChannelLocationSharingMode.appFallback,
isSharing: true,
hardwareSupported: false,
isConnected: true,
),
message: 'Sharing location on this channel from the phone.',
);
}
final activeHardwareChannelIdx = _hardwareChannelLocationSharingIdxFromVars(
vars,
);
final shouldAttemptHardwareStop =
hardwareSupported ||
_hardwareChannelLocationSharingSupported ||
activeHardwareChannelIdx == channelIdx ||
_hardwareChannelLocationSharingChannelIdx == channelIdx;
if (locationTrackingService.fastLocationChannelIdx == channelIdx) {
await locationTrackingService.updateFastLocationChannelIdx(null);
await locationTrackingService.setFastLocationUpdatesEnabled(false);
}
if (shouldAttemptHardwareStop) {
await _setDeviceCustomVarOrThrow('fast_gps_channel', '-1');
_hardwareChannelLocationSharingSupported = true;
_hardwareChannelLocationSharingChannelIdx = null;
await refreshChannelLocationSharingState();
if (_hardwareChannelLocationSharingChannelIdx == channelIdx) {
throw StateError('Radio location sharing is still enabled for this channel');
}
notifyListeners();
return const ChannelLocationSharingResult(
state: ChannelLocationSharingState(
mode: ChannelLocationSharingMode.hardware,
isSharing: false,
hardwareSupported: true,
isConnected: true,
),
message: 'Stopped sharing location on this channel.',
);
}
_hardwareChannelLocationSharingSupported = false;
_hardwareChannelLocationSharingChannelIdx = null;
notifyListeners();
return const ChannelLocationSharingResult(
state: ChannelLocationSharingState(
mode: ChannelLocationSharingMode.appFallback,
isSharing: false,
hardwareSupported: false,
isConnected: true,
),
message: 'Stopped sharing location on this channel.',
);
}
Future<void> _sendFastLocationUpdate( Future<void> _sendFastLocationUpdate(
dynamic position, { dynamic position, {
required String reason, required String reason,

View File

@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import '../models/message.dart'; import '../models/message.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../models/message_contact_location.dart'; import '../models/message_contact_location.dart';
@@ -10,6 +11,7 @@ import '../models/path_selection.dart';
import '../models/sar_marker.dart'; import '../models/sar_marker.dart';
import '../models/map_drawing.dart'; import '../models/map_drawing.dart';
import '../services/message_storage_service.dart'; import '../services/message_storage_service.dart';
import '../services/location_tracking_service.dart';
import '../services/notification_service.dart'; import '../services/notification_service.dart';
import '../utils/sar_message_parser.dart'; import '../utils/sar_message_parser.dart';
import '../utils/drawing_message_parser.dart'; import '../utils/drawing_message_parser.dart';
@@ -24,6 +26,7 @@ typedef DisplayMessageEntry = ({Message message, int occurrenceCount});
class MessagesProvider with ChangeNotifier { class MessagesProvider with ChangeNotifier {
static const Duration _channelEchoWarningDelay = Duration(seconds: 12); static const Duration _channelEchoWarningDelay = Duration(seconds: 12);
static const Duration _receivedDuplicateWindow = Duration(seconds: 5); static const Duration _receivedDuplicateWindow = Duration(seconds: 5);
static const Duration _deliveredRouteRefreshWindow = Duration(seconds: 30);
final List<Message> _messages = []; final List<Message> _messages = [];
final Map<String, SarMarker> _sarMarkers = {}; final Map<String, SarMarker> _sarMarkers = {};
@@ -38,6 +41,9 @@ class MessagesProvider with ChangeNotifier {
final Map<String, MessageReceptionDetails> _messageReceptionDetails = {}; final Map<String, MessageReceptionDetails> _messageReceptionDetails = {};
final Map<String, MessageTransferDetails> _messageTransferDetails = {}; final Map<String, MessageTransferDetails> _messageTransferDetails = {};
final Map<String, MessageRouteMetadata> _messageRouteMetadata = {}; final Map<String, MessageRouteMetadata> _messageRouteMetadata = {};
final Map<String, List<_PendingDeliveredRouteRefresh>>
_pendingDeliveredRouteRefreshByContact =
<String, List<_PendingDeliveredRouteRefresh>>{};
String? _storageNamespace; String? _storageNamespace;
// Track pending sent messages by expected ACK/TAG // Track pending sent messages by expected ACK/TAG
@@ -199,6 +205,83 @@ class MessagesProvider with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
void queueDeliveredMessageRouteRefresh(String messageId, Contact contact) {
if (messageId.isEmpty || contact.publicKeyHex.isEmpty) {
return;
}
_prunePendingDeliveredRouteRefresh();
final queue = _pendingDeliveredRouteRefreshByContact.putIfAbsent(
contact.publicKeyHex,
() => <_PendingDeliveredRouteRefresh>[],
);
queue.removeWhere((entry) => entry.messageId == messageId);
queue.add(
_PendingDeliveredRouteRefresh(
messageId: messageId,
queuedAt: DateTime.now(),
),
);
}
bool applyDeliveredMessageRouteFromContact(Contact contact) {
if (!contact.routeHasPath ||
contact.routeHopCount <= 0 ||
contact.publicKeyHex.isEmpty) {
return false;
}
_prunePendingDeliveredRouteRefresh();
final queue = _pendingDeliveredRouteRefreshByContact[contact.publicKeyHex];
if (queue == null || queue.isEmpty) {
return false;
}
while (queue.isNotEmpty) {
final pending = queue.removeAt(0);
final index = _messages.indexWhere(
(message) => message.id == pending.messageId,
);
if (index == -1) {
continue;
}
final message = _messages[index];
if (!message.isContactMessage ||
message.deliveryStatus != MessageDeliveryStatus.delivered) {
continue;
}
final existingMetadata = _messageRouteMetadata[pending.messageId];
_messageRouteMetadata[pending.messageId] = MessageRouteMetadata(
mode: PathSelectionMode.directCurrent,
routerFallbackAttempted:
existingMetadata?.routerFallbackAttempted ?? false,
canonicalPath: contact.routeCanonicalText.isEmpty
? null
: contact.routeCanonicalText,
hopCount: contact.routeHopCount,
);
_messages[index] = message.copyWith(
pathLen: contact.routeHopCount,
usedFloodFallback: false,
);
if (queue.isEmpty) {
_pendingDeliveredRouteRefreshByContact.remove(contact.publicKeyHex);
}
_persistMessages();
notifyListeners();
return true;
}
if (queue.isEmpty) {
_pendingDeliveredRouteRefreshByContact.remove(contact.publicKeyHex);
}
return false;
}
/// Set localizations for notifications /// Set localizations for notifications
void setLocalizations(AppLocalizations localizations) { void setLocalizations(AppLocalizations localizations) {
_localizations = localizations; _localizations = localizations;
@@ -1727,12 +1810,17 @@ class MessagesProvider with ChangeNotifier {
} }
} }
enhancedMessage = _resolveSenderNameIfNeeded(enhancedMessage); enhancedMessage = _resolveSenderNameIfNeeded(enhancedMessage);
final senderLocationSnapshot = _buildSentMessageLocationSnapshot();
// Check for duplicates (shouldn't happen for sent messages, but be safe) // Check for duplicates (shouldn't happen for sent messages, but be safe)
if (_findDuplicateMessageIndex(enhancedMessage) != -1) { if (_findDuplicateMessageIndex(enhancedMessage) != -1) {
debugPrint( debugPrint(
'⚠️ [MessagesProvider] Duplicate sent message detected, skipping: ${enhancedMessage.id}', '⚠️ [MessagesProvider] Duplicate sent message detected, skipping: ${enhancedMessage.id}',
); );
if (senderLocationSnapshot != null) {
_messageContactLocations[enhancedMessage.id] = senderLocationSnapshot;
_persistMessages();
}
return; return;
} }
@@ -1742,6 +1830,9 @@ class MessagesProvider with ChangeNotifier {
isRead: true, // Sent messages are always marked as read isRead: true, // Sent messages are always marked as read
); );
_messages.add(sendingMessage); _messages.add(sendingMessage);
if (senderLocationSnapshot != null) {
_messageContactLocations[sendingMessage.id] = senderLocationSnapshot;
}
debugPrint(' ✅ Message added to list at index ${_messages.length - 1}'); debugPrint(' ✅ Message added to list at index ${_messages.length - 1}');
debugPrint(' Total messages in list: ${_messages.length}'); debugPrint(' Total messages in list: ${_messages.length}');
@@ -1769,6 +1860,20 @@ class MessagesProvider with ChangeNotifier {
debugPrint(' ✅ notifyListeners() called - UI should update'); debugPrint(' ✅ notifyListeners() called - UI should update');
} }
MessageContactLocation? _buildSentMessageLocationSnapshot() {
final position = LocationTrackingService().currentPosition;
if (position == null) {
return null;
}
return MessageContactLocation(
location: LatLng(position.latitude, position.longitude),
source: 'gps',
capturedAt: DateTime.now(),
sourceTimestamp: position.timestamp,
);
}
/// Register an individual message ID as part of a grouped message /// Register an individual message ID as part of a grouped message
void registerGroupedMessageSend( void registerGroupedMessageSend(
String individualMessageId, String individualMessageId,
@@ -2555,6 +2660,7 @@ class MessagesProvider with ChangeNotifier {
_pendingSentMessages.remove(message.expectedAckTag); _pendingSentMessages.remove(message.expectedAckTag);
} }
_clearAckHistoryForMessage(messageId); _clearAckHistoryForMessage(messageId);
_removePendingDeliveredRouteRefresh(messageId);
// Clear retry tracking // Clear retry tracking
_retryManager.clearRetry(messageId); _retryManager.clearRetry(messageId);
@@ -2595,6 +2701,7 @@ class MessagesProvider with ChangeNotifier {
_pendingSentMessages.remove(message.expectedAckTag); _pendingSentMessages.remove(message.expectedAckTag);
} }
_clearAckHistoryForMessage(messageId); _clearAckHistoryForMessage(messageId);
_removePendingDeliveredRouteRefresh(messageId);
_retryManager.clearRetry(messageId); _retryManager.clearRetry(messageId);
_messageRouteMetadata.remove(messageId); _messageRouteMetadata.remove(messageId);
onManualRetryPreparedCallback?.call(messageId); onManualRetryPreparedCallback?.call(messageId);
@@ -2767,4 +2874,51 @@ class MessagesProvider with ChangeNotifier {
} }
} }
} }
void _prunePendingDeliveredRouteRefresh() {
if (_pendingDeliveredRouteRefreshByContact.isEmpty) {
return;
}
final cutoff = DateTime.now().subtract(_deliveredRouteRefreshWindow);
final emptyKeys = <String>[];
for (final entry in _pendingDeliveredRouteRefreshByContact.entries) {
entry.value.removeWhere(
(pending) => pending.queuedAt.isBefore(cutoff),
);
if (entry.value.isEmpty) {
emptyKeys.add(entry.key);
}
}
for (final key in emptyKeys) {
_pendingDeliveredRouteRefreshByContact.remove(key);
}
}
void _removePendingDeliveredRouteRefresh(String messageId) {
if (_pendingDeliveredRouteRefreshByContact.isEmpty) {
return;
}
final emptyKeys = <String>[];
for (final entry in _pendingDeliveredRouteRefreshByContact.entries) {
entry.value.removeWhere((pending) => pending.messageId == messageId);
if (entry.value.isEmpty) {
emptyKeys.add(entry.key);
}
}
for (final key in emptyKeys) {
_pendingDeliveredRouteRefreshByContact.remove(key);
}
}
}
class _PendingDeliveredRouteRefresh {
final String messageId;
final DateTime queuedAt;
const _PendingDeliveredRouteRefresh({
required this.messageId,
required this.queuedAt,
});
} }

View File

@@ -0,0 +1,541 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import '../models/map_layer.dart';
import '../services/offline_tile_cache_service.dart';
import '../services/tile_download_service.dart';
import '../services/tile_math_service.dart';
import '../services/tile_sharing_service.dart';
export '../services/tile_sharing_service.dart' show TilePeer, PeerCatalog;
export '../services/offline_tile_cache_service.dart'
show StyleInfo, DownloadRegion;
/// Download progress state.
class DownloadProgress {
final int downloaded;
final int skipped;
final int failed;
final int total;
const DownloadProgress({
this.downloaded = 0,
this.skipped = 0,
this.failed = 0,
this.total = 0,
});
int get processed => downloaded + skipped + failed;
double get percent => total == 0 ? 0 : processed / total;
bool get isComplete => total > 0 && processed >= total;
}
/// A downloaded/skipped tile rectangle for map overlay.
class TileOverlay {
final double north, south, east, west;
final bool isSkipped;
const TileOverlay({
required this.north,
required this.south,
required this.east,
required this.west,
this.isSkipped = false,
});
}
/// Drawing mode for polygon selection.
enum DrawingMode { none, polygon, rectangle }
/// State management for offline tile downloading.
class OfflineTilesProvider extends ChangeNotifier {
final OfflineTileCacheService _cache = OfflineTileCacheService.instance;
final TileSharingService _sharing = TileSharingService.instance;
TileDownloadService? _downloadService;
StreamSubscription<TileDownloadEvent>? _downloadSubscription;
StreamSubscription<Set<TilePeer>>? _peersSubscription;
// Drawing state
DrawingMode _drawingMode = DrawingMode.none;
final List<List<LatLng>> _polygons = [];
List<LatLng> _currentVertices = [];
LatLng? _rectangleFirstCorner;
// Download settings
int _minZoom = 8;
int _maxZoom = 14;
MapLayer _selectedLayer = MapLayer.openStreetMap;
// Download progress
bool _isDownloading = false;
DownloadProgress _progress = const DownloadProgress();
final List<TileOverlay> _tileOverlays = [];
// Cache info
int _cacheSizeBytes = 0;
// Sharing state
bool _isServerRunning = false;
Set<TilePeer> _discoveredPeers = {};
List<PeerCatalog> _peerCatalogs = [];
bool _isFetchingCatalogs = false;
bool _isSyncing = false;
String _syncStatus = '';
double _syncProgress = 0;
// Local style info
List<StyleInfo> _localStyles = [];
// Coverage overlay — which cached style's tiles to show on the map
StyleInfo? _coverageStyle;
List<TileOverlay> _coverageOverlays = [];
// Getters
DrawingMode get drawingMode => _drawingMode;
List<List<LatLng>> get polygons => List.unmodifiable(_polygons);
List<LatLng> get currentVertices => List.unmodifiable(_currentVertices);
LatLng? get rectangleFirstCorner => _rectangleFirstCorner;
int get minZoom => _minZoom;
int get maxZoom => _maxZoom;
MapLayer get selectedLayer => _selectedLayer;
bool get isDownloading => _isDownloading;
DownloadProgress get progress => _progress;
List<TileOverlay> get tileOverlays => _tileOverlays;
int get cacheSizeBytes => _cacheSizeBytes;
bool get hasPolygons => _polygons.isNotEmpty;
bool get isServerRunning => _isServerRunning;
Set<TilePeer> get discoveredPeers => _discoveredPeers;
List<PeerCatalog> get peerCatalogs => _peerCatalogs;
bool get isFetchingCatalogs => _isFetchingCatalogs;
bool get isSyncing => _isSyncing;
String get syncStatus => _syncStatus;
double get syncProgress => _syncProgress;
List<StyleInfo> get localStyles => _localStyles;
StyleInfo? get coverageStyle => _coverageStyle;
List<TileOverlay> get coverageOverlays => _coverageOverlays;
/// Estimated tile count for the current selection.
int get estimatedTileCount {
if (_polygons.isEmpty) return 0;
return TileMathService.estimateTileCount(_polygons, _minZoom, _maxZoom);
}
// Drawing methods
void setDrawingMode(DrawingMode mode) {
_drawingMode = mode;
_currentVertices = [];
_rectangleFirstCorner = null;
notifyListeners();
}
void addVertex(LatLng point) {
if (_drawingMode == DrawingMode.polygon) {
_currentVertices = [..._currentVertices, point];
notifyListeners();
} else if (_drawingMode == DrawingMode.rectangle) {
if (_rectangleFirstCorner == null) {
_rectangleFirstCorner = point;
notifyListeners();
} else {
// Complete rectangle
final corner1 = _rectangleFirstCorner!;
final corner2 = point;
final rect = [
LatLng(corner1.latitude, corner1.longitude),
LatLng(corner1.latitude, corner2.longitude),
LatLng(corner2.latitude, corner2.longitude),
LatLng(corner2.latitude, corner1.longitude),
];
_polygons.add(rect);
_rectangleFirstCorner = null;
_drawingMode = DrawingMode.none;
notifyListeners();
}
}
}
void finishPolygon() {
if (_drawingMode == DrawingMode.polygon && _currentVertices.length >= 3) {
_polygons.add(List.from(_currentVertices));
_currentVertices = [];
_drawingMode = DrawingMode.none;
notifyListeners();
}
}
void removePolygon(int index) {
if (index >= 0 && index < _polygons.length) {
_polygons.removeAt(index);
notifyListeners();
}
}
void clearPolygons() {
_polygons.clear();
_currentVertices = [];
_rectangleFirstCorner = null;
_drawingMode = DrawingMode.none;
notifyListeners();
}
void undoLastVertex() {
if (_currentVertices.isNotEmpty) {
_currentVertices = _currentVertices.sublist(0, _currentVertices.length - 1);
notifyListeners();
}
}
// Download settings
void setMinZoom(int zoom) {
_minZoom = zoom.clamp(0, 19);
if (_maxZoom < _minZoom) _maxZoom = _minZoom;
notifyListeners();
}
void setMaxZoom(int zoom) {
_maxZoom = zoom.clamp(0, 19);
if (_minZoom > _maxZoom) _minZoom = _maxZoom;
notifyListeners();
}
void setSelectedLayer(MapLayer layer) {
_selectedLayer = layer;
notifyListeners();
}
// Download control
Future<void> startDownload() async {
if (_isDownloading || _polygons.isEmpty) return;
_isDownloading = true;
_progress = const DownloadProgress();
_tileOverlays.clear();
notifyListeners();
_downloadService = TileDownloadService();
final stream = _downloadService!.downloadTiles(
polygons: _polygons,
minZoom: _minZoom,
maxZoom: _maxZoom,
urlTemplate: _selectedLayer.urlTemplate,
displayName: _selectedLayer.name,
);
await for (final event in stream) {
switch (event) {
case TileDownloadStarted(:final totalTiles):
_progress = DownloadProgress(total: totalTiles);
notifyListeners();
case TileDownloaded(:final north, :final south, :final east, :final west):
_progress = DownloadProgress(
downloaded: _progress.downloaded + 1,
skipped: _progress.skipped,
failed: _progress.failed,
total: _progress.total,
);
_addOverlay(TileOverlay(
north: north, south: south, east: east, west: west,
));
notifyListeners();
case TileSkipped():
_progress = DownloadProgress(
downloaded: _progress.downloaded,
skipped: _progress.skipped + 1,
failed: _progress.failed,
total: _progress.total,
);
notifyListeners();
case TileBatchSkipped(:final count):
_progress = DownloadProgress(
downloaded: _progress.downloaded,
skipped: _progress.skipped + count,
failed: _progress.failed,
total: _progress.total,
);
notifyListeners();
case TileFailed():
_progress = DownloadProgress(
downloaded: _progress.downloaded,
skipped: _progress.skipped,
failed: _progress.failed + 1,
total: _progress.total,
);
notifyListeners();
case TileDownloadComplete():
_isDownloading = false;
notifyListeners();
case TileDownloadCancelled():
_isDownloading = false;
notifyListeners();
}
}
_isDownloading = false;
_downloadService?.dispose();
_downloadService = null;
notifyListeners();
}
void cancelDownload() {
_downloadService?.cancel();
}
void clearOverlays() {
_tileOverlays.clear();
notifyListeners();
}
void _addOverlay(TileOverlay overlay) {
_tileOverlays.add(overlay);
// Limit overlays to prevent OOM
if (_tileOverlays.length > 500) {
_tileOverlays.removeRange(0, _tileOverlays.length - 500);
}
}
// Cache management
Future<void> refreshCacheSize() async {
_cacheSizeBytes = await _cache.getCacheSize();
notifyListeners();
}
Future<void> deleteStyle(StyleInfo style) async {
if (_coverageStyle?.hash == style.hash) hideCoverage();
await _cache.deleteStyle(style.hash);
await refreshCacheSize();
await refreshLocalStyles();
}
Future<void> clearCache() async {
hideCoverage();
await _cache.clearCache();
_cacheSizeBytes = 0;
_localStyles = [];
notifyListeners();
}
// Coverage overlay — show cached tile bounds on the map
/// Show the coverage of a cached style on the map.
/// Loads tile coordinates from the manifest and converts to bounds.
Future<void> showCoverage(StyleInfo style) async {
if (_coverageStyle?.hash == style.hash) {
// Toggle off if same style tapped again
hideCoverage();
return;
}
_coverageStyle = style;
_coverageOverlays = [];
notifyListeners();
final manifest = await _cache.loadManifest(style.hash);
// Convert manifest keys to tile bound overlays
final overlays = <TileOverlay>[];
for (final key in manifest) {
final parts = key.split('/');
if (parts.length != 3) continue;
final z = int.tryParse(parts[0]);
final x = int.tryParse(parts[1]);
final y = int.tryParse(parts[2]);
if (z == null || x == null || y == null) continue;
final bounds = TileMathService.tileBounds(x, y, z);
overlays.add(TileOverlay(
north: bounds.north,
south: bounds.south,
east: bounds.east,
west: bounds.west,
isSkipped: true, // green color
));
}
_coverageOverlays = overlays;
notifyListeners();
}
void hideCoverage() {
_coverageStyle = null;
_coverageOverlays = [];
notifyListeners();
}
// Sharing controls
Future<void> toggleServer() async {
if (_isServerRunning) {
await _sharing.stopServer();
_isServerRunning = false;
} else {
await _sharing.startServer();
_isServerRunning = _sharing.isRunning;
}
notifyListeners();
}
Future<void> startPeerDiscovery() async {
_peersSubscription?.cancel();
_peersSubscription = _sharing.peersStream.listen((peers) {
_discoveredPeers = peers;
notifyListeners();
});
await _sharing.startDiscovery();
}
Future<void> stopPeerDiscovery() async {
_peersSubscription?.cancel();
_peersSubscription = null;
await _sharing.stopPeerDiscovery();
_discoveredPeers = {};
notifyListeners();
}
void addManualPeer(String ipAddress) {
_sharing.addManualPeer(ipAddress);
_discoveredPeers = _sharing.discoveredPeers;
notifyListeners();
}
void removePeer(TilePeer peer) {
_sharing.removePeer(peer);
_discoveredPeers = _sharing.discoveredPeers;
notifyListeners();
}
// Peer catalog & P2P sync
/// Refresh local style info.
Future<void> refreshLocalStyles() async {
_localStyles = await _cache.listStylesDetailed();
notifyListeners();
}
/// Fetch catalogs from all discovered peers to see what they have.
Future<void> refreshPeerCatalogs() async {
_isFetchingCatalogs = true;
notifyListeners();
_peerCatalogs = await _sharing.fetchAllPeerCatalogs();
_isFetchingCatalogs = false;
notifyListeners();
}
/// Sync a style from one or more peers that have it.
/// Finds all peers offering [styleHash] and pulls missing tiles.
Future<void> syncStyleFromPeers(StyleInfo style) async {
if (_isSyncing) return;
// Find all peers that have this style
final peersWithStyle = <TilePeer>[];
for (final catalog in _peerCatalogs) {
if (catalog.styles.any((s) => s.hash == style.hash)) {
peersWithStyle.add(catalog.peer);
}
}
if (peersWithStyle.isEmpty) return;
_isSyncing = true;
_syncStatus = 'Starting sync of ${style.displayName}...';
_syncProgress = 0;
notifyListeners();
final stream = _sharing.syncStyleFromPeers(
peers: peersWithStyle,
styleHash: style.hash,
styleMeta: style,
);
await for (final event in stream) {
switch (event) {
case PeerSyncStarted(:final totalTiles):
_syncStatus = 'Syncing ${style.displayName}: 0/$totalTiles tiles';
_syncProgress = 0;
notifyListeners();
case PeerSyncTileDownloaded(:final downloaded, :final total):
_syncStatus =
'Syncing ${style.displayName}: $downloaded/$total tiles';
_syncProgress = total > 0 ? downloaded / total : 0;
notifyListeners();
case PeerSyncTileSkipped(:final skipped, :final total):
_syncProgress = total > 0 ? skipped / total : 0;
notifyListeners();
case PeerSyncComplete(:final downloaded, :final skipped, :final failed):
_syncStatus =
'Done! $downloaded new, $skipped cached, $failed failed';
_isSyncing = false;
notifyListeners();
await refreshCacheSize();
await refreshLocalStyles();
case PeerSyncCancelled():
_syncStatus = 'Sync cancelled';
_isSyncing = false;
notifyListeners();
}
}
}
void cancelSync() {
_sharing.cancelSync();
}
// Presets — load a previously downloaded region for quick re-download
/// Load a saved download region as the current selection.
/// Restores polygons, zoom range, and map layer.
void loadPreset(StyleInfo style) {
if (style.region == null) return;
final region = style.region!;
// Restore polygons
_polygons.clear();
for (final polyData in region.polygons) {
final poly = polyData.map((v) => LatLng(v[0], v[1])).toList();
if (poly.length >= 3) _polygons.add(poly);
}
// Restore zoom range
_minZoom = region.minZoom;
_maxZoom = region.maxZoom;
// Try to find the matching map layer
if (style.urlTemplate.isNotEmpty) {
final matchingLayer = MapLayer.allLayers.where(
(l) => l.urlTemplate == style.urlTemplate,
);
if (matchingLayer.isNotEmpty) {
_selectedLayer = matchingLayer.first;
}
}
_currentVertices = [];
_rectangleFirstCorner = null;
_drawingMode = DrawingMode.none;
notifyListeners();
}
@override
void dispose() {
_downloadSubscription?.cancel();
_downloadService?.dispose();
_peersSubscription?.cancel();
super.dispose();
}
}

View File

@@ -516,12 +516,89 @@ class _ContactsTabState extends State<ContactsTab> {
messenger.showSnackBar(SnackBar(content: Text(copiedMessage))); messenger.showSnackBar(SnackBar(content: Text(copiedMessage)));
} }
String _locationSharingErrorMessage(Object error) {
final message = error.toString();
if (message.startsWith('Bad state: ')) {
return message.substring('Bad state: '.length);
}
return message;
}
Future<void> _setChannelLocationSharing(
BuildContext context,
Contact channel,
bool enabled,
) async {
final channelIdx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0;
try {
final result = await context.read<AppProvider>().setChannelLocationSharingEnabled(
channelIdx,
enabled,
);
if (!context.mounted) return;
ToastLogger.success(context, result.message);
} catch (error) {
if (!context.mounted) return;
ToastLogger.error(context, _locationSharingErrorMessage(error));
}
}
Future<void> _handleChannelLocationSharingAction(
BuildContext context,
Contact channel,
) async {
if (channel.isPublicChannel) {
if (!context.mounted) return;
ToastLogger.error(
context,
'Select a private channel to share your location.',
);
return;
}
final channelIdx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0;
if (channelIdx <= 0) {
if (!context.mounted) return;
ToastLogger.error(
context,
'Select a private channel to share your location.',
);
return;
}
try {
final sharingState = await context
.read<AppProvider>()
.getChannelLocationSharingState(channelIdx);
if (!context.mounted) return;
await _setChannelLocationSharing(context, channel, !sharingState.isSharing);
} catch (error) {
if (!context.mounted) return;
ToastLogger.error(context, _locationSharingErrorMessage(error));
}
}
void _showChannelActionSheet(BuildContext context, Contact channel) { void _showChannelActionSheet(BuildContext context, Contact channel) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final canExportHashChannelPsk = Channel.isHashChannelName( final canExportHashChannelPsk = Channel.isHashChannelName(
channel.advName.trim(), channel.advName.trim(),
); );
final actions = <_ChannelSheetAction>[ final channelLocationSharingFuture =
!channel.isPublicChannel && channel.publicKey.length > 1
? context
.read<AppProvider>()
.getChannelLocationSharingState(channel.publicKey[1])
: null;
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) {
List<_ChannelSheetAction> buildActions(
ChannelLocationSharingState? sharingState,
) {
return <_ChannelSheetAction>[
_ChannelSheetAction( _ChannelSheetAction(
icon: Icons.message_outlined, icon: Icons.message_outlined,
label: l10n.messages, label: l10n.messages,
@@ -530,6 +607,19 @@ class _ContactsTabState extends State<ContactsTab> {
await _openMessagesForChannel(context, channel); await _openMessagesForChannel(context, channel);
}, },
), ),
if (!channel.isPublicChannel)
_ChannelSheetAction(
icon: sharingState?.isSharing == true
? Icons.location_off_rounded
: Icons.share_location_rounded,
label: sharingState?.isSharing == true
? 'Stop sharing my location'
: 'Share my location',
onTap: () async {
Navigator.pop(context);
await _handleChannelLocationSharingAction(context, channel);
},
),
if (channel.displayLocation != null) if (channel.displayLocation != null)
_ChannelSheetAction( _ChannelSheetAction(
icon: Icons.map_outlined, icon: Icons.map_outlined,
@@ -573,16 +663,27 @@ class _ContactsTabState extends State<ContactsTab> {
}, },
), ),
]; ];
}
showModalBottomSheet<void>( Widget buildSheet(ChannelLocationSharingState? sharingState) {
context: context, return _ChannelActionSheet(
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) => _ChannelActionSheet(
channel: channel, channel: channel,
actions: actions, actions: buildActions(sharingState),
onClose: () => Navigator.pop(sheetContext), onClose: () => Navigator.pop(sheetContext),
), );
}
if (channelLocationSharingFuture == null) {
return buildSheet(null);
}
return FutureBuilder<ChannelLocationSharingState>(
future: channelLocationSharingFuture,
builder: (context, snapshot) {
return buildSheet(snapshot.data);
},
);
},
); );
} }
@@ -1820,6 +1921,27 @@ class _ChannelActivityCard extends StatelessWidget {
return l10n.daysAgo(diff.inDays); return l10n.daysAgo(diff.inDays);
} }
Widget _buildChannelLocationSharingMarker(
BuildContext context,
ChannelLocationSharingMode mode,
) {
const accent = Color(0xFF16A34A);
return Container(
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
color: accent,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
child: Icon(
Icons.location_pin,
size: 11,
color: Colors.white,
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
@@ -1828,6 +1950,9 @@ class _ChannelActivityCard extends StatelessWidget {
letterSpacing: -0.3, letterSpacing: -0.3,
); );
final channelIdx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0; final channelIdx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0;
final channelLocationSharingMode = context
.watch<AppProvider>()
.channelLocationSharingModeForChannel(channelIdx);
final channelMessages = messagesProvider.getMessagesForChannel(channelIdx) final channelMessages = messagesProvider.getMessagesForChannel(channelIdx)
..sort((a, b) => b.sentAt.compareTo(a.sentAt)); ..sort((a, b) => b.sentAt.compareTo(a.sentAt));
final lastActivityAt = messagesProvider.getLastActivityForDestination( final lastActivityAt = messagesProvider.getLastActivityForDestination(
@@ -1869,8 +1994,22 @@ class _ChannelActivityCard extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [
Stack(
clipBehavior: Clip.none,
children: [ children: [
ContactAvatar(contact: channel, radius: 24), ContactAvatar(contact: channel, radius: 24),
if (channelLocationSharingMode != null)
Positioned(
right: -2,
bottom: -2,
child: _buildChannelLocationSharingMarker(
context,
channelLocationSharingMode,
),
),
],
),
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(
child: Column( child: Column(

View File

@@ -190,6 +190,15 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
bool _repeatEnabled = false; bool _repeatEnabled = false;
bool? _gpsEnabled; // null = not supported by hardware bool? _gpsEnabled; // null = not supported by hardware
bool _gpsLoading = false; bool _gpsLoading = false;
bool? _buzzerEnabled;
bool _buzzerLoading = false;
bool? _gpsHasFix;
int? _gpsSatelliteCount;
int? _gpsLatE6;
int? _gpsLonE6;
int? _gpsLastFixAgeSeconds;
DateTime? _gpsStatsLoadedAt;
Timer? _gpsStatsTicker;
bool _isSyncingDeviceTime = false; bool _isSyncingDeviceTime = false;
int? _selectedPathHashMode; int? _selectedPathHashMode;
bool _autoAddDiscoveredContactsEnabled = true; bool _autoAddDiscoveredContactsEnabled = true;
@@ -235,6 +244,10 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
super.initState(); super.initState();
_connectionProvider = context.read<ConnectionProvider>(); _connectionProvider = context.read<ConnectionProvider>();
_connectionProvider.addListener(_handleConnectionProviderChanged); _connectionProvider.addListener(_handleConnectionProviderChanged);
_gpsStatsTicker = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted || _gpsLastFixAgeSeconds == null) return;
setState(() {});
});
final deviceInfo = _connectionProvider.deviceInfo; final deviceInfo = _connectionProvider.deviceInfo;
_nameController = TextEditingController( _nameController = TextEditingController(
@@ -329,6 +342,7 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
_txPowerController.dispose(); _txPowerController.dispose();
_gpsIntervalController.dispose(); _gpsIntervalController.dispose();
_autoAddMaxHopsController.dispose(); _autoAddMaxHopsController.dispose();
_gpsStatsTicker?.cancel();
super.dispose(); super.dispose();
} }
@@ -493,6 +507,66 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
.toString(); .toString();
} }
int? _currentGpsLastFixAgeSeconds() {
final baseAgeSeconds = _gpsLastFixAgeSeconds;
final loadedAt = _gpsStatsLoadedAt;
if (baseAgeSeconds == null || loadedAt == null) {
return null;
}
final elapsedSeconds = DateTime.now().difference(loadedAt).inSeconds;
if (elapsedSeconds <= 0) {
return baseAgeSeconds;
}
return baseAgeSeconds + elapsedSeconds;
}
String _formatGpsFixValue() {
if (_gpsEnabled == false) {
return 'Off';
}
if (_gpsHasFix == true) {
return 'Locked';
}
if (_gpsEnabled == true) {
return 'Searching';
}
return 'Unavailable';
}
String _formatGpsLocationValue() {
final latE6 = _gpsLatE6;
final lonE6 = _gpsLonE6;
if (latE6 == null || lonE6 == null) {
return 'No fix yet';
}
return '${(latE6 / 1000000).toStringAsFixed(6)}, ${(lonE6 / 1000000).toStringAsFixed(6)}';
}
String _formatGpsLastFixValue(AppLocalizations l10n) {
final ageSeconds = _currentGpsLastFixAgeSeconds();
if (ageSeconds == null) {
return 'Never';
}
if (ageSeconds <= 0) {
return l10n.justNow;
}
if (ageSeconds < 60) {
return l10n.secondsAgo(ageSeconds);
}
final ageMinutes = ageSeconds ~/ 60;
if (ageMinutes < 60) {
return l10n.minutesAgo(ageMinutes);
}
final ageHours = ageMinutes ~/ 60;
if (ageHours < 24) {
return l10n.hoursAgo(ageHours);
}
return l10n.daysAgo(ageHours ~/ 24);
}
int _telemetryModesForSave() { int _telemetryModesForSave() {
return (_environmentTelemetryMode << 4) | return (_environmentTelemetryMode << 4) |
(_locationTelemetryMode << 2) | (_locationTelemetryMode << 2) |
@@ -746,17 +820,49 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
if (!mounted) return; if (!mounted) return;
final gpsValue = vars['gps']; final gpsValue = vars['gps'];
final gpsIntervalValue = vars['gps_interval']; final gpsIntervalValue = vars['gps_interval'];
final gpsFixValue = vars['gps_fix'];
final gpsSatsValue = vars['gps_sats'];
final gpsLatValue = vars['gps_lat_e6'];
final gpsLonValue = vars['gps_lon_e6'];
final gpsLastFixAgeValue = vars['gps_last_fix_age_s'];
final buzzerValue = vars['buzzer'];
setState(() { setState(() {
_gpsEnabled = gpsValue != null ? gpsValue == '1' : null; _gpsEnabled = gpsValue != null ? gpsValue == '1' : null;
_buzzerEnabled = buzzerValue != null ? buzzerValue == '1' : null;
if (gpsIntervalValue != null) { if (gpsIntervalValue != null) {
_gpsIntervalController.text = gpsIntervalValue; _gpsIntervalController.text = gpsIntervalValue;
} }
_gpsHasFix = gpsFixValue != null ? gpsFixValue == '1' : null;
_gpsSatelliteCount = int.tryParse(gpsSatsValue ?? '');
_gpsLatE6 = int.tryParse(gpsLatValue ?? '');
_gpsLonE6 = int.tryParse(gpsLonValue ?? '');
_gpsLastFixAgeSeconds = int.tryParse(gpsLastFixAgeValue ?? '');
_gpsStatsLoadedAt = DateTime.now();
}); });
} catch (_) { } catch (_) {
// Device may not support custom vars (old firmware / no GPS hardware) // Device may not support custom vars (old firmware / no GPS hardware)
} }
} }
Future<void> _setBuzzerMode(bool enabled) async {
setState(() => _buzzerLoading = true);
try {
await _connectionProvider.setCustomVar('buzzer', enabled ? '1' : '0');
if (!mounted) return;
setState(() {
_buzzerEnabled = enabled;
_buzzerLoading = false;
});
unawaited(_loadGpsMode());
} catch (e) {
if (!mounted) return;
setState(() => _buzzerLoading = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to set buzzer mode: $e')),
);
}
}
Future<void> _setGpsMode(bool enabled) async { Future<void> _setGpsMode(bool enabled) async {
setState(() => _gpsLoading = true); setState(() => _gpsLoading = true);
try { try {
@@ -766,6 +872,7 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
_gpsEnabled = enabled; _gpsEnabled = enabled;
_gpsLoading = false; _gpsLoading = false;
}); });
unawaited(_loadGpsMode());
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
setState(() => _gpsLoading = false); setState(() => _gpsLoading = false);
@@ -1663,6 +1770,42 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
onChanged: _setGpsMode, onChanged: _setGpsMode,
), ),
), ),
const SizedBox(height: 12),
_GpsDiagnosticsCard(
fixValue: _formatGpsFixValue(),
satellitesValue:
_gpsSatelliteCount?.toString() ?? '',
locationValue: _formatGpsLocationValue(),
lastFixValue: _formatGpsLastFixValue(
AppLocalizations.of(context)!,
),
),
],
if (_buzzerEnabled != null) ...[
const SizedBox(height: 12),
_SettingHighlightCard(
icon: _buzzerEnabled!
? Icons.volume_up_rounded
: Icons.volume_off_rounded,
title: 'Buzzer alerts',
description:
'Enable or mute the onboard buzzer for radio alerts.',
accentColor: _buzzerEnabled!
? colorScheme.primary
: colorScheme.onSurfaceVariant,
trailing: _buzzerLoading
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: Switch(
value: _buzzerEnabled!,
onChanged: _setBuzzerMode,
),
),
], ],
const SizedBox(height: 16), const SizedBox(height: 16),
TextField( TextField(
@@ -2860,6 +3003,115 @@ class _SettingHighlightCard extends StatelessWidget {
} }
} }
class _GpsDiagnosticsCard extends StatelessWidget {
final String fixValue;
final String satellitesValue;
final String locationValue;
final String lastFixValue;
const _GpsDiagnosticsCard({
required this.fixValue,
required this.satellitesValue,
required this.locationValue,
required this.lastFixValue,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(22),
border: Border.all(color: colorScheme.outlineVariant),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.location_searching_rounded,
color: colorScheme.primary,
size: 18,
),
const SizedBox(width: 8),
Text(
'GPS diagnostics',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w800,
),
),
],
),
const SizedBox(height: 12),
_GpsDiagnosticsRow(label: 'Fix', value: fixValue),
const SizedBox(height: 8),
_GpsDiagnosticsRow(label: 'Satellites', value: satellitesValue),
const SizedBox(height: 8),
_GpsDiagnosticsRow(label: 'Last fix', value: lastFixValue),
const SizedBox(height: 8),
_GpsDiagnosticsRow(
label: 'Location',
value: locationValue,
maxLines: 2,
),
],
),
);
}
}
class _GpsDiagnosticsRow extends StatelessWidget {
final String label;
final String value;
final int maxLines;
const _GpsDiagnosticsRow({
required this.label,
required this.value,
this.maxLines = 1,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 84,
child: Text(
label,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
value,
maxLines: maxLines,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w700,
),
),
),
],
);
}
}
class _RadioPreset { class _RadioPreset {
final String id; final String id;
final String label; final String label;

View File

@@ -43,6 +43,8 @@ import '../widgets/messages/sar_update_sheet.dart';
import '../utils/key_comparison.dart'; import '../utils/key_comparison.dart';
import '../utils/sar_message_parser.dart'; import '../utils/sar_message_parser.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../services/offline_map_caching_provider.dart';
import 'offline_map_screen.dart';
class MapTab extends StatefulWidget { class MapTab extends StatefulWidget {
final Function(bool)? onFullscreenChanged; final Function(bool)? onFullscreenChanged;
@@ -61,10 +63,12 @@ class MapTab extends StatefulWidget {
class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin { class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
final MapController _mapController = MapController(); final MapController _mapController = MapController();
static final TileProvider _tileProvider = NetworkTileProvider( static final TileProvider _tileProvider = NetworkTileProvider(
cachingProvider: BuiltInMapCachingProvider.getOrCreateInstance( cachingProvider: OfflineMapCachingProvider(
BuiltInMapCachingProvider.getOrCreateInstance(
maxCacheSize: 10_000_000_000, maxCacheSize: 10_000_000_000,
overrideFreshAge: const Duration(days: 365), overrideFreshAge: const Duration(days: 365),
), ),
),
); );
// DO NOT create a new LocationTrackingService instance here // DO NOT create a new LocationTrackingService instance here
// Use the singleton from AppProvider instead via _locationService getter // Use the singleton from AppProvider instead via _locationService getter
@@ -3229,6 +3233,17 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
child: const Icon(Icons.layers), child: const Icon(Icons.layers),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
FloatingActionButton.small(
heroTag: 'offline_maps',
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const OfflineMapScreen(),
),
),
child: const Icon(Icons.download_for_offline),
),
const SizedBox(height: 8),
FloatingActionButton.small( FloatingActionButton.small(
heroTag: 'fullscreen_toggle', heroTag: 'fullscreen_toggle',
onPressed: () { onPressed: () {

View File

@@ -1746,7 +1746,85 @@ class _MessagesTabState extends State<MessagesTab> {
await action(); await action();
} }
int? _selectedPrivateChannelIdx() {
if (_destinationType !=
MessageDestinationPreferences.destinationTypeChannel) {
return null;
}
final recipient = _selectedRecipient;
if (recipient == null ||
recipient.isPublicChannel ||
recipient.publicKey.length < 2) {
return null;
}
return recipient.publicKey[1];
}
String _locationSharingErrorMessage(Object error) {
final message = error.toString();
if (message.startsWith('Bad state: ')) {
return message.substring('Bad state: '.length);
}
return message;
}
Future<void> _setSelectedChannelLocationSharing(
int channelIdx,
bool enabled,
) async {
try {
final result = await context.read<AppProvider>().setChannelLocationSharingEnabled(
channelIdx,
enabled,
);
if (!mounted) return;
ToastLogger.success(context, result.message);
} catch (error) {
if (!mounted) return;
ToastLogger.error(context, _locationSharingErrorMessage(error));
}
}
Future<void> _handleChannelLocationSharingAction() async {
final recipient = _selectedRecipient;
if (recipient == null || recipient.isPublicChannel) {
if (!mounted) return;
ToastLogger.error(
context,
'Select a private channel to share your location.',
);
return;
}
final channelIdx = recipient.publicKey.length > 1 ? recipient.publicKey[1] : 0;
if (channelIdx <= 0) {
if (!mounted) return;
ToastLogger.error(
context,
'Select a private channel to share your location.',
);
return;
}
try {
final sharingState = await context
.read<AppProvider>()
.getChannelLocationSharingState(channelIdx);
if (!mounted) return;
await _setSelectedChannelLocationSharing(channelIdx, !sharingState.isSharing);
} catch (error) {
if (!mounted) return;
ToastLogger.error(context, _locationSharingErrorMessage(error));
}
}
void _showComposerActions() { void _showComposerActions() {
final privateChannelIdx = _selectedPrivateChannelIdx();
final locationSharingStateFuture = privateChannelIdx == null
? null
: context
.read<AppProvider>()
.getChannelLocationSharingState(privateChannelIdx);
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
showDragHandle: true, showDragHandle: true,
@@ -1759,6 +1837,7 @@ class _MessagesTabState extends State<MessagesTab> {
await _runAfterSheetDismissal(sheetContext, action); await _runAfterSheetDismissal(sheetContext, action);
} }
Widget buildActionsSheet(ChannelLocationSharingState? sharingState) {
final actions = <Widget>[ final actions = <Widget>[
_ComposerActionTile( _ComposerActionTile(
icon: Icons.search_rounded, icon: Icons.search_rounded,
@@ -1776,6 +1855,20 @@ class _MessagesTabState extends State<MessagesTab> {
_showSarDialog(); _showSarDialog();
}), }),
), ),
if (_destinationType ==
MessageDestinationPreferences.destinationTypeChannel)
_ComposerActionTile(
icon: sharingState?.isSharing == true
? Icons.location_off_rounded
: Icons.share_location_rounded,
title: sharingState?.isSharing == true
? 'Stop sharing my location'
: 'Share my location',
color: const Color(0xFF0F766E),
onTap: () => runAction(() async {
await _handleChannelLocationSharingAction();
}),
),
if (_voiceSupported) if (_voiceSupported)
_ComposerActionTile( _ComposerActionTile(
icon: _isRecording ? Icons.stop_rounded : Icons.mic_rounded, icon: _isRecording ? Icons.stop_rounded : Icons.mic_rounded,
@@ -1861,13 +1954,26 @@ class _MessagesTabState extends State<MessagesTab> {
const SizedBox(height: 16), const SizedBox(height: 16),
for (var index = 0; index < actions.length; index++) ...[ for (var index = 0; index < actions.length; index++) ...[
actions[index], actions[index],
if (index != actions.length - 1) const SizedBox(height: 8), if (index != actions.length - 1)
const SizedBox(height: 8),
], ],
], ],
), ),
), ),
), ),
); );
}
if (locationSharingStateFuture == null) {
return buildActionsSheet(null);
}
return FutureBuilder<ChannelLocationSharingState>(
future: locationSharingStateFuture,
builder: (context, snapshot) {
return buildActionsSheet(snapshot.data);
},
);
}, },
); );
} }
@@ -2027,10 +2133,43 @@ class _MessagesTabState extends State<MessagesTab> {
Widget _buildDestinationAvatar(BuildContext context) { Widget _buildDestinationAvatar(BuildContext context) {
final recipient = _selectedRecipient; final recipient = _selectedRecipient;
if (recipient != null) { if (recipient != null) {
return ContactAvatar( final sharingMode =
recipient.isChannel &&
!recipient.isPublicChannel &&
recipient.publicKey.length > 1
? context.watch<AppProvider>().channelLocationSharingModeForChannel(
recipient.publicKey[1],
)
: null;
return Stack(
clipBehavior: Clip.none,
children: [
ContactAvatar(
contact: recipient, contact: recipient,
radius: 14, radius: 14,
displayName: _getDestinationLabel(), displayName: _getDestinationLabel(),
),
if (sharingMode != null)
Positioned(
right: -3,
bottom: -3,
child: Container(
width: 14,
height: 14,
decoration: BoxDecoration(
color: const Color(0xFF16A34A),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 1.5),
),
alignment: Alignment.center,
child: Icon(
Icons.location_pin,
size: 9,
color: Colors.white,
),
),
),
],
); );
} }

View File

@@ -0,0 +1,842 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
import '../models/map_layer.dart';
import '../providers/offline_tiles_provider.dart';
import '../widgets/map/polygon_draw_handler.dart';
/// Screen for downloading offline map tiles.
///
/// Allows drawing polygons/rectangles to select areas, choosing zoom levels,
/// and downloading tiles with real-time progress visualization.
class OfflineMapScreen extends StatefulWidget {
const OfflineMapScreen({super.key});
@override
State<OfflineMapScreen> createState() => _OfflineMapScreenState();
}
class _OfflineMapScreenState extends State<OfflineMapScreen> {
final MapController _mapController = MapController();
late OfflineTilesProvider _provider;
double _currentZoom = 8;
@override
void initState() {
super.initState();
_provider = context.read<OfflineTilesProvider>();
_provider.refreshCacheSize();
_provider.refreshLocalStyles();
_provider.startPeerDiscovery();
}
@override
void dispose() {
_provider.stopPeerDiscovery();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Offline Maps'),
actions: [
Consumer<OfflineTilesProvider>(
builder: (context, provider, _) {
return IconButton(
icon: Badge(
isLabelVisible: provider.discoveredPeers.isNotEmpty,
label: Text('${provider.discoveredPeers.length}'),
child: Icon(
provider.isServerRunning
? Icons.wifi_tethering
: Icons.wifi_tethering_off,
),
),
tooltip: provider.isServerRunning
? 'Sharing tiles'
: 'Tile sharing off',
onPressed: () => _showSharingSheet(context, provider),
);
},
),
Consumer<OfflineTilesProvider>(
builder: (context, provider, _) {
return PopupMenuButton<MapLayer>(
icon: const Icon(Icons.layers),
tooltip: 'Map Style',
onSelected: (layer) => provider.setSelectedLayer(layer),
itemBuilder: (_) => [
for (final layer in MapLayer.allLayers)
PopupMenuItem(
value: layer,
child: Row(
children: [
if (layer.type == provider.selectedLayer.type)
const Icon(Icons.check, size: 18)
else
const SizedBox(width: 18),
const SizedBox(width: 8),
Text(layer.name),
],
),
),
],
);
},
),
],
),
body: Stack(
children: [
Consumer<OfflineTilesProvider>(
builder: (context, provider, _) {
return FlutterMap(
mapController: _mapController,
options: MapOptions(
initialCenter: const LatLng(46.05, 14.5), // Slovenia
initialZoom: 8,
onPositionChanged: (camera, hasGesture) {
if (camera.zoom != _currentZoom) {
setState(() => _currentZoom = camera.zoom);
}
},
onTap: (tapPosition, point) {
if (provider.drawingMode != DrawingMode.none) {
provider.addVertex(point);
}
},
),
children: [
TileLayer(
urlTemplate: provider.selectedLayer.urlTemplate,
userAgentPackageName: 'com.meshcore.sar',
maxZoom: provider.selectedLayer.maxZoom,
),
CoverageLayer(currentZoom: _currentZoom),
const PolygonDrawLayer(),
const DownloadProgressLayer(),
],
);
},
),
const DrawingToolbar(),
Positioned(
left: 16,
top: 16,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.85),
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 4,
),
],
),
child: Text(
'Z ${_currentZoom.toStringAsFixed(1)}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
),
_buildBottomPanel(),
],
),
);
}
Widget _buildBottomPanel() {
return Positioned(
left: 0,
right: 0,
bottom: 0,
child: Consumer<OfflineTilesProvider>(
builder: (context, provider, _) {
return Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius:
const BorderRadius.vertical(top: Radius.circular(16)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 10,
offset: const Offset(0, -2),
),
],
),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Saved region presets dropdown
if (!provider.isDownloading &&
provider.localStyles
.any((s) => s.region != null)) ...[
DropdownButtonFormField<StyleInfo>(
decoration: const InputDecoration(
prefixIcon: Icon(Icons.bookmark, size: 20),
labelText: 'Saved regions',
isDense: true,
contentPadding: EdgeInsets.symmetric(
horizontal: 12, vertical: 8),
border: OutlineInputBorder(),
),
isExpanded: true,
hint: const Text('Load a saved region'),
items: provider.localStyles
.where((s) => s.region != null)
.map((style) => DropdownMenuItem(
value: style,
child: Text(
'${style.displayName} '
'(z${style.region!.minZoom}-${style.region!.maxZoom}, '
'${_formatNumber(style.tileCount)} tiles)',
overflow: TextOverflow.ellipsis,
),
))
.toList(),
onChanged: (style) {
if (style != null) {
provider.loadPreset(style);
_fitMapToPolygons(provider);
}
},
),
const SizedBox(height: 8),
],
// Zoom range
if (!provider.isDownloading) ...[
Row(
children: [
Expanded(
child: _ZoomSelector(
label: 'Min Zoom',
value: provider.minZoom,
onChanged: provider.setMinZoom,
),
),
const SizedBox(width: 16),
Expanded(
child: _ZoomSelector(
label: 'Max Zoom',
value: provider.maxZoom,
onChanged: provider.setMaxZoom,
),
),
],
),
const SizedBox(height: 8),
// Tile estimate
Text(
provider.hasPolygons
? '~${_formatNumber(provider.estimatedTileCount)} tiles'
: 'Draw an area to download',
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
Text(
'Cache: ${_formatBytes(provider.cacheSizeBytes)}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.6),
),
textAlign: TextAlign.center,
),
],
// Progress
if (provider.isDownloading) ...[
LinearProgressIndicator(
value: provider.progress.percent,
),
const SizedBox(height: 8),
Text(
'${(provider.progress.percent * 100).toStringAsFixed(1)}% — '
'Downloaded: ${provider.progress.downloaded}, '
'Cached: ${provider.progress.skipped}, '
'Failed: ${provider.progress.failed} / '
'${provider.progress.total}',
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
),
],
// Download complete summary
if (!provider.isDownloading &&
provider.progress.isComplete) ...[
const SizedBox(height: 4),
Text(
'Done! ${provider.progress.downloaded} downloaded, '
'${provider.progress.skipped} cached, '
'${provider.progress.failed} failed',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.green,
),
textAlign: TextAlign.center,
),
],
const SizedBox(height: 12),
// Action buttons
Row(
children: [
if (!provider.isDownloading) ...[
Expanded(
child: FilledButton.icon(
onPressed: provider.hasPolygons
? () => provider.startDownload()
: null,
icon: const Icon(Icons.download),
label: const Text('Download'),
),
),
if (provider.tileOverlays.isNotEmpty) ...[
const SizedBox(width: 8),
IconButton(
onPressed: provider.clearOverlays,
icon: const Icon(Icons.layers_clear),
tooltip: 'Clear overlay',
),
],
if (provider.cacheSizeBytes > 0) ...[
const SizedBox(width: 8),
IconButton(
onPressed: () => _confirmClearCache(provider),
icon: const Icon(Icons.delete_forever),
tooltip: 'Clear cache',
),
],
],
if (provider.isDownloading) ...[
Expanded(
child: OutlinedButton.icon(
onPressed: () => provider.cancelDownload(),
icon: const Icon(Icons.cancel),
label: const Text('Cancel'),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.red,
),
),
),
],
],
),
],
),
),
),
);
},
),
);
}
void _showSharingSheet(BuildContext context, OfflineTilesProvider provider) {
// Refresh catalogs and local styles when opening
provider.refreshPeerCatalogs();
provider.refreshLocalStyles();
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (sheetContext) {
return DraggableScrollableSheet(
initialChildSize: 0.65,
minChildSize: 0.3,
maxChildSize: 0.9,
expand: false,
builder: (context, scrollController) {
return Consumer<OfflineTilesProvider>(
builder: (context, provider, _) {
return ListView(
controller: scrollController,
padding: const EdgeInsets.all(16),
children: [
// Header
Center(
child: Container(
width: 32,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.grey[400],
borderRadius: BorderRadius.circular(2),
),
),
),
Text(
'Tile Sharing',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
// Server toggle
SwitchListTile(
title: const Text('Share my tiles'),
subtitle: Text(
provider.isServerRunning
? 'Other devices can fetch tiles from this device'
: 'Start serving cached tiles to nearby devices',
),
secondary: Icon(
provider.isServerRunning
? Icons.wifi_tethering
: Icons.wifi_tethering_off,
),
value: provider.isServerRunning,
onChanged: (_) => provider.toggleServer(),
),
// My cached maps
if (provider.localStyles.isNotEmpty) ...[
const Divider(),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
'My Cached Maps',
style: Theme.of(context).textTheme.titleSmall,
),
),
...provider.localStyles.map((style) {
final isShowing =
provider.coverageStyle?.hash == style.hash;
return ListTile(
dense: true,
leading: const Icon(Icons.map, size: 20),
title: Text(style.displayName),
subtitle: Text(
'${_formatNumber(style.tileCount)} tiles, '
'${_formatBytes(style.sizeBytes)}',
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(
isShowing
? Icons.visibility
: Icons.visibility_off,
size: 20,
color: isShowing
? Colors.blue
: null,
),
tooltip: isShowing
? 'Hide on map'
: 'Show on map',
onPressed: () {
provider.showCoverage(style);
},
),
IconButton(
icon: const Icon(Icons.delete_outline,
size: 20),
tooltip: 'Delete',
onPressed: () => _confirmDeleteStyle(
context, provider, style),
),
],
),
);
}),
],
// Peers section
const Divider(),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Expanded(
child: Text(
'Nearby Devices',
style: Theme.of(context).textTheme.titleSmall,
),
),
if (provider.isFetchingCatalogs)
const SizedBox(
width: 16,
height: 16,
child:
CircularProgressIndicator(strokeWidth: 2),
)
else
IconButton(
icon: const Icon(Icons.refresh, size: 20),
tooltip: 'Refresh',
onPressed: () =>
provider.refreshPeerCatalogs(),
),
IconButton(
icon: const Icon(Icons.add, size: 20),
tooltip: 'Add peer manually',
onPressed: () =>
_showAddPeerDialog(context, provider),
),
],
),
),
if (provider.discoveredPeers.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
'No peers found on the local network.\n'
'Make sure other devices have tile sharing enabled.',
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.6),
),
),
),
// Peer catalogs — show each peer and their available styles
...provider.peerCatalogs.map((catalog) => _buildPeerCard(
context, provider, catalog)),
// Peers without catalogs yet (just discovered, not queried)
...provider.discoveredPeers
.where((peer) => !provider.peerCatalogs
.any((c) => c.peer == peer))
.map((peer) => ListTile(
leading: const Icon(Icons.devices),
title: Text(peer.ipAddress),
subtitle: const Text('Fetching catalog...'),
trailing: IconButton(
icon: const Icon(
Icons.remove_circle_outline,
size: 20),
onPressed: () =>
provider.removePeer(peer),
),
)),
// Sync progress
if (provider.isSyncing || provider.syncStatus.isNotEmpty) ...[
const Divider(),
if (provider.isSyncing)
LinearProgressIndicator(
value: provider.syncProgress > 0
? provider.syncProgress
: null,
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Expanded(
child: Text(
provider.syncStatus,
style: Theme.of(context).textTheme.bodySmall,
),
),
if (provider.isSyncing)
TextButton(
onPressed: () => provider.cancelSync(),
child: const Text('Cancel'),
),
],
),
),
],
],
);
},
);
},
);
},
);
}
Widget _buildPeerCard(
BuildContext context,
OfflineTilesProvider provider,
PeerCatalog catalog,
) {
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.devices, size: 18),
const SizedBox(width: 8),
Expanded(
child: Text(
catalog.peer.ipAddress,
style: Theme.of(context).textTheme.titleSmall,
),
),
IconButton(
icon: const Icon(Icons.remove_circle_outline, size: 18),
onPressed: () => provider.removePeer(catalog.peer),
visualDensity: VisualDensity.compact,
),
],
),
if (catalog.styles.isEmpty)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'No cached tiles on this device',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.6),
),
),
),
...catalog.styles.map((style) {
// Check if we already have this style locally
final localMatch = provider.localStyles
.where((s) => s.hash == style.hash);
final localCount =
localMatch.isNotEmpty ? localMatch.first.tileCount : 0;
final missingTiles = style.tileCount - localCount;
return ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.map_outlined, size: 20),
title: Text(style.displayName),
subtitle: Text(
'${_formatNumber(style.tileCount)} tiles, '
'${_formatBytes(style.sizeBytes)}'
'${localCount > 0 ? ' (you have ${_formatNumberInline(localCount)})' : ''}',
),
trailing: missingTiles > 0
? TextButton.icon(
onPressed: provider.isSyncing
? null
: () => provider.syncStyleFromPeers(style),
icon: const Icon(Icons.download, size: 16),
label: Text(
missingTiles == style.tileCount
? 'Get all'
: '+${_formatNumber(missingTiles)}',
),
)
: const Icon(Icons.check_circle,
color: Colors.green, size: 20),
);
}),
],
),
),
);
}
String _formatNumberInline(int n) {
if (n < 1000) return '$n';
if (n < 1000000) return '${(n / 1000).toStringAsFixed(1)}K';
return '${(n / 1000000).toStringAsFixed(1)}M';
}
void _fitMapToPolygons(OfflineTilesProvider provider) {
if (provider.polygons.isEmpty) return;
var minLat = 90.0, maxLat = -90.0;
var minLng = 180.0, maxLng = -180.0;
for (final poly in provider.polygons) {
for (final p in poly) {
if (p.latitude < minLat) minLat = p.latitude;
if (p.latitude > maxLat) maxLat = p.latitude;
if (p.longitude < minLng) minLng = p.longitude;
if (p.longitude > maxLng) maxLng = p.longitude;
}
}
_mapController.fitCamera(
CameraFit.bounds(
bounds: LatLngBounds(
LatLng(minLat, minLng),
LatLng(maxLat, maxLng),
),
padding: const EdgeInsets.all(50),
),
);
}
void _showAddPeerDialog(
BuildContext context, OfflineTilesProvider provider) {
final controller = TextEditingController();
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Add Peer'),
content: TextField(
controller: controller,
decoration: const InputDecoration(
labelText: 'IP Address',
hintText: '192.168.1.100',
),
keyboardType: TextInputType.number,
autofocus: true,
onSubmitted: (value) {
if (value.trim().isNotEmpty) {
provider.addManualPeer(value.trim());
Navigator.pop(context);
}
},
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
final ip = controller.text.trim();
if (ip.isNotEmpty) {
provider.addManualPeer(ip);
Navigator.pop(context);
}
},
child: const Text('Add'),
),
],
),
);
}
void _confirmDeleteStyle(
BuildContext context,
OfflineTilesProvider provider,
StyleInfo style,
) {
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text('Delete ${style.displayName}?'),
content: Text(
'${_formatNumber(style.tileCount)} tiles, '
'${_formatBytes(style.sizeBytes)} will be deleted.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
provider.deleteStyle(style);
Navigator.pop(dialogContext);
},
child:
const Text('Delete', style: TextStyle(color: Colors.red)),
),
],
),
);
}
void _confirmClearCache(OfflineTilesProvider provider) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear offline cache?'),
content: Text(
'This will delete ${_formatBytes(provider.cacheSizeBytes)} of cached tiles.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
provider.clearCache();
Navigator.pop(context);
},
child:
const Text('Delete', style: TextStyle(color: Colors.red)),
),
],
),
);
}
String _formatBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
if (bytes < 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
}
String _formatNumber(int n) {
if (n < 1000) return '$n';
if (n < 1000000) return '${(n / 1000).toStringAsFixed(1)}K';
return '${(n / 1000000).toStringAsFixed(1)}M';
}
}
class _ZoomSelector extends StatelessWidget {
final String label;
final int value;
final ValueChanged<int> onChanged;
const _ZoomSelector({
required this.label,
required this.value,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
Text(label, style: Theme.of(context).textTheme.bodySmall),
const SizedBox(width: 8),
Expanded(
child: Slider(
value: value.toDouble(),
min: 0,
max: 19,
divisions: 19,
label: '$value',
onChanged: (v) => onChanged(v.round()),
),
),
SizedBox(
width: 24,
child: Text(
'$value',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
),
],
);
}
}

View File

@@ -420,6 +420,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
Future<void> _setFastLocationUpdatesEnabled(bool enabled) async { Future<void> _setFastLocationUpdatesEnabled(bool enabled) async {
await _locationService.setFastLocationUpdatesEnabled(enabled); await _locationService.setFastLocationUpdatesEnabled(enabled);
if (!mounted) return; if (!mounted) return;
context.read<AppProvider>().notifyChannelLocationSharingChanged();
setState(() { setState(() {
_fastLocationUpdatesEnabled = _locationService.fastLocationUpdatesEnabled; _fastLocationUpdatesEnabled = _locationService.fastLocationUpdatesEnabled;
}); });
@@ -593,6 +594,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
selected < 0 ? null : selected, selected < 0 ? null : selected,
); );
if (!mounted) return; if (!mounted) return;
context.read<AppProvider>().notifyChannelLocationSharingChanged();
setState(() { setState(() {
_fastLocationChannelIdx = _locationService.fastLocationChannelIdx; _fastLocationChannelIdx = _locationService.fastLocationChannelIdx;
}); });
@@ -1486,19 +1488,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: _showRouteHashSizeDialog, onTap: _showRouteHashSizeDialog,
), ),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: Icon(Icons.swap_horiz),
title: Text(AppLocalizations.of(context)!.autoRouteRotation),
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>( Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile( builder: (context, appProvider, child) => SwitchListTile(
secondary: Icon(Icons.route), secondary: Icon(Icons.route),

View File

@@ -23,7 +23,6 @@ class AppConfigSnapshotService {
voiceEchoCancellationEnabled: appProvider.isVoiceEchoCancellationEnabled, voiceEchoCancellationEnabled: appProvider.isVoiceEchoCancellationEnabled,
voiceNoiseSuppressionEnabled: appProvider.isVoiceNoiseSuppressionEnabled, voiceNoiseSuppressionEnabled: appProvider.isVoiceNoiseSuppressionEnabled,
messageFontScale: appProvider.messageFontScale, messageFontScale: appProvider.messageFontScale,
autoRouteRotationEnabled: appProvider.autoRouteRotationEnabled,
clearPathOnMaxRetry: appProvider.clearPathOnMaxRetry, clearPathOnMaxRetry: appProvider.clearPathOnMaxRetry,
nearestRelayFallbackEnabled: appProvider.nearestRelayFallbackEnabled, nearestRelayFallbackEnabled: appProvider.nearestRelayFallbackEnabled,
voiceBitrate: await VoiceBitratePreferences.getBitrate(), voiceBitrate: await VoiceBitratePreferences.getBitrate(),
@@ -98,11 +97,6 @@ class AppConfigSnapshotService {
if (section.messageFontScale != null) { if (section.messageFontScale != null) {
await appProvider.setMessageFontScale(section.messageFontScale!); await appProvider.setMessageFontScale(section.messageFontScale!);
} }
if (section.autoRouteRotationEnabled != null) {
await appProvider.toggleAutoRouteRotationEnabled(
section.autoRouteRotationEnabled!,
);
}
if (section.clearPathOnMaxRetry != null) { if (section.clearPathOnMaxRetry != null) {
await appProvider.toggleClearPathOnMaxRetry(section.clearPathOnMaxRetry!); await appProvider.toggleClearPathOnMaxRetry(section.clearPathOnMaxRetry!);
} }

View File

@@ -2,30 +2,20 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'profiles_feature_service.dart'; import 'profiles_feature_service.dart';
class MessagingRoutePreferences { class MessagingRoutePreferences {
static const bool defaultAutoRouteRotationEnabled = false;
static const bool defaultClearPathOnMaxRetry = false; static const bool defaultClearPathOnMaxRetry = false;
static const bool defaultNearestRelayFallbackEnabled = true; static const bool defaultNearestRelayFallbackEnabled = true;
static const String _autoRouteRotationKey = static const String _legacyAutoRouteRotationKey =
'messaging_auto_route_rotation_enabled'; 'messaging_auto_route_rotation_enabled';
static const String _clearPathOnMaxRetryKey = static const String _clearPathOnMaxRetryKey =
'messaging_clear_path_on_max_retry'; 'messaging_clear_path_on_max_retry';
static const String _nearestRelayFallbackKey = static const String _nearestRelayFallbackKey =
'messaging_nearest_relay_fallback_enabled'; 'messaging_nearest_relay_fallback_enabled';
static Future<bool> getAutoRouteRotationEnabled() async { static Future<void> cleanupLegacySettings() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
return prefs.getBool( await prefs.remove(
ProfileStorageScope.scopedKey(_autoRouteRotationKey), ProfileStorageScope.scopedKey(_legacyAutoRouteRotationKey),
) ??
defaultAutoRouteRotationEnabled;
}
static Future<void> setAutoRouteRotationEnabled(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(
ProfileStorageScope.scopedKey(_autoRouteRotationKey),
enabled,
); );
} }

View File

@@ -0,0 +1,115 @@
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:flutter_map/flutter_map.dart';
import 'offline_tile_cache_service.dart';
import 'tile_sharing_service.dart';
/// A [MapCachingProvider] that checks the offline AVIF tile cache (and
/// optionally peers) before falling through to the built-in cache.
///
/// This allows preloaded tiles to be served during normal map browsing.
class OfflineMapCachingProvider implements MapCachingProvider {
final MapCachingProvider _delegate;
final OfflineTileCacheService _cache = OfflineTileCacheService.instance;
final TileSharingService _sharing = TileSharingService.instance;
OfflineMapCachingProvider(this._delegate);
@override
bool get isSupported => true;
@override
Future<CachedMapTile?> getTile(String url) async {
// Extract tile coordinates from URL to check our AVIF cache
final coords = _parseTileUrl(url);
if (coords != null) {
final styleHash = _cache.styleHashFromUrl(_extractUrlTemplate(url));
// Check local AVIF cache first
final pngBytes = await _cache.getTileAsPng(
styleHash, coords.z, coords.x, coords.y);
if (pngBytes != null) {
return (
bytes: pngBytes,
metadata: CachedMapTileMetadata(
staleAt: DateTime.now().add(const Duration(days: 365)),
lastModified: null,
etag: null,
),
);
}
// Try peers
if (_sharing.discoveredPeers.isNotEmpty) {
final avifBytes = await _sharing.fetchFromAnyPeer(
styleHash, coords.z, coords.x, coords.y);
if (avifBytes != null) {
// Cache locally for next time
await _cache.putRawTile(
styleHash, coords.z, coords.x, coords.y, avifBytes);
final decoded = await OfflineTileCacheService.getTileAsPngStatic(avifBytes);
if (decoded != null) {
return (
bytes: decoded,
metadata: CachedMapTileMetadata(
staleAt: DateTime.now().add(const Duration(days: 365)),
lastModified: null,
etag: null,
),
);
}
}
}
}
// Fall through to delegate (built-in cache)
return _delegate.getTile(url);
}
@override
Future<void> putTile({
required String url,
required CachedMapTileMetadata metadata,
Uint8List? bytes,
}) {
// Only delegate to built-in cache for normal browsing tiles
return _delegate.putTile(url: url, metadata: metadata, bytes: bytes);
}
/// Parse z/x/y from a tile URL.
static _TileCoords? _parseTileUrl(String url) {
// Match common patterns: /{z}/{x}/{y}.png, /tile/{z}/{y}/{x}, etc.
final patterns = [
RegExp(r'/(\d+)/(\d+)/(\d+)\.(?:png|jpg|jpeg|webp)'),
RegExp(r'/(\d+)/(\d+)/(\d+)$'),
];
for (final pattern in patterns) {
final match = pattern.firstMatch(url);
if (match != null) {
return _TileCoords(
z: int.parse(match.group(1)!),
x: int.parse(match.group(2)!),
y: int.parse(match.group(3)!),
);
}
}
return null;
}
/// Extract a URL template from a concrete URL by replacing coordinates.
static String _extractUrlTemplate(String url) {
// Replace the last three numeric path segments with placeholders
return url.replaceAllMapped(
RegExp(r'/(\d+)/(\d+)/(\d+)(\.(?:png|jpg|jpeg|webp))?$'),
(m) => '/{z}/{x}/{y}${m.group(4) ?? ''}',
);
}
}
class _TileCoords {
final int z, x, y;
const _TileCoords({required this.z, required this.x, required this.y});
}

View File

@@ -0,0 +1,481 @@
import 'dart:convert';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_avif/flutter_avif.dart';
import 'package:path_provider/path_provider.dart';
/// A saved download region (polygons + zoom range) for quick re-download.
class DownloadRegion {
final List<List<List<double>>> polygons; // [polygon][vertex][lat, lng]
final int minZoom;
final int maxZoom;
const DownloadRegion({
required this.polygons,
required this.minZoom,
required this.maxZoom,
});
Map<String, dynamic> toJson() => {
'polygons': polygons,
'minZoom': minZoom,
'maxZoom': maxZoom,
};
factory DownloadRegion.fromJson(Map<String, dynamic> json) {
final rawPolygons = json['polygons'] as List<dynamic>? ?? [];
final polygons = rawPolygons.map<List<List<double>>>((poly) {
return (poly as List<dynamic>).map<List<double>>((vertex) {
final v = vertex as List<dynamic>;
return [
(v[0] as num).toDouble(),
(v[1] as num).toDouble(),
];
}).toList();
}).toList();
return DownloadRegion(
polygons: polygons,
minZoom: json['minZoom'] as int? ?? 8,
maxZoom: json['maxZoom'] as int? ?? 14,
);
}
}
/// Metadata about a cached map style.
class StyleInfo {
final String hash;
final String displayName;
final String urlTemplate;
final int tileCount;
final int sizeBytes;
final DownloadRegion? region;
const StyleInfo({
required this.hash,
required this.displayName,
required this.urlTemplate,
this.tileCount = 0,
this.sizeBytes = 0,
this.region,
});
Map<String, dynamic> toJson() => {
'hash': hash,
'displayName': displayName,
'urlTemplate': urlTemplate,
'tileCount': tileCount,
'sizeBytes': sizeBytes,
if (region != null) 'region': region!.toJson(),
};
factory StyleInfo.fromJson(Map<String, dynamic> json) => StyleInfo(
hash: json['hash'] as String,
displayName: json['displayName'] as String? ?? json['hash'] as String,
urlTemplate: json['urlTemplate'] as String? ?? '',
tileCount: json['tileCount'] as int? ?? 0,
sizeBytes: json['sizeBytes'] as int? ?? 0,
region: json['region'] != null
? DownloadRegion.fromJson(json['region'] as Map<String, dynamic>)
: null,
);
}
/// A tile coordinate in the cache (z/x/y).
class CachedTileCoord {
final int z, x, y;
const CachedTileCoord(this.z, this.x, this.y);
Map<String, int> toJson() => {'z': z, 'x': x, 'y': y};
factory CachedTileCoord.fromJson(Map<String, dynamic> json) =>
CachedTileCoord(
json['z'] as int,
json['x'] as int,
json['y'] as int,
);
}
/// Manages the offline AVIF tile cache on disk.
///
/// Tiles are stored as `{baseDir}/offline_tiles/{styleHash}/{z}/{x}/{y}.avif`.
/// Style metadata is stored as `{baseDir}/offline_tiles/{styleHash}/meta.json`.
/// This cache is separate from flutter_map's built-in cache and is used for
/// proactively downloaded tiles and WiFi sharing.
class OfflineTileCacheService {
OfflineTileCacheService._();
static final instance = OfflineTileCacheService._();
String? _baseDir;
Future<String> get baseDir async {
if (_baseDir != null) return _baseDir!;
final docs = await getApplicationDocumentsDirectory();
_baseDir = '${docs.path}/offline_tiles';
return _baseDir!;
}
/// Derive a short deterministic hash from a URL template.
String styleHashFromUrl(String urlTemplate) {
final bytes = sha256.convert(urlTemplate.codeUnits).bytes;
return bytes
.take(6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
}
String _tilePath(String base, String styleHash, int z, int x, int y) {
return '$base/$styleHash/$z/$x/$y.avif';
}
String _tileDir(String base, String styleHash, int z, int x) {
return '$base/$styleHash/$z/$x';
}
// In-memory manifest cache: styleHash → set of "z/x/y" keys.
// Loaded lazily, kept in sync with writes.
final Map<String, Set<String>> _manifests = {};
static String _tileKey(int z, int x, int y) => '$z/$x/$y';
String _manifestPath(String base, String styleHash) =>
'$base/$styleHash/manifest.txt';
/// Load the manifest for a style into memory (if not already loaded).
Future<Set<String>> loadManifest(String styleHash) async {
if (_manifests.containsKey(styleHash)) return _manifests[styleHash]!;
final base = await baseDir;
final file = File(_manifestPath(base, styleHash));
final Set<String> manifest;
if (await file.exists()) {
final lines = await file.readAsLines();
manifest = lines.where((l) => l.isNotEmpty).toSet();
} else {
// First time — scan the filesystem and build the manifest
manifest = {};
final styleDir = Directory('$base/$styleHash');
if (await styleDir.exists()) {
final avifPattern = RegExp(r'/(\d+)/(\d+)/(\d+)\.avif$');
await for (final entity in styleDir.list(recursive: true)) {
if (entity is! File) continue;
final match = avifPattern.firstMatch(entity.path);
if (match != null) {
manifest.add('${match.group(1)}/${match.group(2)}/${match.group(3)}');
}
}
// Persist the scanned manifest
await _writeManifest(base, styleHash, manifest);
}
}
_manifests[styleHash] = manifest;
return manifest;
}
Future<void> _writeManifest(
String base, String styleHash, Set<String> manifest) async {
final dir = Directory('$base/$styleHash');
if (!await dir.exists()) await dir.create(recursive: true);
await File(_manifestPath(base, styleHash))
.writeAsString(manifest.join('\n'), flush: true);
}
/// Append a tile key to the manifest (both in-memory and on disk).
Future<void> _addToManifest(
String base, String styleHash, String key) async {
_manifests[styleHash] ??= {};
if (_manifests[styleHash]!.add(key)) {
final file = File(_manifestPath(base, styleHash));
await file.writeAsString('$key\n',
mode: FileMode.append, flush: true);
}
}
/// Check if a tile exists in the cache (uses in-memory manifest).
Future<bool> hasTile(String styleHash, int z, int x, int y) async {
final manifest = await loadManifest(styleHash);
return manifest.contains(_tileKey(z, x, y));
}
/// Read a cached tile's raw AVIF bytes (for serving to peers).
Future<Uint8List?> getRawTile(String styleHash, int z, int x, int y) async {
final base = await baseDir;
final file = File(_tilePath(base, styleHash, z, x, y));
if (!await file.exists()) return null;
return file.readAsBytes();
}
/// Read a cached tile and decode AVIF → PNG bytes for flutter_map display.
Future<Uint8List?> getTileAsPng(
String styleHash, int z, int x, int y) async {
final avifBytes = await getRawTile(styleHash, z, x, y);
if (avifBytes == null) return null;
return _avifToPng(avifBytes);
}
/// Store a tile: encode PNG bytes → AVIF, write to disk, update manifest.
Future<void> putTile(
String styleHash,
int z,
int x,
int y,
Uint8List pngBytes,
) async {
final base = await baseDir;
final dir = Directory(_tileDir(base, styleHash, z, x));
if (!await dir.exists()) {
await dir.create(recursive: true);
}
final avifBytes = await _pngToAvif(pngBytes);
if (avifBytes == null) {
await File(_tilePath(base, styleHash, z, x, y))
.writeAsBytes(pngBytes, flush: true);
} else {
await File(_tilePath(base, styleHash, z, x, y))
.writeAsBytes(avifBytes, flush: true);
}
await _addToManifest(base, styleHash, _tileKey(z, x, y));
}
/// Store raw AVIF bytes directly (from a peer), update manifest.
Future<void> putRawTile(
String styleHash,
int z,
int x,
int y,
Uint8List avifBytes,
) async {
final base = await baseDir;
final dir = Directory(_tileDir(base, styleHash, z, x));
if (!await dir.exists()) {
await dir.create(recursive: true);
}
await File(_tilePath(base, styleHash, z, x, y))
.writeAsBytes(avifBytes, flush: true);
await _addToManifest(base, styleHash, _tileKey(z, x, y));
}
/// Get total cache size in bytes.
Future<int> getCacheSize() async {
final base = await baseDir;
final dir = Directory(base);
if (!await dir.exists()) return 0;
var totalSize = 0;
await for (final entity in dir.list(recursive: true)) {
if (entity is File) {
totalSize += await entity.length();
}
}
return totalSize;
}
/// List all style hashes that have cached tiles.
Future<List<String>> listStyles() async {
final base = await baseDir;
final dir = Directory(base);
if (!await dir.exists()) return [];
final styles = <String>[];
await for (final entity in dir.list()) {
if (entity is Directory) {
styles.add(entity.path.split('/').last);
}
}
return styles;
}
/// Save metadata for a style (name, URL template, download region).
Future<void> saveStyleMeta(
String styleHash, {
required String displayName,
required String urlTemplate,
DownloadRegion? region,
}) async {
final base = await baseDir;
final dir = Directory('$base/$styleHash');
if (!await dir.exists()) {
await dir.create(recursive: true);
}
// Merge with existing meta to preserve region if not provided
final metaFile = File('$base/$styleHash/meta.json');
Map<String, dynamic> meta = {
'displayName': displayName,
'urlTemplate': urlTemplate,
};
if (region != null) {
meta['region'] = region.toJson();
} else if (await metaFile.exists()) {
try {
final existing = jsonDecode(await metaFile.readAsString());
if (existing['region'] != null) {
meta['region'] = existing['region'];
}
} catch (_) {}
}
await metaFile.writeAsString(jsonEncode(meta), flush: true);
}
/// Read metadata for a style.
Future<StyleInfo?> getStyleMeta(String styleHash) async {
final base = await baseDir;
final metaFile = File('$base/$styleHash/meta.json');
if (!await metaFile.exists()) return null;
try {
final json = jsonDecode(await metaFile.readAsString());
return StyleInfo(
hash: styleHash,
displayName: json['displayName'] as String? ?? styleHash,
urlTemplate: json['urlTemplate'] as String? ?? '',
region: json['region'] != null
? DownloadRegion.fromJson(json['region'] as Map<String, dynamic>)
: null,
);
} catch (_) {
return null;
}
}
/// List all styles with metadata, tile counts, and sizes.
Future<List<StyleInfo>> listStylesDetailed() async {
final base = await baseDir;
final dir = Directory(base);
if (!await dir.exists()) return [];
final results = <StyleInfo>[];
await for (final entity in dir.list()) {
if (entity is! Directory) continue;
final hash = entity.path.split('/').last;
// Read meta
String displayName = hash;
String urlTemplate = '';
DownloadRegion? region;
final metaFile = File('${entity.path}/meta.json');
if (await metaFile.exists()) {
try {
final json = jsonDecode(await metaFile.readAsString());
displayName = json['displayName'] as String? ?? hash;
urlTemplate = json['urlTemplate'] as String? ?? '';
if (json['region'] != null) {
region = DownloadRegion.fromJson(
json['region'] as Map<String, dynamic>);
}
} catch (_) {}
}
// Count tiles and size
var tileCount = 0;
var sizeBytes = 0;
await for (final file in entity.list(recursive: true)) {
if (file is File && file.path.endsWith('.avif')) {
tileCount++;
sizeBytes += await file.length();
}
}
results.add(StyleInfo(
hash: hash,
displayName: displayName,
urlTemplate: urlTemplate,
tileCount: tileCount,
sizeBytes: sizeBytes,
region: region,
));
}
return results;
}
/// List all tile coordinates cached for a given style.
Future<List<CachedTileCoord>> listTilesForStyle(String styleHash) async {
final base = await baseDir;
final styleDir = Directory('$base/$styleHash');
if (!await styleDir.exists()) return [];
final tiles = <CachedTileCoord>[];
final avifPattern = RegExp(r'/(\d+)/(\d+)/(\d+)\.avif$');
await for (final entity in styleDir.list(recursive: true)) {
if (entity is! File) continue;
final match = avifPattern.firstMatch(entity.path);
if (match != null) {
tiles.add(CachedTileCoord(
int.parse(match.group(1)!),
int.parse(match.group(2)!),
int.parse(match.group(3)!),
));
}
}
return tiles;
}
/// Delete a single style's tiles, manifest, and metadata.
Future<void> deleteStyle(String styleHash) async {
_manifests.remove(styleHash);
final base = await baseDir;
final dir = Directory('$base/$styleHash');
if (await dir.exists()) {
await dir.delete(recursive: true);
}
}
/// Delete all cached tiles, manifests, and metadata.
Future<void> clearCache() async {
_manifests.clear();
final base = await baseDir;
final dir = Directory(base);
if (await dir.exists()) {
await dir.delete(recursive: true);
}
}
/// Decode AVIF bytes to PNG (static helper for use by caching provider).
static Future<Uint8List?> getTileAsPngStatic(Uint8List avifBytes) {
return _avifToPng(avifBytes);
}
/// Encode PNG → AVIF for tile storage.
/// Uses moderate quality for good compression with acceptable quality.
static Future<Uint8List?> _pngToAvif(Uint8List pngBytes) async {
try {
final avif = await encodeAvif(
pngBytes,
maxThreads: 2,
maxQuantizer: 40, // Good quality (0=lossless, 63=worst)
minQuantizer: 25,
maxQuantizerAlpha: 63,
minQuantizerAlpha: 63,
speed: 6,
keepExif: false,
);
if (avif.isEmpty) return null;
return avif;
} catch (e) {
debugPrint('[OfflineTileCache] AVIF encode error: $e');
return null;
}
}
/// Decode AVIF → PNG bytes for display.
static Future<Uint8List?> _avifToPng(Uint8List avifBytes) async {
try {
// Use Flutter's image codec which can handle AVIF via flutter_avif
final codec = await ui.instantiateImageCodec(avifBytes);
final frame = await codec.getNextFrame();
final image = frame.image;
final byteData =
await image.toByteData(format: ui.ImageByteFormat.png);
image.dispose();
return byteData?.buffer.asUint8List();
} catch (e) {
debugPrint('[OfflineTileCache] AVIF decode error: $e');
return null;
}
}
}

View File

@@ -1,496 +1,148 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:geolocator/geolocator.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../models/path_history.dart';
import '../models/path_selection.dart'; import '../models/path_selection.dart';
import '../utils/log_rx_route_decoder.dart';
class _ManualPathSelectionRecord {
final List<int> pathBytes;
final int hopCount;
final int hashSize;
const _ManualPathSelectionRecord({
required this.pathBytes,
required this.hopCount,
required this.hashSize,
});
factory _ManualPathSelectionRecord.fromJson(Map<String, dynamic> json) {
final pathBytes = (json['pathBytes'] as List<dynamic>? ?? const <dynamic>[])
.whereType<int>()
.toList();
return _ManualPathSelectionRecord(
pathBytes: pathBytes,
hopCount: json['hopCount'] as int? ?? 0,
hashSize: json['hashSize'] as int? ?? 1,
);
}
Map<String, dynamic> toJson() => {
'pathBytes': pathBytes,
'hopCount': hopCount,
'hashSize': hashSize,
};
PathSelection toSelection() => PathSelection(
mode: PathSelectionMode.directCurrent,
pathBytes: Uint8List.fromList(pathBytes),
hopCount: hopCount,
hashSize: hashSize,
);
}
class PathHistoryService { class PathHistoryService {
static const String _storageKey = 'contact_path_history_v2'; static const String _legacyStorageKey = 'contact_path_history_v2';
static const String _suppressedRouteStorageKey = static const String _manualRouteStorageKey =
'contact_path_history_suppressed_routes_v1'; 'contact_manual_path_overrides_v1';
static const int _maxDirectPaths = 20;
static const int _topRotationCount = 3;
final Map<String, ContactPathHistory> _cache = {}; final Map<String, _ManualPathSelectionRecord> _manualSelections = {};
final Map<String, String> _suppressedCurrentRoutes = {};
bool _isLoaded = false; bool _isLoaded = false;
Future<void> initialize() async { Future<void> initialize() async {
if (_isLoaded) return; if (_isLoaded) return;
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_storageKey); await prefs.remove(_legacyStorageKey);
final suppressedRaw = prefs.getString(_suppressedRouteStorageKey);
if (raw == null || raw.isEmpty) {
if (suppressedRaw == null || suppressedRaw.isEmpty) {
_isLoaded = true;
return;
}
}
final manualRaw = prefs.getString(_manualRouteStorageKey);
try { try {
if (raw != null && raw.isNotEmpty) { if (manualRaw != null && manualRaw.isNotEmpty) {
final decoded = jsonDecode(raw); final decoded = jsonDecode(manualRaw);
if (decoded is Map<String, dynamic>) { if (decoded is Map<String, dynamic>) {
for (final entry in decoded.entries) { for (final entry in decoded.entries) {
final value = entry.value; final value = entry.value;
if (value is Map<String, dynamic>) { if (value is Map<String, dynamic>) {
_cache[entry.key] = ContactPathHistory.fromJson(entry.key, value); _manualSelections[entry.key] =
} _ManualPathSelectionRecord.fromJson(value);
}
}
}
} catch (error) {
debugPrint('⚠️ [PathHistoryService] Failed to load history: $error');
}
try {
if (suppressedRaw != null && suppressedRaw.isNotEmpty) {
final decoded = jsonDecode(suppressedRaw);
if (decoded is Map<String, dynamic>) {
for (final entry in decoded.entries) {
final value = entry.value;
if (value is String && value.isNotEmpty) {
_suppressedCurrentRoutes[entry.key] = value;
} }
} }
} }
} }
} catch (error) { } catch (error) {
debugPrint( debugPrint(
'⚠️ [PathHistoryService] Failed to load suppressed routes: $error', '⚠️ [PathHistoryService] Failed to load manual routes: $error',
); );
} }
_isLoaded = true; _isLoaded = true;
} }
Future<void> recordLearnedPath(Contact contact) async { Future<PathSelection> getSelectionForContact(Contact contact) async {
await initialize(); await initialize();
if (!contact.routeHasPath || contact.routeHopCount <= 0) { final manualSelection = _manualSelections[contact.publicKeyHex];
return; if (manualSelection != null) {
return manualSelection.toSelection();
} }
final history = _historyFor(contact.publicKeyHex); final route = ContactRouteCodec.fromContact(contact);
final signature = _signature(contact.routePathBytes); if (route == null) {
if (_suppressedCurrentRoutes[contact.publicKeyHex] == signature) { return PathSelection.flood();
return;
}
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(),
lastSucceededAt: existing?.lastSucceededAt,
senderLatitude: existing?.senderLatitude,
senderLongitude: existing?.senderLongitude,
recipientLatitude: existing?.recipientLatitude,
recipientLongitude: existing?.recipientLongitude,
);
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();
_clearSuppressedRoute(
contactPublicKeyHex,
signature: signature,
);
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(),
lastSucceededAt: existing?.lastSucceededAt,
senderLatitude: existing?.senderLatitude,
senderLongitude: existing?.senderLongitude,
recipientLatitude: existing?.recipientLatitude,
recipientLongitude: existing?.recipientLongitude,
);
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( return PathSelection(
mode: PathSelectionMode.directCurrent, mode: PathSelectionMode.directCurrent,
pathBytes: Uint8List.fromList(contact.routePathBytes), pathBytes: Uint8List.fromList(route.pathBytes),
hopCount: contact.routeHopCount, hopCount: route.hopCount,
hashSize: contact.routeHashSize, hashSize: route.hashSize,
); );
} }
if (!autoRouteRotationEnabled) { Future<void> setManualRouteForContact(
return PathSelection.flood(); Contact contact,
} ParsedContactRoute route,
) async {
final history = _historyFor(contact.publicKeyHex); await setManualSelectionFor(
final ranked = List<PathRecord>.from(history.directPaths) contact.publicKeyHex,
..sort(_comparePathRecords); PathSelection(
final topPaths = ranked.take(_topRotationCount).toList(); mode: PathSelectionMode.directCurrent,
if (topPaths.isEmpty) { pathBytes: Uint8List.fromList(route.pathBytes),
final nextFloodHistory = history.copyWith( hopCount: route.hopCount,
rotationIndex: history.rotationIndex + 1, hashSize: route.hashSize,
);
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( Future<void> setManualSelectionFor(
String contactPublicKeyHex, String contactPublicKeyHex,
PathSelection selection, { PathSelection selection,
required bool success, ) async {
int? roundTripTimeMs,
double? senderLatitude,
double? senderLongitude,
double? recipientLatitude,
double? recipientLongitude,
}) async {
await initialize(); await initialize();
final history = _historyFor(contactPublicKeyHex); _manualSelections[contactPublicKeyHex] = _ManualPathSelectionRecord(
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);
_clearSuppressedRoute(
contactPublicKeyHex,
signature: signature,
);
final existing = _findDirectPath(history.directPaths, signature);
final updated = PathRecord(
pathBytes: selection.pathBytes.toList(), pathBytes: selection.pathBytes.toList(),
hopCount: selection.hopCount, hopCount: selection.hopCount,
hashSize: selection.hashSize, hashSize: selection.hashSize,
source: success
? PathRecordSource.learned
: 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(),
lastSucceededAt: success ? DateTime.now() : existing?.lastSucceededAt,
senderLatitude: success ? senderLatitude : existing?.senderLatitude,
senderLongitude: success ? senderLongitude : existing?.senderLongitude,
recipientLatitude:
success ? recipientLatitude : existing?.recipientLatitude,
recipientLongitude:
success ? recipientLongitude : existing?.recipientLongitude,
);
await _saveHistory(
contactPublicKeyHex,
history.copyWith(
directPaths: _upsertDirectPath(history.directPaths, updated),
),
); );
await _persistManualSelections();
} }
Future<PathSelection?> getLastSuccessfulDirectSelection( Future<PathSelection?> getManualSelectionForContact(Contact contact) async {
Contact contact, {
String? excludeSignature,
double? senderLatitude,
double? senderLongitude,
double? recipientLatitude,
double? recipientLongitude,
}) async {
await initialize(); await initialize();
final history = _historyFor(contact.publicKeyHex); return _manualSelections[contact.publicKeyHex]?.toSelection();
final ranked = history.directPaths
.where(
(record) =>
record.successCount > 0 &&
record.lastSucceededAt != null &&
record.signature != excludeSignature,
)
.toList()
..sort((a, b) {
final locationCompare = _compareLocationFit(
a,
b,
senderLatitude: senderLatitude,
senderLongitude: senderLongitude,
recipientLatitude: recipientLatitude,
recipientLongitude: recipientLongitude,
);
if (locationCompare != 0) return locationCompare;
final succeededCompare = b.lastSucceededAt!.compareTo(
a.lastSucceededAt!,
);
if (succeededCompare != 0) return succeededCompare;
return _comparePathRecords(a, b);
});
if (ranked.isEmpty) {
return null;
} }
final record = ranked.first; Future<void> clearManualRouteFor(String contactPublicKeyHex) async {
return PathSelection(
mode: PathSelectionMode.directHistorical,
pathBytes: Uint8List.fromList(record.pathBytes),
hopCount: record.hopCount,
hashSize: record.hashSize,
);
}
ContactPathHistory historyFor(String contactPublicKeyHex) {
return _cache[contactPublicKeyHex] ??
ContactPathHistory.empty(contactPublicKeyHex);
}
Future<void> clearHistoryFor(String contactPublicKeyHex) async {
await initialize(); await initialize();
_cache.remove(contactPublicKeyHex); _manualSelections.remove(contactPublicKeyHex);
_suppressedCurrentRoutes.remove(contactPublicKeyHex); await _persistManualSelections();
await _persistState();
} }
Future<void> clearHistoryForContact(Contact contact) async { Future<void> _persistManualSelections() async {
await initialize();
_cache.remove(contact.publicKeyHex);
if (contact.routeHasPath && contact.routeHopCount > 0) {
_suppressedCurrentRoutes[contact.publicKeyHex] = _signature(
contact.routePathBytes,
);
} else {
_suppressedCurrentRoutes.remove(contact.publicKeyHex);
}
await _persistState();
}
ContactPathHistory _historyFor(String contactPublicKeyHex) {
return _cache.putIfAbsent(
contactPublicKeyHex,
() => ContactPathHistory.empty(contactPublicKeyHex),
);
}
Future<void> _saveHistory(
String contactPublicKeyHex,
ContactPathHistory history,
) async {
_cache[contactPublicKeyHex] = history;
await _persistState();
}
void _clearSuppressedRoute(String contactPublicKeyHex, {String? signature}) {
final suppressedSignature = _suppressedCurrentRoutes[contactPublicKeyHex];
if (suppressedSignature == null) {
return;
}
if (signature == null || suppressedSignature == signature) {
_suppressedCurrentRoutes.remove(contactPublicKeyHex);
}
}
Future<void> _persistState() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final payload = <String, dynamic>{}; final manualPayload = <String, dynamic>{};
for (final entry in _cache.entries) { for (final entry in _manualSelections.entries) {
payload[entry.key] = entry.value.toJson(); manualPayload[entry.key] = entry.value.toJson();
} }
final suppressedPayload = <String, dynamic>{}; await prefs.setString(_manualRouteStorageKey, jsonEncode(manualPayload));
for (final entry in _suppressedCurrentRoutes.entries) {
suppressedPayload[entry.key] = entry.value;
}
await prefs.setString(_storageKey, jsonEncode(payload));
await prefs.setString(
_suppressedRouteStorageKey,
jsonEncode(suppressedPayload),
);
}
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();
int _compareLocationFit(
PathRecord a,
PathRecord b, {
required double? senderLatitude,
required double? senderLongitude,
required double? recipientLatitude,
required double? recipientLongitude,
}) {
final aDistance = _locationDistanceScore(
a,
senderLatitude: senderLatitude,
senderLongitude: senderLongitude,
recipientLatitude: recipientLatitude,
recipientLongitude: recipientLongitude,
);
final bDistance = _locationDistanceScore(
b,
senderLatitude: senderLatitude,
senderLongitude: senderLongitude,
recipientLatitude: recipientLatitude,
recipientLongitude: recipientLongitude,
);
return aDistance.compareTo(bDistance);
}
double _locationDistanceScore(
PathRecord record, {
required double? senderLatitude,
required double? senderLongitude,
required double? recipientLatitude,
required double? recipientLongitude,
}) {
var total = 0.0;
var matched = false;
if (senderLatitude != null &&
senderLongitude != null &&
record.senderLatitude != null &&
record.senderLongitude != null) {
matched = true;
total += Geolocator.distanceBetween(
senderLatitude,
senderLongitude,
record.senderLatitude!,
record.senderLongitude!,
);
}
if (recipientLatitude != null &&
recipientLongitude != null &&
record.recipientLatitude != null &&
record.recipientLongitude != null) {
matched = true;
total += Geolocator.distanceBetween(
recipientLatitude,
recipientLongitude,
record.recipientLatitude!,
record.recipientLongitude!,
);
}
return matched ? total : double.infinity;
} }
} }

View File

@@ -0,0 +1,332 @@
import 'dart:async';
import 'package:http/http.dart' as http;
import 'package:latlong2/latlong.dart';
import 'offline_tile_cache_service.dart';
import 'tile_math_service.dart';
/// Events emitted during tile download.
sealed class TileDownloadEvent {}
class TileDownloadStarted extends TileDownloadEvent {
final int totalTiles;
TileDownloadStarted(this.totalTiles);
}
class TileDownloaded extends TileDownloadEvent {
final double north, south, east, west;
TileDownloaded({
required this.north,
required this.south,
required this.east,
required this.west,
});
}
class TileSkipped extends TileDownloadEvent {
final double north, south, east, west;
TileSkipped({
required this.north,
required this.south,
required this.east,
required this.west,
});
}
class TileFailed extends TileDownloadEvent {
final TileCoord coord;
final String error;
TileFailed(this.coord, this.error);
}
class TileDownloadComplete extends TileDownloadEvent {
final int downloaded;
final int skipped;
final int failed;
final int total;
TileDownloadComplete({
required this.downloaded,
required this.skipped,
required this.failed,
required this.total,
});
}
class TileBatchSkipped extends TileDownloadEvent {
final int count;
final int total;
TileBatchSkipped({required this.count, required this.total});
}
class TileDownloadCancelled extends TileDownloadEvent {}
/// Downloads map tiles for given polygons and zoom levels.
class TileDownloadService {
final OfflineTileCacheService _cache = OfflineTileCacheService.instance;
final http.Client _httpClient = http.Client();
bool _cancelled = false;
/// Cancel an ongoing download.
void cancel() {
_cancelled = true;
}
/// Download tiles for the given polygons and zoom range.
///
/// Returns a stream of [TileDownloadEvent]s.
/// [urlTemplate] should contain `{z}`, `{x}`, `{y}` placeholders,
/// and optionally `{s}` for subdomains.
Stream<TileDownloadEvent> downloadTiles({
required List<List<LatLng>> polygons,
required int minZoom,
required int maxZoom,
required String urlTemplate,
String? displayName,
int maxConcurrency = 6,
int rateLimit = 30,
}) {
final controller = StreamController<TileDownloadEvent>();
_runDownload(
controller: controller,
polygons: polygons,
minZoom: minZoom,
maxZoom: maxZoom,
urlTemplate: urlTemplate,
displayName: displayName,
maxConcurrency: maxConcurrency,
rateLimit: rateLimit,
);
return controller.stream;
}
Future<void> _runDownload({
required StreamController<TileDownloadEvent> controller,
required List<List<LatLng>> polygons,
required int minZoom,
required int maxZoom,
required String urlTemplate,
String? displayName,
required int maxConcurrency,
required int rateLimit,
}) async {
_cancelled = false;
final styleHash = _cache.styleHashFromUrl(urlTemplate);
// Save style metadata with download region so it can be reused
final region = DownloadRegion(
polygons: polygons
.map((poly) =>
poly.map((p) => [p.latitude, p.longitude]).toList())
.toList(),
minZoom: minZoom,
maxZoom: maxZoom,
);
await _cache.saveStyleMeta(
styleHash,
displayName: displayName ?? urlTemplate,
urlTemplate: urlTemplate,
region: region,
);
final allTiles =
TileMathService.getTilesForPolygons(polygons, minZoom, maxZoom);
// Load manifest once and partition tiles into needed vs already cached
final manifest = await _cache.loadManifest(styleHash);
final tilesToDownload = <TileCoord>[];
var skipped = 0;
for (final tile in allTiles) {
final key = '${tile.z}/${tile.x}/${tile.y}';
if (manifest.contains(key)) {
skipped++;
} else {
tilesToDownload.add(tile);
}
}
final total = allTiles.length;
controller.add(TileDownloadStarted(total));
// Report all skipped tiles immediately (no per-tile filesystem check)
if (skipped > 0) {
controller.add(TileBatchSkipped(count: skipped, total: total));
}
if (tilesToDownload.isEmpty) {
controller.add(TileDownloadComplete(
downloaded: 0, skipped: skipped, failed: 0, total: total));
await controller.close();
return;
}
var downloaded = 0;
var failed = 0;
final semaphore = _Semaphore(maxConcurrency);
final rateLimiter = _RateLimiter(rateLimit);
final futures = <Future<void>>[];
for (final tile in tilesToDownload) {
if (_cancelled) break;
await rateLimiter.wait();
if (_cancelled) break;
await semaphore.acquire();
if (_cancelled) {
semaphore.release();
break;
}
final future = _downloadSingleTile(tile, urlTemplate, styleHash)
.then((event) {
if (!controller.isClosed) {
controller.add(event);
if (event is TileDownloaded) {
downloaded++;
} else if (event is TileFailed) {
failed++;
}
}
semaphore.release();
});
futures.add(future);
}
// Wait for all in-flight downloads to finish
await Future.wait(futures);
if (_cancelled) {
controller.add(TileDownloadCancelled());
} else {
controller.add(TileDownloadComplete(
downloaded: downloaded,
skipped: skipped,
failed: failed,
total: total,
));
}
await controller.close();
}
Future<TileDownloadEvent> _downloadSingleTile(
TileCoord tile,
String urlTemplate,
String styleHash,
) async {
final bounds = TileMathService.tileBounds(tile.x, tile.y, tile.z);
// Build URL
final subdomains = ['a', 'b', 'c'];
var url = urlTemplate
.replaceAll('{s}', subdomains[tile.x % 3])
.replaceAll('{z}', '${tile.z}')
.replaceAll('{x}', '${tile.x}')
.replaceAll('{y}', '${tile.y}');
// Download with retries
const maxRetries = 3;
for (var attempt = 0; attempt < maxRetries; attempt++) {
if (_cancelled) {
return TileFailed(tile, 'Cancelled');
}
try {
final response = await _httpClient.get(
Uri.parse(url),
headers: {'User-Agent': 'MeshCoreSAR/1.0'},
);
if (response.statusCode != 200) {
if (attempt < maxRetries - 1) {
await Future.delayed(Duration(seconds: 1 << attempt));
continue;
}
return TileFailed(tile, 'HTTP ${response.statusCode}');
}
// Store tile (PNG → AVIF conversion happens inside cache service)
await _cache.putTile(
styleHash, tile.z, tile.x, tile.y, response.bodyBytes);
return TileDownloaded(
north: bounds.north,
south: bounds.south,
east: bounds.east,
west: bounds.west,
);
} catch (e) {
if (attempt < maxRetries - 1) {
await Future.delayed(Duration(seconds: 1 << attempt));
continue;
}
return TileFailed(tile, e.toString());
}
}
return TileFailed(tile, 'Max retries exceeded');
}
void dispose() {
_cancelled = true;
_httpClient.close();
}
}
/// Simple counting semaphore for concurrency limiting.
class _Semaphore {
final int maxCount;
int _currentCount = 0;
final _waitQueue = <Completer<void>>[];
_Semaphore(this.maxCount);
Future<void> acquire() async {
if (_currentCount < maxCount) {
_currentCount++;
return;
}
final completer = Completer<void>();
_waitQueue.add(completer);
await completer.future;
}
void release() {
if (_waitQueue.isNotEmpty) {
_waitQueue.removeAt(0).complete();
} else {
_currentCount--;
}
}
}
/// Rate limiter that ensures no more than [maxPerSecond] operations per second.
class _RateLimiter {
final int maxPerSecond;
final _timestamps = <DateTime>[];
_RateLimiter(this.maxPerSecond);
Future<void> wait() async {
final now = DateTime.now();
_timestamps
.removeWhere((t) => now.difference(t) > const Duration(seconds: 1));
if (_timestamps.length >= maxPerSecond) {
final oldest = _timestamps.first;
final waitTime = const Duration(seconds: 1) - now.difference(oldest);
if (waitTime > Duration.zero) {
await Future.delayed(waitTime);
}
_timestamps.removeAt(0);
}
_timestamps.add(DateTime.now());
}
}

View File

@@ -0,0 +1,268 @@
import 'dart:math';
import 'package:latlong2/latlong.dart';
/// A tile coordinate with x, y, and zoom level.
class TileCoord {
final int x;
final int y;
final int z;
const TileCoord(this.x, this.y, this.z);
@override
bool operator ==(Object other) =>
other is TileCoord && other.x == x && other.y == y && other.z == z;
@override
int get hashCode => Object.hash(x, y, z);
@override
String toString() => 'TileCoord($z/$x/$y)';
}
/// A geographical bounding box.
class TileBounds {
final double north;
final double south;
final double east;
final double west;
const TileBounds({
required this.north,
required this.south,
required this.east,
required this.west,
});
}
/// Pure math utilities for slippy map tile calculations.
///
/// Ported from the Go offline-map-tile-downloader.
class TileMathService {
const TileMathService._();
/// Convert latitude/longitude to tile coordinates at the given zoom level.
static (int x, int y) latLonToTile(double lat, double lon, int zoom) {
final latRad = lat * pi / 180;
final n = pow(2, zoom).toDouble();
final x = (n * ((lon + 180) / 360)).floor();
final y =
(n * (1 - (log(tan(latRad) + 1 / cos(latRad)) / pi)) / 2).floor();
return (x, y);
}
/// Calculate the geographical bounding box of a tile.
static TileBounds tileBounds(int x, int y, int z) {
final n = pow(2.0, z).toDouble();
final lonDeg = x / n * 360.0 - 180.0;
final latRad = atan(sinh(pi * (1 - 2 * y / n)));
final latDeg = latRad * 180.0 / pi;
final lon2Deg = (x + 1) / n * 360.0 - 180.0;
final lat2Rad = atan(sinh(pi * (1 - 2 * (y + 1) / n)));
final lat2Deg = lat2Rad * 180.0 / pi;
return TileBounds(
north: latDeg,
south: lat2Deg,
east: lon2Deg,
west: lonDeg,
);
}
/// Hyperbolic sine.
static double sinh(double x) => (exp(x) - exp(-x)) / 2;
/// Check if a point is inside a polygon using the ray casting algorithm.
static bool polygonContains(List<LatLng> poly, LatLng point) {
var inside = false;
for (int i = 0, j = poly.length - 1; i < poly.length; j = i++) {
if ((poly[i].latitude > point.latitude) !=
(poly[j].latitude > point.latitude) &&
(point.longitude <
(poly[j].longitude - poly[i].longitude) *
(point.latitude - poly[i].latitude) /
(poly[j].latitude - poly[i].latitude) +
poly[i].longitude)) {
inside = !inside;
}
}
return inside;
}
/// Check if a bounding box contains a point.
static bool boundsContains(TileBounds bounds, LatLng point) {
return point.latitude <= bounds.north &&
point.latitude >= bounds.south &&
point.longitude >= bounds.west &&
point.longitude <= bounds.east;
}
/// Check if a polygon intersects with a tile bounding box.
static bool polygonIntersects(List<LatLng> poly, TileBounds bounds) {
// Check if any polygon vertex is inside the tile
for (final p in poly) {
if (boundsContains(bounds, p)) return true;
}
// Check if any tile corner is inside the polygon
final corners = [
LatLng(bounds.north, bounds.west),
LatLng(bounds.north, bounds.east),
LatLng(bounds.south, bounds.west),
LatLng(bounds.south, bounds.east),
];
for (final corner in corners) {
if (polygonContains(poly, corner)) return true;
}
// Check if any polygon edge intersects any tile edge
final tileEdges = [
(corners[0], corners[1]),
(corners[1], corners[3]),
(corners[3], corners[2]),
(corners[2], corners[0]),
];
for (int i = 0; i < poly.length; i++) {
final p1 = poly[i];
final p2 = poly[(i + 1) % poly.length];
for (final edge in tileEdges) {
if (_lineIntersects(p1, p2, edge.$1, edge.$2)) return true;
}
}
return false;
}
/// Check if two line segments intersect.
static bool _lineIntersects(LatLng p1, LatLng q1, LatLng p2, LatLng q2) {
final o1 = _orientation(p1, q1, p2);
final o2 = _orientation(p1, q1, q2);
final o3 = _orientation(p2, q2, p1);
final o4 = _orientation(p2, q2, q1);
if (o1 != o2 && o3 != o4) return true;
if (o1 == 0 && _onSegment(p1, p2, q1)) return true;
if (o2 == 0 && _onSegment(p1, q2, q1)) return true;
if (o3 == 0 && _onSegment(p2, p1, q2)) return true;
if (o4 == 0 && _onSegment(p2, q1, q2)) return true;
return false;
}
/// Find orientation of ordered triplet (p, q, r).
/// Returns 0 for collinear, 1 for clockwise, 2 for counterclockwise.
static int _orientation(LatLng p, LatLng q, LatLng r) {
final val = (q.longitude - p.longitude) * (r.latitude - q.latitude) -
(q.latitude - p.latitude) * (r.longitude - q.longitude);
if (val == 0) return 0;
return val > 0 ? 1 : 2;
}
/// Check if point q lies on segment pr.
static bool _onSegment(LatLng p, LatLng q, LatLng r) {
return q.latitude <= max(p.latitude, r.latitude) &&
q.latitude >= min(p.latitude, r.latitude) &&
q.longitude <= max(p.longitude, r.longitude) &&
q.longitude >= min(p.longitude, r.longitude);
}
/// Get all tiles that overlap with the given polygons across zoom levels.
static List<TileCoord> getTilesForPolygons(
List<List<LatLng>> polygons,
int minZoom,
int maxZoom,
) {
final tileSet = <TileCoord>{};
for (final poly in polygons) {
if (poly.length < 3) continue;
// Find bounding box of polygon
var minLat = 90.0, minLon = 180.0;
var maxLat = -90.0, maxLon = -180.0;
for (final p in poly) {
if (p.latitude < minLat) minLat = p.latitude;
if (p.latitude > maxLat) maxLat = p.latitude;
if (p.longitude < minLon) minLon = p.longitude;
if (p.longitude > maxLon) maxLon = p.longitude;
}
for (int z = minZoom; z <= maxZoom; z++) {
final (tlx, tly) = latLonToTile(maxLat, minLon, z);
final (brx, bry) = latLonToTile(minLat, maxLon, z);
for (int x = tlx; x <= brx; x++) {
for (int y = tly; y <= bry; y++) {
final tile = TileCoord(x, y, z);
if (tileSet.contains(tile)) continue;
final bounds = tileBounds(x, y, z);
// Check if all tile corners are inside the polygon
final allCornersInside = polygonContains(
poly, LatLng(bounds.north, bounds.west)) &&
polygonContains(poly, LatLng(bounds.north, bounds.east)) &&
polygonContains(poly, LatLng(bounds.south, bounds.west)) &&
polygonContains(poly, LatLng(bounds.south, bounds.east));
if (allCornersInside) {
tileSet.add(tile);
continue;
}
// Check if all polygon vertices are inside the tile
var polyInTile = true;
for (final p in poly) {
if (!boundsContains(bounds, p)) {
polyInTile = false;
break;
}
}
if (polyInTile) {
tileSet.add(tile);
continue;
}
// Check for intersection
if (polygonIntersects(poly, bounds)) {
tileSet.add(tile);
}
}
}
}
}
return tileSet.toList();
}
/// Estimate the number of tiles for given polygons and zoom range.
/// Faster than getTilesForPolygons — uses bounding box approximation.
static int estimateTileCount(
List<List<LatLng>> polygons,
int minZoom,
int maxZoom,
) {
var count = 0;
for (final poly in polygons) {
if (poly.length < 3) continue;
var minLat = 90.0, minLon = 180.0;
var maxLat = -90.0, maxLon = -180.0;
for (final p in poly) {
if (p.latitude < minLat) minLat = p.latitude;
if (p.latitude > maxLat) maxLat = p.latitude;
if (p.longitude < minLon) minLon = p.longitude;
if (p.longitude > maxLon) maxLon = p.longitude;
}
for (int z = minZoom; z <= maxZoom; z++) {
final (tlx, tly) = latLonToTile(maxLat, minLon, z);
final (brx, bry) = latLonToTile(minLat, maxLon, z);
count += (brx - tlx + 1) * (bry - tly + 1);
}
}
return count;
}
}

View File

@@ -0,0 +1,518 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:nsd/nsd.dart' as nsd;
import 'offline_tile_cache_service.dart';
/// A discovered tile-serving peer on the local network.
class TilePeer {
final String ipAddress;
final int port;
const TilePeer({required this.ipAddress, required this.port});
String get baseUrl => 'http://$ipAddress:$port';
@override
bool operator ==(Object other) =>
other is TilePeer && other.ipAddress == ipAddress && other.port == port;
@override
int get hashCode => Object.hash(ipAddress, port);
@override
String toString() => 'TilePeer($ipAddress:$port)';
}
/// What a remote peer has available.
class PeerCatalog {
final TilePeer peer;
final List<StyleInfo> styles;
const PeerCatalog({required this.peer, required this.styles});
}
/// Progress events during a P2P sync.
sealed class PeerSyncEvent {}
class PeerSyncStarted extends PeerSyncEvent {
final int totalTiles;
PeerSyncStarted(this.totalTiles);
}
class PeerSyncTileDownloaded extends PeerSyncEvent {
final int downloaded;
final int total;
PeerSyncTileDownloaded({required this.downloaded, required this.total});
}
class PeerSyncTileSkipped extends PeerSyncEvent {
final int skipped;
final int total;
PeerSyncTileSkipped({required this.skipped, required this.total});
}
class PeerSyncComplete extends PeerSyncEvent {
final int downloaded;
final int skipped;
final int failed;
PeerSyncComplete({
required this.downloaded,
required this.skipped,
required this.failed,
});
}
class PeerSyncCancelled extends PeerSyncEvent {}
/// HTTP server that serves cached AVIF tiles to other devices on the
/// local network, with mDNS advertisement, peer discovery, and P2P sync.
///
/// Protocol:
/// GET /styles → JSON array of StyleInfo
/// GET /tiles/{hash}/list → JSON array of {z, x, y}
/// GET /tiles/{hash}/{z}/{x}/{y}.avif → AVIF bytes | 404
class TileSharingService {
TileSharingService._();
static final instance = TileSharingService._();
static const int defaultPort = 8347;
static const String serviceType = '_sartiles._tcp';
final OfflineTileCacheService _cache = OfflineTileCacheService.instance;
final http.Client _httpClient = http.Client();
HttpServer? _server;
nsd.Discovery? _activeDiscovery;
nsd.Registration? _activeRegistration;
final _peersController = StreamController<Set<TilePeer>>.broadcast();
final Set<TilePeer> _discoveredPeers = {};
bool _syncCancelled = false;
bool get isRunning => _server != null;
Stream<Set<TilePeer>> get peersStream => _peersController.stream;
Set<TilePeer> get discoveredPeers => Set.unmodifiable(_discoveredPeers);
// ── Server ──────────────────────────────────────────────────────────────
Future<void> startServer() async {
if (_server != null) return;
try {
_server = await HttpServer.bind(InternetAddress.anyIPv4, defaultPort);
debugPrint('[TileSharing] Server started on port $defaultPort');
_server!.listen(_handleRequest, onError: (error) {
debugPrint('[TileSharing] Server error: $error');
});
await _advertise();
} catch (e) {
debugPrint('[TileSharing] Failed to start server: $e');
_server = null;
}
}
Future<void> stopServer() async {
await _stopAdvertising();
await _server?.close();
_server = null;
debugPrint('[TileSharing] Server stopped');
}
// ── Discovery ───────────────────────────────────────────────────────────
Future<void> startDiscovery() async {
if (_activeDiscovery != null) return;
try {
_activeDiscovery = await nsd.startDiscovery(serviceType);
_activeDiscovery!.addServiceListener((service, status) {
if (service.host == null || service.port == null) return;
final peer = TilePeer(
ipAddress: service.host!,
port: service.port!,
);
if (status == nsd.ServiceStatus.found) {
_discoveredPeers.add(peer);
} else {
_discoveredPeers.remove(peer);
}
_peersController.add(Set.unmodifiable(_discoveredPeers));
});
} catch (e) {
debugPrint('[TileSharing] Discovery error: $e');
}
}
Future<void> stopPeerDiscovery() async {
if (_activeDiscovery != null) {
await nsd.stopDiscovery(_activeDiscovery!);
_activeDiscovery = null;
}
_discoveredPeers.clear();
_peersController.add(const {});
}
void addManualPeer(String ipAddress, {int port = defaultPort}) {
_discoveredPeers.add(TilePeer(ipAddress: ipAddress, port: port));
_peersController.add(Set.unmodifiable(_discoveredPeers));
}
void removePeer(TilePeer peer) {
_discoveredPeers.remove(peer);
_peersController.add(Set.unmodifiable(_discoveredPeers));
}
// ── Peer queries ────────────────────────────────────────────────────────
/// Fetch the catalog (available styles + tile counts) from a peer.
Future<PeerCatalog?> fetchPeerCatalog(TilePeer peer) async {
try {
final uri = Uri.parse('${peer.baseUrl}/styles');
final response =
await _httpClient.get(uri).timeout(const Duration(seconds: 5));
if (response.statusCode != 200) return null;
final List<dynamic> data = jsonDecode(response.body);
final styles = data
.map((e) => StyleInfo.fromJson(e as Map<String, dynamic>))
.toList();
return PeerCatalog(peer: peer, styles: styles);
} catch (e) {
debugPrint('[TileSharing] fetchPeerCatalog(${peer.ipAddress}): $e');
return null;
}
}
/// Fetch catalogs from all discovered peers.
Future<List<PeerCatalog>> fetchAllPeerCatalogs() async {
final futures =
_discoveredPeers.map((peer) => fetchPeerCatalog(peer)).toList();
final results = await Future.wait(futures);
return results.whereType<PeerCatalog>().toList();
}
/// Fetch the tile list for a style from a peer.
Future<List<CachedTileCoord>?> fetchPeerTileList(
TilePeer peer,
String styleHash,
) async {
try {
final uri = Uri.parse('${peer.baseUrl}/tiles/$styleHash/list');
final response =
await _httpClient.get(uri).timeout(const Duration(seconds: 10));
if (response.statusCode != 200) return null;
final List<dynamic> data = jsonDecode(response.body);
return data
.map((e) => CachedTileCoord.fromJson(e as Map<String, dynamic>))
.toList();
} catch (e) {
debugPrint('[TileSharing] fetchPeerTileList(${peer.ipAddress}): $e');
return null;
}
}
/// Fetch a single tile from a peer. Returns raw AVIF bytes or null.
Future<Uint8List?> fetchTileFromPeer(
TilePeer peer,
String styleHash,
int z,
int x,
int y,
) async {
try {
final uri =
Uri.parse('${peer.baseUrl}/tiles/$styleHash/$z/$x/$y.avif');
final response =
await _httpClient.get(uri).timeout(const Duration(seconds: 5));
if (response.statusCode == 200) return response.bodyBytes;
} catch (e) {
// Silently fail — caller will try next peer
}
return null;
}
/// Try fetching a tile from any available peer (for the caching provider).
Future<Uint8List?> fetchFromAnyPeer(
String styleHash,
int z,
int x,
int y,
) async {
for (final peer in _discoveredPeers) {
final bytes = await fetchTileFromPeer(peer, styleHash, z, x, y);
if (bytes != null) return bytes;
}
return null;
}
// ── P2P Sync ────────────────────────────────────────────────────────────
void cancelSync() {
_syncCancelled = true;
}
/// Sync a style from peers: fetch their tile list, download tiles we
/// don't have, trying multiple peers in round-robin for speed.
///
/// [peers] — which peers to pull from (all that have this style).
/// [styleHash] — which style to sync.
/// [styleMeta] — metadata to save locally (name, URL template).
Stream<PeerSyncEvent> syncStyleFromPeers({
required List<TilePeer> peers,
required String styleHash,
required StyleInfo styleMeta,
int maxConcurrency = 8,
}) {
final controller = StreamController<PeerSyncEvent>();
_runSync(
controller: controller,
peers: peers,
styleHash: styleHash,
styleMeta: styleMeta,
maxConcurrency: maxConcurrency,
);
return controller.stream;
}
Future<void> _runSync({
required StreamController<PeerSyncEvent> controller,
required List<TilePeer> peers,
required String styleHash,
required StyleInfo styleMeta,
required int maxConcurrency,
}) async {
_syncCancelled = false;
// Save style metadata locally
await _cache.saveStyleMeta(
styleHash,
displayName: styleMeta.displayName,
urlTemplate: styleMeta.urlTemplate,
);
// Collect tile lists from all peers and merge (union)
final allTiles = <String, CachedTileCoord>{};
for (final peer in peers) {
if (_syncCancelled) break;
final tiles = await fetchPeerTileList(peer, styleHash);
if (tiles != null) {
for (final t in tiles) {
allTiles['${t.z}/${t.x}/${t.y}'] = t;
}
}
}
final tilesToSync = allTiles.values.toList();
controller.add(PeerSyncStarted(tilesToSync.length));
if (tilesToSync.isEmpty || _syncCancelled) {
controller
.add(PeerSyncComplete(downloaded: 0, skipped: 0, failed: 0));
await controller.close();
return;
}
var downloaded = 0;
var skipped = 0;
var failed = 0;
final total = tilesToSync.length;
final semaphore = _Semaphore(maxConcurrency);
final futures = <Future<void>>[];
var peerIndex = 0;
for (final tile in tilesToSync) {
if (_syncCancelled) break;
await semaphore.acquire();
if (_syncCancelled) {
semaphore.release();
break;
}
// Round-robin across peers for parallel throughput
final peer = peers[peerIndex % peers.length];
peerIndex++;
final future = () async {
try {
// Skip if we already have it
if (await _cache.hasTile(styleHash, tile.z, tile.x, tile.y)) {
skipped++;
controller.add(
PeerSyncTileSkipped(skipped: skipped, total: total));
return;
}
// Try this peer, then fallback to others
Uint8List? bytes =
await fetchTileFromPeer(peer, styleHash, tile.z, tile.x, tile.y);
if (bytes == null) {
for (final fallback in peers) {
if (fallback == peer) continue;
bytes = await fetchTileFromPeer(
fallback, styleHash, tile.z, tile.x, tile.y);
if (bytes != null) break;
}
}
if (bytes != null) {
await _cache.putRawTile(
styleHash, tile.z, tile.x, tile.y, bytes);
downloaded++;
controller.add(PeerSyncTileDownloaded(
downloaded: downloaded, total: total));
} else {
failed++;
}
} catch (_) {
failed++;
} finally {
semaphore.release();
}
}();
futures.add(future);
}
await Future.wait(futures);
if (_syncCancelled) {
controller.add(PeerSyncCancelled());
} else {
controller.add(PeerSyncComplete(
downloaded: downloaded,
skipped: skipped,
failed: failed,
));
}
await controller.close();
}
// ── mDNS ────────────────────────────────────────────────────────────────
Future<void> _advertise() async {
try {
final styles = await _cache.listStyles();
_activeRegistration = await nsd.register(nsd.Service(
name: 'MeshCore SAR Tiles',
type: serviceType,
port: defaultPort,
txt: {
'styles':
Uint8List.fromList(utf8.encode(styles.join(','))),
},
));
} catch (e) {
debugPrint('[TileSharing] mDNS registration error: $e');
}
}
Future<void> _stopAdvertising() async {
if (_activeRegistration != null) {
await nsd.unregister(_activeRegistration!);
_activeRegistration = null;
}
}
// ── HTTP Server ─────────────────────────────────────────────────────────
void _handleRequest(HttpRequest request) async {
request.response.headers.add('Access-Control-Allow-Origin', '*');
final path = request.uri.path;
// GET /styles → detailed style list
if (path == '/styles') {
final styles = await _cache.listStylesDetailed();
request.response
..statusCode = HttpStatus.ok
..headers.contentType = ContentType.json
..write(jsonEncode(styles.map((s) => s.toJson()).toList()));
await request.response.close();
return;
}
// GET /tiles/{hash}/list → tile coordinate inventory
final listPattern = RegExp(r'^/tiles/([a-f0-9]+)/list$');
final listMatch = listPattern.firstMatch(path);
if (listMatch != null) {
final styleHash = listMatch.group(1)!;
final tiles = await _cache.listTilesForStyle(styleHash);
request.response
..statusCode = HttpStatus.ok
..headers.contentType = ContentType.json
..write(jsonEncode(tiles.map((t) => t.toJson()).toList()));
await request.response.close();
return;
}
// GET /tiles/{hash}/{z}/{x}/{y}.avif → tile bytes
final tilePattern =
RegExp(r'^/tiles/([a-f0-9]+)/(\d+)/(\d+)/(\d+)\.avif$');
final tileMatch = tilePattern.firstMatch(path);
if (tileMatch != null) {
final styleHash = tileMatch.group(1)!;
final z = int.parse(tileMatch.group(2)!);
final x = int.parse(tileMatch.group(3)!);
final y = int.parse(tileMatch.group(4)!);
final bytes = await _cache.getRawTile(styleHash, z, x, y);
if (bytes != null) {
request.response
..statusCode = HttpStatus.ok
..headers.contentType = ContentType('image', 'avif')
..add(bytes);
await request.response.close();
return;
}
}
request.response.statusCode = HttpStatus.notFound;
await request.response.close();
}
void dispose() {
stopServer();
stopPeerDiscovery();
_httpClient.close();
_peersController.close();
}
}
/// Simple counting semaphore for concurrency limiting.
class _Semaphore {
final int maxCount;
int _currentCount = 0;
final _waitQueue = <Completer<void>>[];
_Semaphore(this.maxCount);
Future<void> acquire() async {
if (_currentCount < maxCount) {
_currentCount++;
return;
}
final completer = Completer<void>();
_waitQueue.add(completer);
await completer.future;
}
void release() {
if (_waitQueue.isNotEmpty) {
_waitQueue.removeAt(0).complete();
} else {
_currentCount--;
}
}
}

View File

@@ -1,3 +1,5 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -119,7 +121,9 @@ Future<bool> showConnectionDialogFlow(
/// Connection Dialog with tabs for BLE devices and Network servers /// Connection Dialog with tabs for BLE devices and Network servers
class ConnectionDialog extends StatefulWidget { class ConnectionDialog extends StatefulWidget {
const ConnectionDialog({super.key}); final NetworkScannerService? networkScanner;
const ConnectionDialog({super.key, this.networkScanner});
@override @override
State<ConnectionDialog> createState() => _ConnectionDialogState(); State<ConnectionDialog> createState() => _ConnectionDialogState();
@@ -129,7 +133,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
late TabController _tabController; late TabController _tabController;
late final ConnectionProvider _connectionProvider; late final ConnectionProvider _connectionProvider;
final NetworkScannerService _networkScanner = NetworkScannerService(); late final NetworkScannerService _networkScanner;
final List<DiscoveredServer> _discoveredServers = []; final List<DiscoveredServer> _discoveredServers = [];
int _scannedCount = 0; int _scannedCount = 0;
int _totalToScan = 0; int _totalToScan = 0;
@@ -162,6 +166,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
context, context,
listen: false, listen: false,
); );
_networkScanner = widget.networkScanner ?? NetworkScannerService();
_networkScanner.onServerDiscovered = (server) { _networkScanner.onServerDiscovered = (server) {
if (!mounted) return; if (!mounted) return;
@@ -334,6 +339,9 @@ class _ConnectionDialogState extends State<ConnectionDialog>
required IconData icon, required IconData icon,
required String message, required String message,
required VoidCallback onRefresh, required VoidCallback onRefresh,
IconData? secondaryActionIcon,
String? secondaryActionTooltip,
VoidCallback? onSecondaryAction,
}) { }) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Container( return Container(
@@ -355,6 +363,18 @@ class _ConnectionDialogState extends State<ConnectionDialog>
), ),
), ),
), ),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (secondaryActionIcon != null)
IconButton(
tooltip: secondaryActionTooltip,
icon: Icon(
secondaryActionIcon,
color: theme.colorScheme.onPrimaryContainer,
),
onPressed: onSecondaryAction,
),
IconButton( IconButton(
icon: Icon( icon: Icon(
Icons.refresh_rounded, Icons.refresh_rounded,
@@ -364,9 +384,56 @@ class _ConnectionDialogState extends State<ConnectionDialog>
), ),
], ],
), ),
],
),
); );
} }
Future<String?> _promptForManualTcpHost() async {
return showDialog<String>(
context: context,
builder: (dialogContext) => _ManualTcpHostDialog(
initialHost: _connectionProvider.tcpHost,
),
);
}
Future<void> _connectManualTcpHost() async {
final host = await _promptForManualTcpHost();
if (host == null || !mounted) {
return;
}
final serverKey = '$host:${NetworkScannerService.defaultPort}';
final connectionProvider = context.read<ConnectionProvider>();
setState(() {
_connectingToServerKey = serverKey;
});
try {
final success = await connectionProvider.connectTcp(
host,
NetworkScannerService.defaultPort,
);
if (!success) {
throw Exception(
connectionProvider.error ??
'Failed to connect to $host:${NetworkScannerService.defaultPort}',
);
}
_closeOnSuccessfulConnection();
} catch (error) {
if (!mounted) {
return;
}
setState(() {
_connectingToServerKey = null;
});
_showConnectionError(error);
}
}
Widget _buildErrorBanner(String message) { Widget _buildErrorBanner(String message) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Container( return Container(
@@ -404,9 +471,12 @@ class _ConnectionDialogState extends State<ConnectionDialog>
required VoidCallback onAction, required VoidCallback onAction,
}) { }) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Center( return LayoutBuilder(
child: Padding( builder: (context, constraints) => SingleChildScrollView(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
@@ -442,6 +512,8 @@ class _ConnectionDialogState extends State<ConnectionDialog>
], ],
), ),
), ),
),
),
); );
} }
@@ -620,6 +692,11 @@ class _ConnectionDialogState extends State<ConnectionDialog>
message: showingCachedResults message: showingCachedResults
? 'Showing cached results. Tap refresh to rescan.' ? 'Showing cached results. Tap refresh to rescan.'
: 'Scanning local network for MeshCore WiFi devices on port 5000', : 'Scanning local network for MeshCore WiFi devices on port 5000',
secondaryActionIcon: Icons.add_rounded,
secondaryActionTooltip: 'Add IP address',
onSecondaryAction: _connectingToServerKey != null
? null
: _connectManualTcpHost,
onRefresh: _startNetworkScan, onRefresh: _startNetworkScan,
), ),
if (_networkScanner.isScanning) if (_networkScanner.isScanning)
@@ -768,6 +845,82 @@ class _ConnectionDialogState extends State<ConnectionDialog>
} }
} }
class _ManualTcpHostDialog extends StatefulWidget {
final String? initialHost;
const _ManualTcpHostDialog({this.initialHost});
@override
State<_ManualTcpHostDialog> createState() => _ManualTcpHostDialogState();
}
class _ManualTcpHostDialogState extends State<_ManualTcpHostDialog> {
late final TextEditingController _controller;
String? _errorText;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.initialHost);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _submit() {
final host = _controller.text.trim();
final parsedAddress = InternetAddress.tryParse(host);
if (parsedAddress == null) {
setState(() {
_errorText = 'Enter a valid IP address';
});
return;
}
Navigator.of(context).pop(parsedAddress.address);
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Connect by IP address'),
content: TextField(
controller: _controller,
autofocus: true,
keyboardType: TextInputType.url,
decoration: InputDecoration(
labelText: 'IP address',
hintText: '192.168.1.42',
helperText: 'Uses TCP port 5000',
border: const OutlineInputBorder(),
errorText: _errorText,
),
onChanged: (_) {
if (_errorText == null) {
return;
}
setState(() {
_errorText = null;
});
},
onSubmitted: (_) => _submit(),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: _submit,
child: const Text('Connect'),
),
],
);
}
}
typedef _TransportCardBuilder = typedef _TransportCardBuilder =
Widget Function({ Widget Function({
required IconData icon, required IconData icon,

View File

@@ -6,11 +6,9 @@ import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../models/contact.dart'; import '../../models/contact.dart';
import '../../models/path_history.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../../providers/app_provider.dart'; import '../../providers/app_provider.dart';
import '../../providers/connection_provider.dart'; import '../../providers/connection_provider.dart';
import '../../services/path_history_service.dart';
import '../../services/relay_candidate_sorter.dart'; import '../../services/relay_candidate_sorter.dart';
import '../../services/route_hash_preferences.dart'; import '../../services/route_hash_preferences.dart';
@@ -72,7 +70,6 @@ class ContactRouteDialog extends StatefulWidget {
class _ContactRouteDialogState extends State<ContactRouteDialog> { class _ContactRouteDialogState extends State<ContactRouteDialog> {
late final TextEditingController _controller; late final TextEditingController _controller;
late final TextEditingController _relaySearchController; late final TextEditingController _relaySearchController;
final PathHistoryService _pathHistoryService = PathHistoryService();
final RelayCandidateSorter _relayCandidateSorter = final RelayCandidateSorter _relayCandidateSorter =
const RelayCandidateSorter(); const RelayCandidateSorter();
int _selectedHashSize = RouteHashPreferences.defaultHashSize; int _selectedHashSize = RouteHashPreferences.defaultHashSize;
@@ -80,7 +77,6 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
String? _errorText; String? _errorText;
bool _showRoutingInfo = false; bool _showRoutingInfo = false;
List<Contact> _selectedMapHops = const []; List<Contact> _selectedMapHops = const [];
ContactPathHistory? _pathHistory;
@override @override
void initState() { void initState() {
@@ -91,7 +87,6 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
_relaySearchController = TextEditingController(); _relaySearchController = TextEditingController();
_controller.addListener(_reparse); _controller.addListener(_reparse);
_loadHashSizePreference(); _loadHashSizePreference();
_loadPathHistory();
_reparse(); _reparse();
} }
@@ -182,16 +177,6 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
_reparse(); _reparse();
} }
Future<void> _loadPathHistory() async {
await _pathHistoryService.initialize();
if (!mounted) return;
setState(() {
_pathHistory = _pathHistoryService.historyFor(
widget.contact.publicKeyHex,
);
});
}
String _tokenFor(Contact contact, int hashSize) { String _tokenFor(Contact contact, int hashSize) {
final hex = contact.publicKeyHex.toUpperCase(); final hex = contact.publicKeyHex.toUpperCase();
final length = hashSize * 2; final length = hashSize * 2;
@@ -237,21 +222,6 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
}); });
} }
void _applyHistoryRecord(PathRecord record) {
final canonicalText = _canonicalRouteFromBytes(
record.pathBytes,
hashSize: record.hashSize,
);
setState(() {
_controller.text = canonicalText;
_controller.selection = TextSelection.fromPosition(
TextPosition(offset: _controller.text.length),
);
_errorText = null;
});
_reparse();
}
LatLng? _resolveLastHopLocation() { LatLng? _resolveLastHopLocation() {
if (_selectedMapHops.isNotEmpty) { if (_selectedMapHops.isNotEmpty) {
return _selectedMapHops.last.displayLocation == null return _selectedMapHops.last.displayLocation == null
@@ -302,86 +272,6 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
); );
} }
String _canonicalRouteFromBytes(
List<int> pathBytes, {
required int hashSize,
}) {
final hops = <String>[];
for (var i = 0; i < pathBytes.length; i += hashSize) {
final hop = pathBytes.sublist(i, i + hashSize);
hops.add(
hop
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join()
.toUpperCase(),
);
}
return hops.join(',');
}
String _historySubtitle(PathRecord record) {
final attempts = record.successCount + record.failureCount;
final lastSeen = MaterialLocalizations.of(
context,
).formatShortDate(record.lastUsedAt);
final sourceLabel = switch (record.source) {
PathRecordSource.observed => 'Observed on mesh',
PathRecordSource.learned => 'Learned route',
};
final successRate = attempts == 0
? 'No send stats yet'
: '${(record.successRate * 100).round()}% success over $attempts send${attempts == 1 ? '' : 's'}';
final latency = record.lastRoundTripTimeMs > 0
? '${record.lastRoundTripTimeMs} ms'
: '';
return '$sourceLabel$successRate • Last used $lastSeen$latency';
}
Widget _buildHistoryRecordTile(PathRecord record, {String? title}) {
final canonicalText = _canonicalRouteFromBytes(
record.pathBytes,
hashSize: record.hashSize,
);
return Card(
margin: EdgeInsets.zero,
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
leading: title == null ? null : const Icon(Icons.alt_route),
title: title == null
? Text(
canonicalText,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 6),
Text(
canonicalText,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
),
],
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(_historySubtitle(record)),
),
trailing: FilledButton.tonal(
onPressed: () => _applyHistoryRecord(record),
child: Text(AppLocalizations.of(context)!.use),
),
),
);
}
Widget _buildPreviewSection() { Widget _buildPreviewSection() {
final previewRoute = _effectiveRoute; final previewRoute = _effectiveRoute;
if (previewRoute == null) { if (previewRoute == null) {
@@ -676,7 +566,6 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
_showRoutingInfo = !_showRoutingInfo; _showRoutingInfo = !_showRoutingInfo;
}); });
}, },
autoRouteRotationEnabled: appProvider.autoRouteRotationEnabled,
nearestRelayFallbackEnabled: appProvider.nearestRelayFallbackEnabled, nearestRelayFallbackEnabled: appProvider.nearestRelayFallbackEnabled,
clearPathOnMaxRetry: appProvider.clearPathOnMaxRetry, clearPathOnMaxRetry: appProvider.clearPathOnMaxRetry,
), ),
@@ -685,79 +574,6 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
); );
} }
Widget _buildHistoryTab() {
final records = List<PathRecord>.from(_pathHistory?.directPaths ?? const [])
..sort((a, b) => b.lastUsedAt.compareTo(a.lastUsedAt));
if (records.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
'No historical paths for this contact yet.',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
),
);
}
PathRecord? observedRecord;
for (final record in records) {
if (record.source == PathRecordSource.observed) {
observedRecord = record;
break;
}
}
final remainingRecords = observedRecord == null
? records
: records
.where((record) => !identical(record, observedRecord))
.toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () async {
await _pathHistoryService.clearHistoryForContact(widget.contact);
if (!mounted) return;
setState(() {
_pathHistory = _pathHistoryService.historyFor(
widget.contact.publicKeyHex,
);
});
},
child: const Text('Clear history'),
),
),
const SizedBox(height: 8),
if (observedRecord != null) ...[
_buildHistoryRecordTile(observedRecord, title: AppLocalizations.of(context)!.observedMeshRoute),
const SizedBox(height: 16),
],
if (remainingRecords.isEmpty)
Text(
observedRecord == null
? 'No additional route history yet.'
: 'Observed routes you start using will continue to build history here.',
style: Theme.of(context).textTheme.bodyMedium,
)
else
ListView.separated(
itemCount: remainingRecords.length,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
return _buildHistoryRecordTile(remainingRecords[index]);
},
),
],
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final effectiveRoute = _effectiveRoute; final effectiveRoute = _effectiveRoute;
@@ -802,14 +618,13 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
]; ];
return DefaultTabController( return DefaultTabController(
length: 3, length: 2,
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text('Set Path for ${widget.contact.displayName}'), title: Text('Set Path for ${widget.contact.displayName}'),
bottom: const TabBar( bottom: const TabBar(
tabs: [ tabs: [
Tab(text: 'Build'), Tab(text: 'Build'),
Tab(text: 'History'),
Tab(text: 'Info'), Tab(text: 'Info'),
], ],
), ),
@@ -827,12 +642,6 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
const SizedBox(height: 24), const SizedBox(height: 24),
], ],
), ),
ListView(
children: [
_buildHistoryTab(),
const SizedBox(height: 24),
],
),
_buildInfoTab( _buildInfoTab(
appProvider: appProvider, appProvider: appProvider,
routeCandidates: routeCandidates, routeCandidates: routeCandidates,
@@ -912,14 +721,12 @@ class _RouteMarkerDot extends StatelessWidget {
class _AutomationRoutingInfo extends StatelessWidget { class _AutomationRoutingInfo extends StatelessWidget {
final bool isExpanded; final bool isExpanded;
final VoidCallback onToggle; final VoidCallback onToggle;
final bool autoRouteRotationEnabled;
final bool nearestRelayFallbackEnabled; final bool nearestRelayFallbackEnabled;
final bool clearPathOnMaxRetry; final bool clearPathOnMaxRetry;
const _AutomationRoutingInfo({ const _AutomationRoutingInfo({
required this.isExpanded, required this.isExpanded,
required this.onToggle, required this.onToggle,
required this.autoRouteRotationEnabled,
required this.nearestRelayFallbackEnabled, required this.nearestRelayFallbackEnabled,
required this.clearPathOnMaxRetry, required this.clearPathOnMaxRetry,
}); });
@@ -968,7 +775,7 @@ class _AutomationRoutingInfo extends StatelessWidget {
if (isExpanded) ...[ if (isExpanded) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Room/contact sends keep one selected path for the whole send chain, retry up to 5 total attempts with 1s, 2s, 4s, and 8s backoff, then try one final nearest repeater if everything else fails.', 'Room/contact sends use the current direct path when one is known, switch to flood on the last normal retry, then try one final nearest repeater if everything else fails.',
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -981,12 +788,6 @@ class _AutomationRoutingInfo extends StatelessWidget {
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,
children: [ children: [
_InfoChip(
label: autoRouteRotationEnabled
? 'Auto route rotation on'
: 'Auto route rotation off',
icon: Icons.swap_horiz,
),
_InfoChip( _InfoChip(
label: nearestRelayFallbackEnabled label: nearestRelayFallbackEnabled
? 'Nearest repeater fallback on' ? 'Nearest repeater fallback on'
@@ -1004,7 +805,7 @@ class _AutomationRoutingInfo extends StatelessWidget {
] else ...[ ] else ...[
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(
'Shows retry, rotation, and final repeater fallback behavior.', 'Shows retry and final repeater fallback behavior.',
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
], ],

View File

@@ -6,6 +6,7 @@ import 'package:geolocator/geolocator.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import '../../models/contact.dart'; import '../../models/contact.dart';
import '../../models/room_login_state.dart'; import '../../models/room_login_state.dart';
import '../../providers/app_provider.dart';
import '../../providers/connection_provider.dart'; import '../../providers/connection_provider.dart';
import '../../providers/contacts_provider.dart'; import '../../providers/contacts_provider.dart';
import '../../providers/map_provider.dart'; import '../../providers/map_provider.dart';
@@ -66,6 +67,27 @@ class ContactTile extends StatelessWidget {
.toUpperCase(); .toUpperCase();
} }
Widget _buildChannelLocationSharingMarker(
BuildContext context,
ChannelLocationSharingMode mode,
) {
const accent = Color(0xFF16A34A);
return Container(
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
color: accent,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
child: Icon(
Icons.location_pin,
size: 11,
color: Colors.white,
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isChannel = contact.type == ContactType.channel; final isChannel = contact.type == ContactType.channel;
@@ -90,6 +112,7 @@ class ContactTile extends StatelessWidget {
// Get room login state if this is a room // Get room login state if this is a room
final connectionProvider = context.watch<ConnectionProvider>(); final connectionProvider = context.watch<ConnectionProvider>();
final messagesProvider = context.watch<MessagesProvider>(); final messagesProvider = context.watch<MessagesProvider>();
final appProvider = context.watch<AppProvider>();
final isPingInProgress = connectionProvider.isPingInProgress( final isPingInProgress = connectionProvider.isPingInProgress(
contact.publicKey, contact.publicKey,
); );
@@ -134,6 +157,10 @@ class ContactTile extends StatelessWidget {
final lastActivityAt = isRoomOrChannel final lastActivityAt = isRoomOrChannel
? messagesProvider.getLastActivityForDestination(contact) ? messagesProvider.getLastActivityForDestination(contact)
: null; : null;
final channelLocationSharingMode =
isChannel && !contact.isPublicChannel && contact.publicKey.length > 1
? appProvider.channelLocationSharingModeForChannel(contact.publicKey[1])
: null;
final timeAgoText = _getLocalizedRelativeTime( final timeAgoText = _getLocalizedRelativeTime(
context, context,
lastActivityAt ?? contact.lastSeenTime, lastActivityAt ?? contact.lastSeenTime,
@@ -239,6 +266,15 @@ class ContactTile extends StatelessWidget {
), ),
), ),
), ),
if (isChannel && channelLocationSharingMode != null)
Positioned(
bottom: -2,
right: -2,
child: _buildChannelLocationSharingMarker(
context,
channelLocationSharingMode,
),
),
if (contact.type == ContactType.room && if (contact.type == ContactType.room &&
roomLoginState != null) roomLoginState != null)
Positioned( Positioned(
@@ -383,6 +419,68 @@ class ContactTile extends StatelessWidget {
_showContactActionSheet(context, contact); _showContactActionSheet(context, contact);
} }
String _locationSharingErrorMessage(Object error) {
final message = error.toString();
if (message.startsWith('Bad state: ')) {
return message.substring('Bad state: '.length);
}
return message;
}
Future<void> _setChannelLocationSharing(
BuildContext context,
Contact contact,
bool enabled,
) async {
final channelIdx = contact.publicKey.length > 1 ? contact.publicKey[1] : 0;
try {
final result = await context.read<AppProvider>().setChannelLocationSharingEnabled(
channelIdx,
enabled,
);
if (!context.mounted) return;
ToastLogger.success(context, result.message);
} catch (error) {
if (!context.mounted) return;
ToastLogger.error(context, _locationSharingErrorMessage(error));
}
}
Future<void> _handleChannelLocationSharingAction(
BuildContext context,
Contact contact,
) async {
if (contact.isPublicChannel) {
if (!context.mounted) return;
ToastLogger.error(
context,
'Select a private channel to share your location.',
);
return;
}
final channelIdx = contact.publicKey.length > 1 ? contact.publicKey[1] : 0;
if (channelIdx <= 0) {
if (!context.mounted) return;
ToastLogger.error(
context,
'Select a private channel to share your location.',
);
return;
}
try {
final sharingState = await context
.read<AppProvider>()
.getChannelLocationSharingState(channelIdx);
if (!context.mounted) return;
await _setChannelLocationSharing(context, contact, !sharingState.isSharing);
} catch (error) {
if (!context.mounted) return;
ToastLogger.error(context, _locationSharingErrorMessage(error));
}
}
void _showContactActionSheet(BuildContext context, Contact contact) { void _showContactActionSheet(BuildContext context, Contact contact) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final canToggleFavourite = !contact.isChannel; final canToggleFavourite = !contact.isChannel;
@@ -402,7 +500,21 @@ class ContactTile extends StatelessWidget {
final canPreviewSensor = contact.isSensor; final canPreviewSensor = contact.isSensor;
final sensorsProvider = context.read<SensorsProvider>(); final sensorsProvider = context.read<SensorsProvider>();
final isInSensors = sensorsProvider.isWatched(contact.publicKeyHex); final isInSensors = sensorsProvider.isWatched(contact.publicKeyHex);
final channelLocationSharingFuture =
contact.type == ContactType.channel &&
!contact.isPublicChannel &&
contact.publicKey.length > 1
? context
.read<AppProvider>()
.getChannelLocationSharingState(contact.publicKey[1])
: null;
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) {
Widget buildSheet(ChannelLocationSharingState? sharingState) {
final primaryActions = <_ContactSheetAction>[ final primaryActions = <_ContactSheetAction>[
if (canMessage) if (canMessage)
_ContactSheetAction( _ContactSheetAction(
@@ -413,6 +525,19 @@ class ContactTile extends StatelessWidget {
await _openMessagesForContact(context, contact); await _openMessagesForContact(context, contact);
}, },
), ),
if (contact.type == ContactType.channel)
_ContactSheetAction(
icon: sharingState?.isSharing == true
? Icons.location_off_rounded
: Icons.share_location_rounded,
label: sharingState?.isSharing == true
? 'Stop sharing my location'
: 'Share my location',
onTap: () async {
Navigator.pop(context);
await _handleChannelLocationSharingAction(context, contact);
},
),
if (!contact.isChannel) if (!contact.isChannel)
_ContactSheetAction( _ContactSheetAction(
icon: Icons.share_outlined, icon: Icons.share_outlined,
@@ -427,7 +552,9 @@ class ContactTile extends StatelessWidget {
await Clipboard.setData(ClipboardData(text: url)); await Clipboard.setData(ClipboardData(text: url));
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.contactLinkCopiedToClipboard)), SnackBar(
content: Text(l10n.contactLinkCopiedToClipboard),
),
); );
} }
} else if (context.mounted) { } else if (context.mounted) {
@@ -507,7 +634,9 @@ class ContactTile extends StatelessWidget {
if (canAddToSensors) if (canAddToSensors)
_ContactSheetAction( _ContactSheetAction(
icon: isInSensors ? Icons.sensors : Icons.sensors_outlined, icon: isInSensors ? Icons.sensors : Icons.sensors_outlined,
label: isInSensors ? l10n.contactInSensors : l10n.contactAddToSensors, label: isInSensors
? l10n.contactInSensors
: l10n.contactAddToSensors,
enabled: !isInSensors, enabled: !isInSensors,
onTap: () async { onTap: () async {
Navigator.pop(context); Navigator.pop(context);
@@ -553,11 +682,7 @@ class ContactTile extends StatelessWidget {
), ),
]; ];
showModalBottomSheet<void>( return _ContactActionSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) => _ContactActionSheet(
contact: contact, contact: contact,
primaryActions: primaryActions, primaryActions: primaryActions,
secondaryActions: secondaryActions, secondaryActions: secondaryActions,
@@ -568,13 +693,27 @@ class ContactTile extends StatelessWidget {
? null ? null
: () async { : () async {
final toggled = contact.toggleFavourite(); final toggled = contact.toggleFavourite();
final connectionProvider = context.read<ConnectionProvider>(); final connectionProvider =
context.read<ConnectionProvider>();
await connectionProvider.addOrUpdateContact(toggled); await connectionProvider.addOrUpdateContact(toggled);
if (context.mounted) { if (context.mounted) {
await connectionProvider.getContact(contact.publicKey); await connectionProvider.getContact(contact.publicKey);
} }
}, },
), );
}
if (channelLocationSharingFuture == null) {
return buildSheet(null);
}
return FutureBuilder<ChannelLocationSharingState>(
future: channelLocationSharingFuture,
builder: (context, snapshot) {
return buildSheet(snapshot.data);
},
);
},
); );
} }
@@ -825,6 +964,7 @@ class ContactTile extends StatelessWidget {
return; return;
} }
await pathHistoryService.clearManualRouteFor(contact.publicKeyHex);
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
@@ -849,12 +989,7 @@ class ContactTile extends StatelessWidget {
signedEncodedPathLen: parsedRoute.signedEncodedPathLen, signedEncodedPathLen: parsedRoute.signedEncodedPathLen,
paddedPathBytes: parsedRoute.paddedPathBytes, paddedPathBytes: parsedRoute.paddedPathBytes,
); );
await pathHistoryService.clearHistoryForContact( await pathHistoryService.setManualRouteForContact(contact, parsedRoute);
contact.copyWith(
outPathLen: parsedRoute.signedEncodedPathLen,
outPath: Uint8List.fromList(parsedRoute.paddedPathBytes),
),
);
if (context.mounted) { if (context.mounted) {
final routeLabel = parsedRoute.hopCount == 0 final routeLabel = parsedRoute.hopCount == 0
? AppLocalizations.of(context)!.direct ? AppLocalizations.of(context)!.direct

View File

@@ -0,0 +1,303 @@
import 'dart:math' show log, ln2;
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
import '../../providers/offline_tiles_provider.dart';
/// Renders polygon/rectangle drawing interaction on the map.
///
/// Shows completed polygons, in-progress vertices, and handles tap events.
class PolygonDrawLayer extends StatelessWidget {
const PolygonDrawLayer({super.key});
@override
Widget build(BuildContext context) {
return Consumer<OfflineTilesProvider>(
builder: (context, provider, _) {
final polygons = <Polygon>[];
final markers = <Marker>[];
// Completed polygons
for (int i = 0; i < provider.polygons.length; i++) {
final poly = provider.polygons[i];
polygons.add(Polygon(
points: poly,
color: Colors.blue.withValues(alpha: 0.2),
borderColor: Colors.blue,
borderStrokeWidth: 2,
));
}
// In-progress polygon vertices
if (provider.drawingMode == DrawingMode.polygon &&
provider.currentVertices.isNotEmpty) {
final verts = provider.currentVertices;
// Draw lines between vertices
if (verts.length >= 2) {
polygons.add(Polygon(
points: verts,
color: Colors.orange.withValues(alpha: 0.1),
borderColor: Colors.orange,
borderStrokeWidth: 2,
));
}
// Draw vertex markers
for (final v in verts) {
markers.add(Marker(
point: v,
width: 12,
height: 12,
child: Container(
decoration: BoxDecoration(
color: Colors.orange,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
),
));
}
}
// Rectangle first corner marker
if (provider.drawingMode == DrawingMode.rectangle &&
provider.rectangleFirstCorner != null) {
markers.add(Marker(
point: provider.rectangleFirstCorner!,
width: 14,
height: 14,
child: Container(
decoration: BoxDecoration(
color: Colors.orange,
shape: BoxShape.rectangle,
border: Border.all(color: Colors.white, width: 2),
),
),
));
}
return Stack(
children: [
if (polygons.isNotEmpty) PolygonLayer(polygons: polygons),
if (markers.isNotEmpty) MarkerLayer(markers: markers),
],
);
},
);
}
}
/// Renders the download progress overlay tiles.
class DownloadProgressLayer extends StatelessWidget {
const DownloadProgressLayer({super.key});
@override
Widget build(BuildContext context) {
return Consumer<OfflineTilesProvider>(
builder: (context, provider, _) {
if (provider.tileOverlays.isEmpty) return const SizedBox.shrink();
final polygons = provider.tileOverlays.map((overlay) {
return Polygon(
points: [
LatLng(overlay.north, overlay.west),
LatLng(overlay.north, overlay.east),
LatLng(overlay.south, overlay.east),
LatLng(overlay.south, overlay.west),
],
color: overlay.isSkipped
? Colors.green.withValues(alpha: 0.15)
: Colors.orange.withValues(alpha: 0.15),
borderColor:
overlay.isSkipped ? Colors.green : Colors.orange,
borderStrokeWidth: 1,
);
}).toList();
return PolygonLayer(polygons: polygons);
},
);
}
}
/// Renders the coverage overlay for a cached style.
///
/// Only renders tiles at the zoom level closest to the current map zoom
/// to avoid drawing thousands of rectangles at once.
class CoverageLayer extends StatelessWidget {
final double currentZoom;
const CoverageLayer({super.key, required this.currentZoom});
@override
Widget build(BuildContext context) {
return Consumer<OfflineTilesProvider>(
builder: (context, provider, _) {
if (provider.coverageOverlays.isEmpty) {
return const SizedBox.shrink();
}
// Filter to tiles near the current zoom level to keep rendering fast.
// Show the zoom level that's <= current map zoom (best visual match).
final targetZoom = currentZoom.floor();
// Group overlays by approximate zoom level based on tile size.
// A tile at zoom z covers roughly (360/2^z) degrees of longitude.
// We filter by checking if the tile width matches the target zoom.
final filtered = <Polygon>[];
for (final overlay in provider.coverageOverlays) {
// Estimate the zoom level from the tile's longitude span
final lonSpan = (overlay.east - overlay.west).abs();
if (lonSpan <= 0) continue;
final estimatedZoom = (log(360.0 / lonSpan) / ln2).round();
if (estimatedZoom == targetZoom ||
estimatedZoom == targetZoom - 1 ||
estimatedZoom == targetZoom + 1) {
filtered.add(Polygon(
points: [
LatLng(overlay.north, overlay.west),
LatLng(overlay.north, overlay.east),
LatLng(overlay.south, overlay.east),
LatLng(overlay.south, overlay.west),
],
color: Colors.blue.withValues(alpha: 0.1),
borderColor: Colors.blue.withValues(alpha: 0.4),
borderStrokeWidth: 1,
));
}
// Cap at 1000 visible tiles to avoid jank
if (filtered.length >= 1000) break;
}
if (filtered.isEmpty) return const SizedBox.shrink();
return PolygonLayer(polygons: filtered);
},
);
}
}
/// Toolbar for drawing controls.
class DrawingToolbar extends StatelessWidget {
const DrawingToolbar({super.key});
@override
Widget build(BuildContext context) {
return Consumer<OfflineTilesProvider>(
builder: (context, provider, _) {
if (provider.isDownloading) return const SizedBox.shrink();
return Positioned(
right: 16,
top: 100,
child: Column(
children: [
_ToolButton(
icon: Icons.crop_square,
label: 'Rectangle',
isActive: provider.drawingMode == DrawingMode.rectangle,
onTap: () => provider.setDrawingMode(
provider.drawingMode == DrawingMode.rectangle
? DrawingMode.none
: DrawingMode.rectangle,
),
),
const SizedBox(height: 8),
_ToolButton(
icon: Icons.pentagon_outlined,
label: 'Polygon',
isActive: provider.drawingMode == DrawingMode.polygon,
onTap: () => provider.setDrawingMode(
provider.drawingMode == DrawingMode.polygon
? DrawingMode.none
: DrawingMode.polygon,
),
),
if (provider.drawingMode == DrawingMode.polygon &&
provider.currentVertices.length >= 3) ...[
const SizedBox(height: 8),
_ToolButton(
icon: Icons.check,
label: 'Finish',
isActive: false,
color: Colors.green,
onTap: () => provider.finishPolygon(),
),
],
if (provider.drawingMode == DrawingMode.polygon &&
provider.currentVertices.isNotEmpty) ...[
const SizedBox(height: 8),
_ToolButton(
icon: Icons.undo,
label: 'Undo',
isActive: false,
onTap: () => provider.undoLastVertex(),
),
],
if (provider.hasPolygons) ...[
const SizedBox(height: 8),
_ToolButton(
icon: Icons.delete_outline,
label: 'Clear',
isActive: false,
color: Colors.red,
onTap: () => provider.clearPolygons(),
),
],
],
),
);
},
);
}
}
class _ToolButton extends StatelessWidget {
final IconData icon;
final String label;
final bool isActive;
final Color? color;
final VoidCallback onTap;
const _ToolButton({
required this.icon,
required this.label,
required this.isActive,
this.color,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final activeColor = color ?? theme.colorScheme.primary;
return Tooltip(
message: label,
child: Material(
elevation: 2,
shape: const CircleBorder(),
color: isActive ? activeColor : theme.colorScheme.surface,
child: InkWell(
customBorder: const CircleBorder(),
onTap: onTap,
child: SizedBox(
width: 48,
height: 48,
child: Icon(
icon,
color: isActive
? theme.colorScheme.onPrimary
: (color ?? theme.colorScheme.onSurface),
),
),
),
),
);
}
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter_map/flutter_map.dart' as flutter_map;
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -21,7 +22,9 @@ import '../../providers/image_provider.dart' as ip;
import '../../providers/app_provider.dart'; import '../../providers/app_provider.dart';
import '../drawing_minimap_preview.dart'; import '../drawing_minimap_preview.dart';
import '../../models/ble_packet_log.dart'; import '../../models/ble_packet_log.dart';
import '../../models/message_contact_location.dart';
import '../../services/sar_template_service.dart'; import '../../services/sar_template_service.dart';
import '../../services/location_tracking_service.dart';
import '../../utils/toast_logger.dart'; import '../../utils/toast_logger.dart';
import '../../utils/sar_message_parser.dart'; import '../../utils/sar_message_parser.dart';
import '../../utils/key_comparison.dart'; import '../../utils/key_comparison.dart';
@@ -420,6 +423,8 @@ class _MessageBubbleState extends State<MessageBubble> {
} }
void _showMessageOptions(BuildContext context) { void _showMessageOptions(BuildContext context) {
final parentContext = context;
final l10n = AppLocalizations.of(context)!;
final connectionProvider = context.read<ConnectionProvider>(); final connectionProvider = context.read<ConnectionProvider>();
final selfPublicKey = connectionProvider.deviceInfo.publicKey; final selfPublicKey = connectionProvider.deviceInfo.publicKey;
final isOwnMessage = final isOwnMessage =
@@ -432,7 +437,7 @@ class _MessageBubbleState extends State<MessageBubble> {
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)), borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
), ),
builder: (context) => Padding( builder: (sheetContext) => Padding(
padding: const EdgeInsets.symmetric(vertical: 16), padding: const EdgeInsets.symmetric(vertical: 16),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -440,29 +445,29 @@ class _MessageBubbleState extends State<MessageBubble> {
if (!isOwnMessage && widget.onReply != null) if (!isOwnMessage && widget.onReply != null)
ListTile( ListTile(
leading: Icon(Icons.reply), leading: Icon(Icons.reply),
title: Text(AppLocalizations.of(context)!.reply), title: Text(l10n.reply),
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(sheetContext);
widget.onReply?.call(); widget.onReply?.call();
}, },
), ),
// Copy text option // Copy text option
ListTile( ListTile(
leading: Icon(Icons.copy), leading: Icon(Icons.copy),
title: Text(AppLocalizations.of(context)!.copyText), title: Text(l10n.copyText),
onTap: () { onTap: () {
Clipboard.setData(ClipboardData(text: widget.message.text)); Clipboard.setData(ClipboardData(text: widget.message.text));
Navigator.pop(context); Navigator.pop(sheetContext);
ToastLogger.success( ToastLogger.success(
context, parentContext,
AppLocalizations.of(context)!.textCopiedToClipboard, l10n.textCopiedToClipboard,
); );
}, },
), ),
// Save as Template option (only for SAR markers without existing template) // Save as Template option (only for SAR markers without existing template)
if (widget.message.isSarMarker) if (widget.message.isSarMarker)
Builder( Builder(
builder: (context) { builder: (_) {
// Extract emoji from SAR message // Extract emoji from SAR message
final sarInfo = SarMessageParser.parse(widget.message.text); final sarInfo = SarMessageParser.parse(widget.message.text);
if (sarInfo == null || sarInfo.emoji.isEmpty) { if (sarInfo == null || sarInfo.emoji.isEmpty) {
@@ -481,10 +486,10 @@ class _MessageBubbleState extends State<MessageBubble> {
return ListTile( return ListTile(
leading: Icon(Icons.bookmark_add), leading: Icon(Icons.bookmark_add),
title: Text(AppLocalizations.of(context)!.saveAsTemplate), title: Text(l10n.saveAsTemplate),
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(sheetContext);
_saveAsTemplate(context); _saveAsTemplate(parentContext);
}, },
); );
}, },
@@ -494,49 +499,49 @@ class _MessageBubbleState extends State<MessageBubble> {
widget.message.sarGpsCoordinates != null) widget.message.sarGpsCoordinates != null)
ListTile( ListTile(
leading: Icon(Icons.share_location), leading: Icon(Icons.share_location),
title: Text(AppLocalizations.of(context)!.shareLocation), title: Text(l10n.shareLocation),
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(sheetContext);
_shareLocation(context); _shareLocation(parentContext);
}, },
), ),
// Navigate to drawing option (only for drawing messages) // Navigate to drawing option (only for drawing messages)
if (widget.message.isDrawing && widget.message.drawingId != null) if (widget.message.isDrawing && widget.message.drawingId != null)
ListTile( ListTile(
leading: Icon(Icons.map), leading: Icon(Icons.map),
title: Text(AppLocalizations.of(context)!.navigateToDrawing), title: Text(l10n.navigateToDrawing),
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(sheetContext);
_navigateToDrawing(context); _navigateToDrawing(parentContext);
}, },
), ),
// Copy coordinates option (only for drawing messages) // Copy coordinates option (only for drawing messages)
if (widget.message.isDrawing && widget.message.drawingId != null) if (widget.message.isDrawing && widget.message.drawingId != null)
ListTile( ListTile(
leading: Icon(Icons.copy), leading: Icon(Icons.copy),
title: Text(AppLocalizations.of(context)!.copyCoordinates), title: Text(l10n.copyCoordinates),
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(sheetContext);
_copyDrawingCoordinates(context); _copyDrawingCoordinates(parentContext);
}, },
), ),
// Hide from map option (only for drawing messages) // Hide from map option (only for drawing messages)
if (widget.message.isDrawing && widget.message.drawingId != null) if (widget.message.isDrawing && widget.message.drawingId != null)
ListTile( ListTile(
leading: Icon(Icons.visibility_off), leading: Icon(Icons.visibility_off),
title: Text(AppLocalizations.of(context)!.hideFromMap), title: Text(l10n.hideFromMap),
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(sheetContext);
_hideDrawingFromMap(context); _hideDrawingFromMap(parentContext);
}, },
), ),
// Technical details option // Technical details option
ListTile( ListTile(
leading: Icon(Icons.data_object), leading: Icon(Icons.data_object),
title: Text(AppLocalizations.of(context)!.technicalDetails), title: Text(l10n.technicalDetails),
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(sheetContext);
_showTechnicalDetails(context); _showTechnicalDetails(parentContext);
}, },
), ),
if (!isOwnMessage && if (!isOwnMessage &&
@@ -544,22 +549,22 @@ class _MessageBubbleState extends State<MessageBubble> {
widget.message.pathLen < 255) widget.message.pathLen < 255)
ListTile( ListTile(
leading: Icon(Icons.route), leading: Icon(Icons.route),
title: Text(AppLocalizations.of(context)!.trace), title: Text(l10n.trace),
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(sheetContext);
_showTraceSheet(context); _showTraceSheet(parentContext);
}, },
), ),
// Delete message option // Delete message option
ListTile( ListTile(
leading: Icon(Icons.delete, color: Colors.red), leading: Icon(Icons.delete, color: Colors.red),
title: Text( title: Text(
AppLocalizations.of(context)!.delete, l10n.delete,
style: const TextStyle(color: Colors.red), style: const TextStyle(color: Colors.red),
), ),
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(sheetContext);
_showDeleteConfirmation(context); _showDeleteConfirmation(parentContext);
}, },
), ),
], ],
@@ -581,6 +586,7 @@ class _MessageBubbleState extends State<MessageBubble> {
} }
void _showTechnicalDetails(BuildContext context) { void _showTechnicalDetails(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final connectionProvider = context.read<ConnectionProvider>(); final connectionProvider = context.read<ConnectionProvider>();
final radioBw = connectionProvider.deviceInfo.radioBw; final radioBw = connectionProvider.deviceInfo.radioBw;
final radioSf = connectionProvider.deviceInfo.radioSf; final radioSf = connectionProvider.deviceInfo.radioSf;
@@ -595,6 +601,7 @@ class _MessageBubbleState extends State<MessageBubble> {
widget.message.isFromSelf(selfPublicKey); widget.message.isFromSelf(selfPublicKey);
String? senderName; String? senderName;
Contact? senderContact;
if (widget.message.senderPublicKeyPrefix != null) { if (widget.message.senderPublicKeyPrefix != null) {
final senderKeyHex = widget.message.senderPublicKeyPrefix! final senderKeyHex = widget.message.senderPublicKeyPrefix!
.sublist( .sublist(
@@ -605,7 +612,7 @@ class _MessageBubbleState extends State<MessageBubble> {
) )
.map((b) => b.toRadixString(16).padLeft(2, '0')) .map((b) => b.toRadixString(16).padLeft(2, '0'))
.join(''); .join('');
final senderContact = contactsProvider.contacts senderContact = contactsProvider.contacts
.where((c) => c.publicKeyHex.startsWith(senderKeyHex)) .where((c) => c.publicKeyHex.startsWith(senderKeyHex))
.firstOrNull; .firstOrNull;
senderName = senderContact?.advName; senderName = senderContact?.advName;
@@ -628,7 +635,7 @@ class _MessageBubbleState extends State<MessageBubble> {
recipientName = recipientContact?.advName; recipientName = recipientContact?.advName;
} }
final senderLocationSnapshot = messagesProvider.getMessageContactLocation( final storedLocationSnapshot = messagesProvider.getMessageContactLocation(
widget.message.id, widget.message.id,
); );
final receptionDetails = messagesProvider.getMessageReceptionDetails( final receptionDetails = messagesProvider.getMessageReceptionDetails(
@@ -709,6 +716,12 @@ class _MessageBubbleState extends State<MessageBubble> {
final routeMetadata = context final routeMetadata = context
.read<MessagesProvider>() .read<MessagesProvider>()
.getMessageRouteMetadata(widget.message.id); .getMessageRouteMetadata(widget.message.id);
final messageLocationSnapshot = _resolveDetailsLocationSnapshot(
storedSnapshot: storedLocationSnapshot,
senderContact: senderContact,
connectionProvider: connectionProvider,
isOwnMessage: isOwnMessage,
);
final rawLines = <String>[ final rawLines = <String>[
'Message ID: ${widget.message.id}', 'Message ID: ${widget.message.id}',
@@ -742,9 +755,9 @@ class _MessageBubbleState extends State<MessageBubble> {
'Retry result: ${retryResult ?? '-'}', 'Retry result: ${retryResult ?? '-'}',
'Sender key prefix: ${senderPrefixHex ?? '-'}', 'Sender key prefix: ${senderPrefixHex ?? '-'}',
'Sender name: ${senderName ?? widget.message.senderName ?? '-'}', 'Sender name: ${senderName ?? widget.message.senderName ?? '-'}',
'Sender location at receipt: ${senderLocationSnapshot?.formattedCoordinates ?? '-'}', '${widget.message.isSentMessage ? "Sent from location" : "Sender location at receipt"}: ${messageLocationSnapshot?.formattedCoordinates ?? '-'}',
'Sender location source: ${senderLocationSnapshot?.technicalSourceLabel ?? '-'}', '${widget.message.isSentMessage ? "Sent from source" : "Sender location source"}: ${messageLocationSnapshot?.technicalSourceLabel ?? '-'}',
'Sender location timestamp: ${senderLocationSnapshot?.sourceTimestamp?.toIso8601String() ?? '-'}', '${widget.message.isSentMessage ? "Sent from timestamp" : "Sender location timestamp"}: ${messageLocationSnapshot?.sourceTimestamp?.toIso8601String() ?? '-'}',
'Recipient key prefix: ${recipientPrefixHex ?? '-'}', 'Recipient key prefix: ${recipientPrefixHex ?? '-'}',
'Recipient name: ${recipientName ?? '-'}', 'Recipient name: ${recipientName ?? '-'}',
'Drawing flag: ${widget.message.isDrawing}', 'Drawing flag: ${widget.message.isDrawing}',
@@ -833,12 +846,9 @@ class _MessageBubbleState extends State<MessageBubble> {
} }
} }
void copyField(String value) { void copyField(BuildContext feedbackContext, String value) {
Clipboard.setData(ClipboardData(text: value)); Clipboard.setData(ClipboardData(text: value));
ToastLogger.success( ToastLogger.success(feedbackContext, l10n.textCopiedToClipboard);
context,
AppLocalizations.of(context)!.textCopiedToClipboard,
);
} }
showModalBottomSheet( showModalBottomSheet(
@@ -860,14 +870,14 @@ class _MessageBubbleState extends State<MessageBubble> {
children: [ children: [
Expanded( Expanded(
child: Text( child: Text(
AppLocalizations.of(context)!.messageTechnicalDetails, l10n.messageTechnicalDetails,
style: Theme.of(sheetContext).textTheme.titleLarge, style: Theme.of(sheetContext).textTheme.titleLarge,
), ),
), ),
IconButton( IconButton(
onPressed: () => Navigator.pop(sheetContext), onPressed: () => Navigator.pop(sheetContext),
icon: Icon(Icons.close), icon: Icon(Icons.close),
tooltip: AppLocalizations.of(context)!.close, tooltip: l10n.close,
), ),
], ],
), ),
@@ -907,6 +917,54 @@ class _MessageBubbleState extends State<MessageBubble> {
), ),
], ],
), ),
if (messageLocationSnapshot != null) ...[
const SizedBox(height: 12),
_techSection(
sheetContext,
icon: Icons.location_on,
title: l10n.location,
child: Column(
children: [
_buildLocationPreviewMap(
sheetContext,
messageLocationSnapshot.location,
),
const SizedBox(height: 12),
_detailRow(
sheetContext,
label: l10n.coordinates,
value: messageLocationSnapshot
.formattedCoordinates,
onCopy: () => copyField(
sheetContext,
messageLocationSnapshot.formattedCoordinates,
),
),
_detailRow(
sheetContext,
label: l10n.source,
value: messageLocationSnapshot
.technicalSourceLabel,
),
_detailRow(
sheetContext,
label: l10n.captured,
value: _formatRfc3339(
messageLocationSnapshot.sourceTimestamp ??
messageLocationSnapshot.capturedAt,
),
onCopy: () => copyField(
sheetContext,
_formatRfc3339(
messageLocationSnapshot.sourceTimestamp ??
messageLocationSnapshot.capturedAt,
),
),
),
],
),
),
],
if (widget.message.lastEchoRssiDbm != null || if (widget.message.lastEchoRssiDbm != null ||
snrDb != null || snrDb != null ||
rssiDbm != null) ...[ rssiDbm != null) ...[
@@ -914,13 +972,13 @@ class _MessageBubbleState extends State<MessageBubble> {
_techSection( _techSection(
sheetContext, sheetContext,
icon: Icons.network_check, icon: Icons.network_check,
title: AppLocalizations.of(context)!.linkQuality, title: l10n.linkQuality,
child: Column( child: Column(
children: [ children: [
if (rssiDbm != null) if (rssiDbm != null)
_signalRow( _signalRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.rssi, label: l10n.rssi,
valueLabel: '$rssiDbm dBm', valueLabel: '$rssiDbm dBm',
normalized: normalized:
((rssiDbm.toDouble() + 120.0) / 70.0) ((rssiDbm.toDouble() + 120.0) / 70.0)
@@ -935,7 +993,7 @@ class _MessageBubbleState extends State<MessageBubble> {
SizedBox(height: 8), SizedBox(height: 8),
_signalRow( _signalRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.snr, label: l10n.snr,
valueLabel: '${snrDb.toStringAsFixed(1)} dB', valueLabel: '${snrDb.toStringAsFixed(1)} dB',
normalized: ((snrDb + 20.0) / 40.0).clamp( normalized: ((snrDb + 20.0) / 40.0).clamp(
0.0, 0.0,
@@ -956,39 +1014,34 @@ class _MessageBubbleState extends State<MessageBubble> {
_techSection( _techSection(
sheetContext, sheetContext,
icon: Icons.tune, icon: Icons.tune,
title: AppLocalizations.of(context)!.delivery, title: l10n.delivery,
child: Column( child: Column(
children: [ children: [
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.status, label: l10n.status,
value: widget.message.deliveryStatus.name, value: widget.message.deliveryStatus.name,
), ),
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.receivedRfc3339,
context,
)!.receivedRfc3339,
value: _formatRfc3339(widget.message.receivedAt), value: _formatRfc3339(widget.message.receivedAt),
onCopy: () => copyField( onCopy: () => copyField(
sheetContext,
_formatRfc3339(widget.message.receivedAt), _formatRfc3339(widget.message.receivedAt),
), ),
), ),
if (widget.message.expectedAckTag != null) if (widget.message.expectedAckTag != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.expectedAckTag,
context,
)!.expectedAckTag,
value: widget.message.expectedAckTag! value: widget.message.expectedAckTag!
.toString(), .toString(),
), ),
if (receptionDetails?.senderToReceiptMs != null) if (receptionDetails?.senderToReceiptMs != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.senderToReceipt,
context,
)!.senderToReceipt,
value: _formatDurationMs( value: _formatDurationMs(
receptionDetails!.senderToReceiptMs!, receptionDetails!.senderToReceiptMs!,
), ),
@@ -996,9 +1049,7 @@ class _MessageBubbleState extends State<MessageBubble> {
if (receptionDetails?.estimatedTransmitMs != null) if (receptionDetails?.estimatedTransmitMs != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.estimatedTx,
context,
)!.estimatedTx,
value: _formatDurationMs( value: _formatDurationMs(
receptionDetails!.estimatedTransmitMs!, receptionDetails!.estimatedTransmitMs!,
), ),
@@ -1006,9 +1057,7 @@ class _MessageBubbleState extends State<MessageBubble> {
if (receptionDetails?.postTransmitDelayMs != null) if (receptionDetails?.postTransmitDelayMs != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.posttxDelay,
context,
)!.posttxDelay,
value: _formatDurationMs( value: _formatDurationMs(
receptionDetails!.postTransmitDelayMs!, receptionDetails!.postTransmitDelayMs!,
), ),
@@ -1016,87 +1065,80 @@ class _MessageBubbleState extends State<MessageBubble> {
if (widget.receivedCopies > 1) if (widget.receivedCopies > 1)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.receivedCopies,
context,
)!.receivedCopies,
value: '${widget.receivedCopies}', value: '${widget.receivedCopies}',
), ),
if (widget.message.suggestedTimeoutMs != null) if (widget.message.suggestedTimeoutMs != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.ackTimeout, label: l10n.ackTimeout,
value: value:
'${widget.message.suggestedTimeoutMs} ms', '${widget.message.suggestedTimeoutMs} ms',
), ),
if (retryCause != null) if (retryCause != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.retryCause, label: l10n.retryCause,
value: retryCause, value: retryCause,
), ),
if (retryMode != null) if (retryMode != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.retryMode, label: l10n.retryMode,
value: retryMode, value: retryMode,
), ),
if (widget.message.roundTripTimeMs != null) if (widget.message.roundTripTimeMs != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.roundTrip, label: l10n.roundTrip,
value: '${widget.message.roundTripTimeMs} ms', value: '${widget.message.roundTripTimeMs} ms',
), ),
if (widget.message.retryAttempt > 0) if (widget.message.retryAttempt > 0)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.retryAttempt,
context,
)!.retryAttempt,
value: '${widget.message.retryAttempt}/4', value: '${widget.message.retryAttempt}/4',
), ),
if (widget.message.lastRetryAt != null) if (widget.message.lastRetryAt != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.lastRetry, label: l10n.lastRetry,
value: _formatRfc3339( value: _formatRfc3339(
widget.message.lastRetryAt!, widget.message.lastRetryAt!,
), ),
onCopy: () => copyField( onCopy: () => copyField(
sheetContext,
_formatRfc3339(widget.message.lastRetryAt!), _formatRfc3339(widget.message.lastRetryAt!),
), ),
), ),
if (widget.message.usedFloodFallback) if (widget.message.usedFloodFallback)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.floodFallback,
context, value: l10n.yes,
)!.floodFallback,
value: AppLocalizations.of(context)!.yes,
), ),
if (routeMetadata?.canonicalPath if (routeMetadata?.canonicalPath
case final routePath?) case final routePath?)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.selectedPath,
context,
)!.selectedPath,
value: routePath, value: routePath,
onCopy: () => copyField(routePath), onCopy: () =>
copyField(sheetContext, routePath),
), ),
if (retryResult != null) if (retryResult != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.retryResult,
context,
)!.retryResult,
value: retryResult, value: retryResult,
), ),
if (packetPathHex != null) if (packetPathHex != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.pathBytes, label: l10n.pathBytes,
value: packetPathHex, value: packetPathHex,
onCopy: () => copyField(packetPathHex), onCopy: () =>
copyField(sheetContext, packetPathHex),
), ),
], ],
), ),
@@ -1105,18 +1147,19 @@ class _MessageBubbleState extends State<MessageBubble> {
_techSection( _techSection(
sheetContext, sheetContext,
icon: Icons.badge, icon: Icons.badge,
title: AppLocalizations.of(context)!.identity, title: l10n.identity,
child: Column( child: Column(
children: [ children: [
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.messageId, label: l10n.messageId,
value: widget.message.id, value: widget.message.id,
onCopy: () => copyField(widget.message.id), onCopy: () =>
copyField(sheetContext, widget.message.id),
), ),
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.sender, label: l10n.sender,
value: value:
senderName ?? senderName ??
widget.message.senderName ?? widget.message.senderName ??
@@ -1125,24 +1168,26 @@ class _MessageBubbleState extends State<MessageBubble> {
if (senderPrefixHex != null) if (senderPrefixHex != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.senderKey, label: l10n.senderKey,
value: senderPrefixHex, value: senderPrefixHex,
onCopy: () => copyField(senderPrefixHex), onCopy: () =>
copyField(sheetContext, senderPrefixHex),
), ),
if (recipientName != null) if (recipientName != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.recipient, label: l10n.recipient,
value: recipientName, value: recipientName,
), ),
if (recipientPrefixHex != null) if (recipientPrefixHex != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.recipientKey,
context,
)!.recipientKey,
value: recipientPrefixHex, value: recipientPrefixHex,
onCopy: () => copyField(recipientPrefixHex), onCopy: () => copyField(
sheetContext,
recipientPrefixHex,
),
), ),
], ],
), ),
@@ -1152,53 +1197,47 @@ class _MessageBubbleState extends State<MessageBubble> {
_techSection( _techSection(
sheetContext, sheetContext,
icon: Icons.graphic_eq, icon: Icons.graphic_eq,
title: AppLocalizations.of(context)!.voice, title: l10n.voice,
child: Column( child: Column(
children: [ children: [
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.voiceId, label: l10n.voiceId,
value: widget.message.voiceId ?? '-', value: widget.message.voiceId ?? '-',
), ),
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.envelope, label: l10n.envelope,
value: envelope != null value: envelope != null
? 'VE3 compact' ? 'VE3 compact'
: AppLocalizations.of(context)!.unknown, : l10n.unknown,
), ),
if (voiceSession != null) if (voiceSession != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.sessionProgress,
context,
)!.sessionProgress,
value: value:
'${voiceSession.receivedCount}/${voiceSession.total} segments', '${voiceSession.receivedCount}/${voiceSession.total} segments',
), ),
if (voiceSession != null) if (voiceSession != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.complete, label: l10n.complete,
value: voiceSession.isComplete value: voiceSession.isComplete
? AppLocalizations.of(context)!.yes ? l10n.yes
: AppLocalizations.of(context)!.no, : l10n.no,
), ),
if (transferDetails != null) if (transferDetails != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.transfers,
context,
)!.transfers,
value: '${transferDetails.totalTransfers}', value: '${transferDetails.totalTransfers}',
), ),
if (transferDetails != null && if (transferDetails != null &&
transferDetails.downloaders.isNotEmpty) transferDetails.downloaders.isNotEmpty)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.downloadedBy,
context,
)!.downloadedBy,
value: _formatDownloaderSummary( value: _formatDownloaderSummary(
transferDetails, transferDetails,
), ),
@@ -1206,9 +1245,7 @@ class _MessageBubbleState extends State<MessageBubble> {
if (voiceTxEstimate > Duration.zero) if (voiceTxEstimate > Duration.zero)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.estimatedTx,
context,
)!.estimatedTx,
value: voiceTxEstimate.inSeconds < 60 value: voiceTxEstimate.inSeconds < 60
? '~${voiceTxEstimate.inSeconds}s' ? '~${voiceTxEstimate.inSeconds}s'
: '~${voiceTxEstimate.inMinutes}m ${voiceTxEstimate.inSeconds % 60}s', : '~${voiceTxEstimate.inMinutes}m ${voiceTxEstimate.inSeconds % 60}s',
@@ -1222,28 +1259,28 @@ class _MessageBubbleState extends State<MessageBubble> {
_techSection( _techSection(
sheetContext, sheetContext,
icon: Icons.image_outlined, icon: Icons.image_outlined,
title: AppLocalizations.of(context)!.image, title: l10n.image,
child: Column( child: Column(
children: [ children: [
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.envelope, label: l10n.envelope,
value: 'IE1', value: 'IE1',
), ),
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.format, label: l10n.format,
value: imageEnvelope.format.label, value: imageEnvelope.format.label,
), ),
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.dimensions, label: l10n.dimensions,
value: value:
'${imageEnvelope.width}×${imageEnvelope.height}', '${imageEnvelope.width}×${imageEnvelope.height}',
), ),
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.segments, label: l10n.segments,
value: imageSession != null value: imageSession != null
? '${imageSession.receivedCount}/${imageSession.total}' ? '${imageSession.receivedCount}/${imageSession.total}'
: '${imageEnvelope.total}', : '${imageEnvelope.total}',
@@ -1251,26 +1288,22 @@ class _MessageBubbleState extends State<MessageBubble> {
if (imageSession != null) if (imageSession != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.complete, label: l10n.complete,
value: imageSession.isComplete value: imageSession.isComplete
? AppLocalizations.of(context)!.yes ? l10n.yes
: AppLocalizations.of(context)!.no, : l10n.no,
), ),
if (transferDetails != null) if (transferDetails != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.transfers,
context,
)!.transfers,
value: '${transferDetails.totalTransfers}', value: '${transferDetails.totalTransfers}',
), ),
if (transferDetails != null && if (transferDetails != null &&
transferDetails.downloaders.isNotEmpty) transferDetails.downloaders.isNotEmpty)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.downloadedBy,
context,
)!.downloadedBy,
value: _formatDownloaderSummary( value: _formatDownloaderSummary(
transferDetails, transferDetails,
), ),
@@ -1278,9 +1311,7 @@ class _MessageBubbleState extends State<MessageBubble> {
if (imageTxEstimate > Duration.zero) if (imageTxEstimate > Duration.zero)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.estimatedTx,
context,
)!.estimatedTx,
value: imageTxEstimate.inSeconds < 60 value: imageTxEstimate.inSeconds < 60
? '~${imageTxEstimate.inSeconds}s' ? '~${imageTxEstimate.inSeconds}s'
: '~${imageTxEstimate.inMinutes}m ${imageTxEstimate.inSeconds % 60}s', : '~${imageTxEstimate.inMinutes}m ${imageTxEstimate.inSeconds % 60}s',
@@ -1295,7 +1326,7 @@ class _MessageBubbleState extends State<MessageBubble> {
dense: true, dense: true,
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
title: Text( title: Text(
AppLocalizations.of(context)!.rawDump, l10n.rawDump,
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -1333,6 +1364,108 @@ class _MessageBubbleState extends State<MessageBubble> {
); );
} }
Widget _buildLocationPreviewMap(BuildContext context, LatLng location) {
return ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
height: 180,
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
),
),
child: flutter_map.FlutterMap(
options: flutter_map.MapOptions(
initialCenter: location,
initialZoom: 15,
interactionOptions: const flutter_map.InteractionOptions(
flags: flutter_map.InteractiveFlag.none,
),
),
children: [
flutter_map.TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.meshcore.sar',
),
flutter_map.MarkerLayer(
markers: [
flutter_map.Marker(
point: location,
width: 40,
height: 40,
child: Icon(
widget.message.isSentMessage
? Icons.near_me
: Icons.location_on,
color: Theme.of(context).colorScheme.primary,
size: 32,
),
),
],
),
],
),
),
),
);
}
MessageContactLocation? _resolveDetailsLocationSnapshot({
required MessageContactLocation? storedSnapshot,
required Contact? senderContact,
required ConnectionProvider connectionProvider,
required bool isOwnMessage,
}) {
if (storedSnapshot != null) {
return storedSnapshot;
}
if (isOwnMessage) {
final advLat = connectionProvider.deviceInfo.advLat;
final advLon = connectionProvider.deviceInfo.advLon;
if (advLat != null &&
advLon != null &&
(advLat != 0 || advLon != 0)) {
return MessageContactLocation(
location: LatLng(advLat / 1e6, advLon / 1e6),
source: 'shared',
capturedAt: widget.message.sentAt,
sourceTimestamp: widget.message.sentAt,
);
}
final currentPosition = LocationTrackingService().currentPosition;
if (currentPosition != null) {
return MessageContactLocation(
location: LatLng(
currentPosition.latitude,
currentPosition.longitude,
),
source: 'shared',
capturedAt: currentPosition.timestamp,
sourceTimestamp: currentPosition.timestamp,
);
}
}
final contactLocation = senderContact?.displayLocation;
if (contactLocation != null) {
final source =
senderContact?.telemetry?.gpsLocation != null
? 'telemetry'
: 'advert';
return MessageContactLocation(
location: contactLocation,
source: source,
capturedAt: senderContact?.locationUpdateTime ?? widget.message.receivedAt,
sourceTimestamp: senderContact?.locationUpdateTime,
);
}
return null;
}
Widget _techSection( Widget _techSection(
BuildContext context, { BuildContext context, {
required IconData icon, required IconData icon,

View File

@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../../models/contact.dart'; import '../../models/contact.dart';
import '../../providers/app_provider.dart';
import '../../providers/messages_provider.dart'; import '../../providers/messages_provider.dart';
import '../../utils/avatar_label_helper.dart'; import '../../utils/avatar_label_helper.dart';
import '../common/contact_avatar.dart'; import '../common/contact_avatar.dart';
@@ -259,6 +260,21 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
return _formatRelativeTime(context, lastActivityAt); return _formatRelativeTime(context, lastActivityAt);
} }
IconData _channelLocationSharingIcon(ChannelLocationSharingMode mode) {
switch (mode) {
case ChannelLocationSharingMode.hardware:
case ChannelLocationSharingMode.appFallback:
return Icons.location_pin;
}
}
Color _channelLocationSharingColor(
BuildContext context,
ChannelLocationSharingMode mode,
) {
return const Color(0xFF16A34A);
}
Widget _buildTextSubtitle( Widget _buildTextSubtitle(
BuildContext context, BuildContext context,
Contact contact, Contact contact,
@@ -312,6 +328,9 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
MessagesProvider? messagesProvider, MessagesProvider? messagesProvider,
) { ) {
final previewData = _channelPreviewData(context, channel, messagesProvider); final previewData = _channelPreviewData(context, channel, messagesProvider);
final sharingMode = context.watch<AppProvider>().channelLocationSharingModeForChannel(
channel.publicKey.length > 1 ? channel.publicKey[1] : 0,
);
return _buildRecipientCard( return _buildRecipientCard(
context: context, context: context,
@@ -319,6 +338,7 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
contact: channel, contact: channel,
title: channel.getLocalizedDisplayName(context), title: channel.getLocalizedDisplayName(context),
subtitle: _buildChannelSubtitle(context, channel, previewData), subtitle: _buildChannelSubtitle(context, channel, previewData),
avatarMarkerMode: sharingMode,
unreadCount: _unreadFor(channel), unreadCount: _unreadFor(channel),
isSelected: _isSelected('channel', channel), isSelected: _isSelected('channel', channel),
compact: true, compact: true,
@@ -838,6 +858,7 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
required Contact contact, required Contact contact,
required String title, required String title,
Widget? subtitle, Widget? subtitle,
ChannelLocationSharingMode? avatarMarkerMode,
required int unreadCount, required int unreadCount,
required bool isSelected, required bool isSelected,
bool compact = false, bool compact = false,
@@ -846,6 +867,12 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
}) { }) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final accentColor = _sectionColor(context, type); final accentColor = _sectionColor(context, type);
final markerColor = avatarMarkerMode == null
? accentColor
: _channelLocationSharingColor(context, avatarMarkerMode);
final markerIcon = avatarMarkerMode == null
? _typeIcon(contact)
: _channelLocationSharingIcon(avatarMarkerMode);
return Material( return Material(
color: Colors.transparent, color: Colors.transparent,
@@ -886,14 +913,14 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
color: colorScheme.surface, color: colorScheme.surface,
shape: BoxShape.circle, shape: BoxShape.circle,
border: Border.all( border: Border.all(
color: accentColor.withValues(alpha: 0.3), color: markerColor.withValues(alpha: 0.3),
), ),
), ),
alignment: Alignment.center, alignment: Alignment.center,
child: Icon( child: Icon(
_typeIcon(contact), markerIcon,
size: compact ? 9 : 10, size: compact ? 9 : 10,
color: accentColor, color: markerColor,
), ),
), ),
), ),

View File

@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 2026.0403.1+45 version: 2026.0405.1+47
environment: environment:
sdk: ^3.9.2 sdk: ^3.9.2

View File

@@ -10,13 +10,16 @@ import 'package:meshcore_sar_app/providers/helpers/message_retry_manager.dart';
import 'package:meshcore_sar_app/providers/messages_provider.dart'; import 'package:meshcore_sar_app/providers/messages_provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
Contact _buildContact() { Contact _buildContact({
int outPathLen = 1,
List<int> outPath = const [1, 2, 3, 4],
}) {
return Contact( return Contact(
publicKey: Uint8List.fromList(List<int>.generate(32, (i) => i)), publicKey: Uint8List.fromList(List<int>.generate(32, (i) => i)),
type: ContactType.chat, type: ContactType.chat,
flags: 0, flags: 0,
outPathLen: 1, outPathLen: outPathLen,
outPath: Uint8List.fromList([1, 2, 3, 4]), outPath: Uint8List.fromList(outPath),
advName: 'Teammate', advName: 'Teammate',
lastAdvert: 1700000000, lastAdvert: 1700000000,
advLat: 0, advLat: 0,
@@ -162,6 +165,42 @@ void main() {
expect(provider.messages.single.roundTripTimeMs, 190); expect(provider.messages.single.roundTripTimeMs, 190);
}); });
test('delivered flood message upgrades to learned direct route from ACK path', () {
final provider = MessagesProvider();
final contactWithoutRoute = _buildContact(outPathLen: -1, outPath: []);
provider.addSentMessage(
_buildDirectMessage('m1d'),
contact: contactWithoutRoute,
);
provider.updateMessageRouteSelection(
'm1d',
PathSelection.flood(),
routerFallbackAttempted: false,
);
provider.markMessageSent('m1d', 80, 250);
provider.markMessageDelivered(80, 200);
provider.queueDeliveredMessageRouteRefresh('m1d', contactWithoutRoute);
final applied = provider.applyDeliveredMessageRouteFromContact(
_buildContact(outPathLen: 2, outPath: const [0xAA, 0xBB]),
);
expect(applied, isTrue);
expect(provider.messages.single.deliveryStatus, MessageDeliveryStatus.delivered);
expect(provider.messages.single.usedFloodFallback, isFalse);
expect(provider.messages.single.pathLen, 2);
expect(
provider.getMessageRouteMetadata('m1d')?.mode,
PathSelectionMode.directCurrent,
);
expect(
provider.getMessageRouteMetadata('m1d')?.canonicalPath,
'AA,BB',
);
expect(provider.getMessageRouteMetadata('m1d')?.hopCount, 2);
});
test('channel messages are marked sent immediately', () { test('channel messages are marked sent immediately', () {
final provider = MessagesProvider(); final provider = MessagesProvider();
provider.resolveContactNameCallback = (_) => 'dz0ny (SI)'; provider.resolveContactNameCallback = (_) => 'dz0ny (SI)';

View File

@@ -1,9 +1,11 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:geolocator/geolocator.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/contact.dart'; import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/models/message.dart'; import 'package:meshcore_sar_app/models/message.dart';
import 'package:meshcore_sar_app/models/message_contact_location.dart'; import 'package:meshcore_sar_app/models/message_contact_location.dart';
import 'package:meshcore_sar_app/providers/messages_provider.dart'; import 'package:meshcore_sar_app/providers/messages_provider.dart';
import 'package:meshcore_sar_app/services/location_tracking_service.dart';
import 'package:meshcore_sar_app/utils/image_message_parser.dart'; import 'package:meshcore_sar_app/utils/image_message_parser.dart';
import 'package:meshcore_sar_app/utils/voice_message_parser.dart'; import 'package:meshcore_sar_app/utils/voice_message_parser.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
@@ -39,6 +41,7 @@ void main() {
group('MessagesProvider voice detection', () { group('MessagesProvider voice detection', () {
setUp(() { setUp(() {
SharedPreferences.setMockInitialValues({}); SharedPreferences.setMockInitialValues({});
LocationTrackingService().currentPosition = null;
}); });
test('marks VE3 envelope messages as voice', () { test('marks VE3 envelope messages as voice', () {
@@ -131,6 +134,50 @@ void main() {
expect(snapshot.location.longitude, closeTo(14.5058, 0.000001)); expect(snapshot.location.longitude, closeTo(14.5058, 0.000001));
}); });
test('persists current device location for sent messages', () async {
final provider = MessagesProvider();
LocationTrackingService().currentPosition = Position(
latitude: 46.0569,
longitude: 14.5058,
timestamp: DateTime.fromMillisecondsSinceEpoch(1700000003000),
accuracy: 4.5,
altitude: 310.0,
altitudeAccuracy: 6.0,
heading: 0.0,
headingAccuracy: 0.0,
speed: 0.0,
speedAccuracy: 0.0,
);
provider.addSentMessage(
Message(
id: 'sent-1',
messageType: MessageType.contact,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 1700000003,
text: 'outgoing status',
receivedAt: DateTime.now(),
senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]),
deliveryStatus: MessageDeliveryStatus.sending,
),
);
await Future<void>.delayed(const Duration(milliseconds: 50));
final restoredProvider = MessagesProvider();
await restoredProvider.initialize();
final snapshot = restoredProvider.getMessageContactLocation('sent-1');
expect(snapshot, isNotNull);
expect(snapshot!.source, equals('gps'));
expect(snapshot.location.latitude, closeTo(46.0569, 0.000001));
expect(snapshot.location.longitude, closeTo(14.5058, 0.000001));
expect(
snapshot.sourceTimestamp,
DateTime.fromMillisecondsSinceEpoch(1700000003000),
);
});
test('dedupes repeated incoming contact messages with same text', () { test('dedupes repeated incoming contact messages with same text', () {
final provider = MessagesProvider(); final provider = MessagesProvider();
final sender = Uint8List.fromList([0, 1, 2, 3, 4, 5]); final sender = Uint8List.fromList([0, 1, 2, 3, 4, 5]);

View File

@@ -10,11 +10,7 @@ void main() {
SharedPreferences.setMockInitialValues({}); SharedPreferences.setMockInitialValues({});
}); });
test('route preference defaults are disabled', () async { test('route preference defaults are clear-path disabled and fallback enabled', () async {
expect(
await MessagingRoutePreferences.getAutoRouteRotationEnabled(),
isFalse,
);
expect(await MessagingRoutePreferences.getClearPathOnMaxRetry(), isFalse); expect(await MessagingRoutePreferences.getClearPathOnMaxRetry(), isFalse);
expect( expect(
await MessagingRoutePreferences.getNearestRelayFallbackEnabled(), await MessagingRoutePreferences.getNearestRelayFallbackEnabled(),
@@ -23,18 +19,29 @@ void main() {
}); });
test('route preferences persist changes', () async { test('route preferences persist changes', () async {
await MessagingRoutePreferences.setAutoRouteRotationEnabled(true);
await MessagingRoutePreferences.setClearPathOnMaxRetry(true); await MessagingRoutePreferences.setClearPathOnMaxRetry(true);
await MessagingRoutePreferences.setNearestRelayFallbackEnabled(false); await MessagingRoutePreferences.setNearestRelayFallbackEnabled(false);
expect(
await MessagingRoutePreferences.getAutoRouteRotationEnabled(),
isTrue,
);
expect(await MessagingRoutePreferences.getClearPathOnMaxRetry(), isTrue); expect(await MessagingRoutePreferences.getClearPathOnMaxRetry(), isTrue);
expect( expect(
await MessagingRoutePreferences.getNearestRelayFallbackEnabled(), await MessagingRoutePreferences.getNearestRelayFallbackEnabled(),
isFalse, isFalse,
); );
}); });
test('legacy auto route rotation preference is removed during cleanup', () async {
SharedPreferences.setMockInitialValues({
'messaging_auto_route_rotation_enabled': true,
});
final prefs = await SharedPreferences.getInstance();
expect(prefs.getBool('messaging_auto_route_rotation_enabled'), isTrue);
await MessagingRoutePreferences.cleanupLegacySettings();
expect(
prefs.containsKey('messaging_auto_route_rotation_enabled'),
isFalse,
);
});
} }

View File

@@ -1,44 +1,31 @@
import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:meshcore_sar_app/models/contact.dart'; import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/models/path_history.dart';
import 'package:meshcore_sar_app/models/path_selection.dart'; import 'package:meshcore_sar_app/models/path_selection.dart';
import 'package:meshcore_sar_app/services/path_history_service.dart'; import 'package:meshcore_sar_app/services/path_history_service.dart';
Contact _buildContact({ Contact _buildContact({
required int seed, required int seed,
required List<int> pathBytes, List<int> pathBytes = const [],
required int hopCount, int hopCount = 0,
required int hashSize, int hashSize = 1,
}) { }) {
final encoded = ((hashSize - 1) << 6) | (hopCount & 0x3F); final encoded = pathBytes.isEmpty ? -1 : ((hashSize - 1) << 6) | (hopCount & 0x3F);
final outPath = Uint8List(ContactRouteCodec.maxPathBytes) final outPath = Uint8List(ContactRouteCodec.maxPathBytes);
..setRange(0, pathBytes.length, pathBytes); if (pathBytes.isNotEmpty) {
outPath.setRange(0, pathBytes.length, pathBytes);
return Contact(
publicKey: Uint8List.fromList(List<int>.generate(32, (i) => i + seed)),
type: ContactType.chat,
flags: 0,
outPathLen: ContactRouteCodec.toSignedDescriptor(encoded),
outPath: outPath,
advName: 'Contact $seed',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
} }
Contact _buildContactWithoutRoute({required int seed}) {
return Contact( return Contact(
publicKey: Uint8List.fromList(List<int>.generate(32, (i) => i + seed)), publicKey: Uint8List.fromList(List<int>.generate(32, (i) => i + seed)),
type: ContactType.chat, type: ContactType.chat,
flags: 0, flags: 0,
outPathLen: -1, outPathLen: encoded == -1 ? -1 : ContactRouteCodec.toSignedDescriptor(encoded),
outPath: Uint8List(0), outPath: encoded == -1 ? Uint8List(0) : outPath,
advName: 'Contact $seed', advName: 'Contact $seed',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0, advLat: 0,
@@ -54,304 +41,143 @@ void main() {
SharedPreferences.setMockInitialValues({}); SharedPreferences.setMockInitialValues({});
}); });
test('auto rotation ranks best paths before flood', () async { test('manual route override persists across reloads', () async {
final contact = _buildContact(seed: 1);
final service = PathHistoryService(); final service = PathHistoryService();
final contact = _buildContactWithoutRoute(seed: 0);
final best = PathSelection(
mode: PathSelectionMode.directHistorical,
pathBytes: Uint8List.fromList([0xAA, 0xBB]),
hopCount: 2,
hashSize: 1,
);
final second = PathSelection(
mode: PathSelectionMode.directHistorical,
pathBytes: Uint8List.fromList([0xCC, 0xDD]),
hopCount: 2,
hashSize: 1,
);
await service.initialize(); await service.initialize();
await service.recordLearnedPath(contact); await service.setManualSelectionFor(
await service.recordPathResult(
contact.publicKeyHex,
best,
success: true,
roundTripTimeMs: 120,
);
await service.recordPathResult(
contact.publicKeyHex,
best,
success: true,
roundTripTimeMs: 110,
);
await service.recordPathResult(
contact.publicKeyHex,
second,
success: true,
roundTripTimeMs: 200,
);
await service.recordPathResult(
contact.publicKeyHex,
second,
success: false,
);
final first = await service.getSelectionForContact(
contact,
autoRouteRotationEnabled: true,
);
final third = await service.getSelectionForContact(
contact,
autoRouteRotationEnabled: true,
);
final secondPick = await service.getSelectionForContact(
contact,
autoRouteRotationEnabled: true,
);
expect(first.mode, PathSelectionMode.directHistorical);
expect(first.canonicalPath, 'AA,BB');
expect(third.mode, PathSelectionMode.directHistorical);
expect(third.canonicalPath, 'CC,DD');
expect(secondPick.mode, PathSelectionMode.flood);
});
test(
'current learned route is reused first even with rotation enabled',
() async {
final service = PathHistoryService();
final contact = _buildContact(
seed: 9,
pathBytes: [0xAA, 0xBB, 0xCC],
hopCount: 1,
hashSize: 3,
);
await service.initialize();
await service.recordPathResult(
contact.publicKeyHex, contact.publicKeyHex,
PathSelection( PathSelection(
mode: PathSelectionMode.directHistorical, mode: PathSelectionMode.directCurrent,
pathBytes: Uint8List.fromList([0x11, 0x22, 0x33]),
hopCount: 1,
hashSize: 3,
),
success: true,
roundTripTimeMs: 90,
);
final selection = await service.getSelectionForContact(
contact,
autoRouteRotationEnabled: true,
);
expect(selection.mode, PathSelectionMode.directCurrent);
expect(selection.canonicalPath, 'AABBCC');
},
);
test('no history falls back to flood', () async {
final service = PathHistoryService();
final contact = _buildContactWithoutRoute(seed: 0);
final selection = await service.getSelectionForContact(
contact,
autoRouteRotationEnabled: true,
);
expect(selection.mode, PathSelectionMode.flood);
});
test(
'received public byte path is reversed before adding to history',
() async {
final service = PathHistoryService();
await service.initialize();
await service.recordReceivedBytePath('abc123', [
0x01,
0x02,
0x03,
0x04,
], 2);
final history = service.historyFor('abc123');
expect(history.directPaths, hasLength(1));
expect(history.directPaths.single.pathBytes, [0x03, 0x04, 0x01, 0x02]);
expect(history.directPaths.single.hashSize, 2);
expect(history.directPaths.single.hopCount, 2);
expect(history.directPaths.single.source, PathRecordSource.observed);
},
);
test(
'learned paths stay marked as observed after being seen on-air',
() async {
final service = PathHistoryService();
final contact = _buildContact(
seed: 3,
pathBytes: [0xAA, 0xBB],
hopCount: 2,
hashSize: 1,
);
await service.initialize();
await service.recordReceivedBytePath(contact.publicKeyHex, [
0xBB,
0xAA,
], 1);
await service.recordLearnedPath(contact);
final history = service.historyFor(contact.publicKeyHex);
expect(history.directPaths, hasLength(1));
expect(history.directPaths.single.source, PathRecordSource.observed);
},
);
test(
'confirmed direct delivery promotes an observed path to learned',
() async {
final service = PathHistoryService();
final contact = _buildContact(
seed: 4,
pathBytes: [0xAA, 0xBB],
hopCount: 2,
hashSize: 1,
);
await service.initialize();
await service.recordReceivedBytePath(contact.publicKeyHex, [
0xBB,
0xAA,
], 1);
await service.recordPathResult(
contact.publicKeyHex,
PathSelection(
mode: PathSelectionMode.directHistorical,
pathBytes: Uint8List.fromList([0xAA, 0xBB]), pathBytes: Uint8List.fromList([0xAA, 0xBB]),
hopCount: 2, hopCount: 2,
hashSize: 1, hashSize: 1,
), ),
success: true,
roundTripTimeMs: 150,
); );
final history = service.historyFor(contact.publicKeyHex); final reloaded = PathHistoryService();
expect(history.directPaths, hasLength(1)); final selection = await reloaded.getManualSelectionForContact(contact);
expect(history.directPaths.single.source, PathRecordSource.learned);
expect(history.directPaths.single.successCount, 1);
expect(history.directPaths.single.lastRoundTripTimeMs, 150);
},
);
test('clear history removes stored direct paths for one contact', () async {
final service = PathHistoryService();
await service.initialize();
await service.recordReceivedBytePath('abc123', [0x01, 0x02], 1);
await service.recordReceivedBytePath('def456', [0x03, 0x04], 1);
expect(service.historyFor('abc123').directPaths, hasLength(1));
expect(service.historyFor('def456').directPaths, hasLength(1));
await service.clearHistoryFor('abc123');
expect(service.historyFor('abc123').directPaths, isEmpty);
expect(service.historyFor('def456').directPaths, hasLength(1));
});
test(
'clear history for contact suppresses immediate relearn of current route',
() async {
final service = PathHistoryService();
final contact = _buildContact(
seed: 5,
pathBytes: [0xAA, 0xBB],
hopCount: 2,
hashSize: 1,
);
await service.initialize();
await service.recordLearnedPath(contact);
expect(service.historyFor(contact.publicKeyHex).directPaths, hasLength(1));
await service.clearHistoryForContact(contact);
expect(service.historyFor(contact.publicKeyHex).directPaths, isEmpty);
await service.recordLearnedPath(contact);
expect(service.historyFor(contact.publicKeyHex).directPaths, isEmpty);
await service.recordPathResult(
contact.publicKeyHex,
PathSelection(
mode: PathSelectionMode.directHistorical,
pathBytes: Uint8List.fromList([0xAA, 0xBB]),
hopCount: 2,
hashSize: 1,
),
success: true,
roundTripTimeMs: 120,
);
expect(service.historyFor(contact.publicKeyHex).directPaths, hasLength(1));
expect(
service.historyFor(contact.publicKeyHex).directPaths.single.source,
PathRecordSource.learned,
);
},
);
test('last successful direct path is chosen by location fit', () async {
final service = PathHistoryService();
final contact = _buildContact(
seed: 7,
pathBytes: [0xAA],
hopCount: 1,
hashSize: 1,
);
await service.initialize();
await service.recordPathResult(
contact.publicKeyHex,
PathSelection(
mode: PathSelectionMode.directHistorical,
pathBytes: Uint8List.fromList([0x11]),
hopCount: 1,
hashSize: 1,
),
success: true,
roundTripTimeMs: 120,
senderLatitude: 46.0,
senderLongitude: 14.0,
recipientLatitude: 46.1,
recipientLongitude: 14.1,
);
await service.recordPathResult(
contact.publicKeyHex,
PathSelection(
mode: PathSelectionMode.directHistorical,
pathBytes: Uint8List.fromList([0x22]),
hopCount: 1,
hashSize: 1,
),
success: true,
roundTripTimeMs: 90,
senderLatitude: 46.0001,
senderLongitude: 14.0001,
recipientLatitude: 46.1001,
recipientLongitude: 14.1001,
);
final selection = await service.getLastSuccessfulDirectSelection(
contact,
excludeSignature: 'aa',
senderLatitude: 46.0002,
senderLongitude: 14.0002,
recipientLatitude: 46.1002,
recipientLongitude: 14.1002,
);
expect(selection, isNotNull); expect(selection, isNotNull);
expect(selection!.mode, PathSelectionMode.directHistorical); expect(selection!.mode, PathSelectionMode.directCurrent);
expect(selection.canonicalPath, '22'); expect(selection.canonicalPath, 'AA,BB');
});
test('selection uses stored manual route before contact route', () async {
final contact = _buildContact(
seed: 2,
pathBytes: const [0x11, 0x22],
hopCount: 2,
hashSize: 1,
);
final service = PathHistoryService();
await service.initialize();
await service.setManualSelectionFor(
contact.publicKeyHex,
PathSelection(
mode: PathSelectionMode.directCurrent,
pathBytes: Uint8List.fromList([0xAA, 0xBB]),
hopCount: 2,
hashSize: 1,
),
);
final selection = await service.getSelectionForContact(contact);
expect(selection.mode, PathSelectionMode.directCurrent);
expect(selection.canonicalPath, 'AA,BB');
});
test('selection falls back to the current contact route', () async {
final contact = _buildContact(
seed: 3,
pathBytes: const [0x10, 0x20, 0x30],
hopCount: 1,
hashSize: 3,
);
final service = PathHistoryService();
final selection = await service.getSelectionForContact(contact);
expect(selection.mode, PathSelectionMode.directCurrent);
expect(selection.canonicalPath, '102030');
expect(selection.hashSize, 3);
expect(selection.hopCount, 1);
});
test('selection falls back to flood when no route exists', () async {
final contact = _buildContact(seed: 4);
final service = PathHistoryService();
final selection = await service.getSelectionForContact(contact);
expect(selection.mode, PathSelectionMode.flood);
expect(selection.pathBytes, isEmpty);
});
test('clearing manual route falls back to the contact route', () async {
final contact = _buildContact(
seed: 5,
pathBytes: const [0x01, 0x02],
hopCount: 2,
hashSize: 1,
);
final service = PathHistoryService();
await service.initialize();
await service.setManualSelectionFor(
contact.publicKeyHex,
PathSelection(
mode: PathSelectionMode.directCurrent,
pathBytes: Uint8List.fromList([0xAA, 0xBB]),
hopCount: 2,
hashSize: 1,
),
);
await service.clearManualRouteFor(contact.publicKeyHex);
final selection = await service.getSelectionForContact(contact);
expect(selection.mode, PathSelectionMode.directCurrent);
expect(selection.canonicalPath, '01,02');
});
test('initialize removes legacy path history storage', () async {
final contact = Contact(
publicKey: Uint8List.fromList([
0xAB,
0xC1,
0x23,
...List<int>.filled(29, 0),
]),
type: ContactType.chat,
flags: 0,
outPathLen: -1,
outPath: Uint8List(0),
advName: 'Legacy Contact',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
SharedPreferences.setMockInitialValues({
'contact_path_history_v2': '{"abc123":{"direct_paths":[]}}',
'contact_manual_path_overrides_v1': jsonEncode({
contact.publicKeyHex: {
'pathBytes': [0xAA, 0xBB],
'hopCount': 2,
'hashSize': 1,
},
}),
});
final service = PathHistoryService();
await service.initialize();
final prefs = await SharedPreferences.getInstance();
expect(prefs.containsKey('contact_path_history_v2'), isFalse);
final selection = await service.getManualSelectionForContact(contact);
expect(selection, isNotNull);
expect(selection!.canonicalPath, 'AA,BB');
}); });
} }

View File

@@ -3,6 +3,7 @@ import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/l10n/app_localizations.dart'; import 'package:meshcore_sar_app/l10n/app_localizations.dart';
import 'package:meshcore_sar_app/providers/connection_provider.dart'; import 'package:meshcore_sar_app/providers/connection_provider.dart';
import 'package:meshcore_sar_app/services/network_scanner_service.dart';
import 'package:meshcore_sar_app/widgets/connection_dialog.dart'; import 'package:meshcore_sar_app/widgets/connection_dialog.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -53,6 +54,51 @@ class _ConnectableFakeConnectionProvider extends ConnectionProvider {
} }
} }
class _TcpConnectableFakeConnectionProvider extends ConnectionProvider {
int connectTcpCalls = 0;
String? connectedHost;
int? connectedPort;
@override
List<ScannedDevice> get scannedDevices => const [];
@override
String? get error => null;
@override
Future<bool> connectTcp(String host, int port) async {
connectTcpCalls += 1;
connectedHost = host;
connectedPort = port;
return true;
}
}
class _FakeNetworkScannerService extends NetworkScannerService {
int scanCalls = 0;
@override
bool get isScanning => false;
@override
bool get hasCachedResults => false;
@override
List<DiscoveredServer> get cachedServers => const [];
@override
Future<List<DiscoveredServer>> scan({int? port}) async {
scanCalls += 1;
return const [];
}
@override
void clearCache() {}
@override
void stopScan() {}
}
void main() { void main() {
testWidgets('BLE scan waits for explicit user action', (tester) async { testWidgets('BLE scan waits for explicit user action', (tester) async {
final connectionProvider = _FakeConnectionProvider(); final connectionProvider = _FakeConnectionProvider();
@@ -126,4 +172,59 @@ void main() {
expect(connectionProvider.connectCalls, 1); expect(connectionProvider.connectCalls, 1);
expect(find.byType(ConnectionDialog), findsNothing); expect(find.byType(ConnectionDialog), findsNothing);
}); });
testWidgets('manual TCP connect accepts an IP address from the network tab', (
tester,
) async {
final connectionProvider = _TcpConnectableFakeConnectionProvider();
final networkScanner = _FakeNetworkScannerService();
await tester.pumpWidget(
ChangeNotifierProvider<ConnectionProvider>.value(
value: connectionProvider,
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder: (context) => Scaffold(
body: Center(
child: FilledButton(
onPressed: () {
showModalBottomSheet<Object?>(
context: context,
isScrollControlled: true,
builder: (_) => ConnectionDialog(
networkScanner: networkScanner,
),
);
},
child: const Text('Open'),
),
),
),
),
),
),
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
await tester.tap(find.text('Network'));
await tester.pumpAndSettle();
expect(networkScanner.scanCalls, 1);
await tester.tap(find.byTooltip('Add IP address'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), '192.168.1.42');
await tester.tap(find.widgetWithText(FilledButton, 'Connect'));
await tester.pumpAndSettle();
expect(connectionProvider.connectTcpCalls, 1);
expect(connectionProvider.connectedHost, '192.168.1.42');
expect(connectionProvider.connectedPort, NetworkScannerService.defaultPort);
expect(find.byType(ConnectionDialog), findsNothing);
});
} }

View File

@@ -1,9 +1,12 @@
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart' as flutter_map;
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:geolocator/geolocator.dart';
import 'package:meshcore_sar_app/l10n/app_localizations.dart'; import 'package:meshcore_sar_app/l10n/app_localizations.dart';
import 'package:meshcore_sar_app/models/message.dart'; import 'package:meshcore_sar_app/models/message.dart';
import 'package:meshcore_sar_app/models/message_contact_location.dart';
import 'package:meshcore_sar_app/providers/app_provider.dart'; import 'package:meshcore_sar_app/providers/app_provider.dart';
import 'package:meshcore_sar_app/providers/channels_provider.dart'; import 'package:meshcore_sar_app/providers/channels_provider.dart';
import 'package:meshcore_sar_app/providers/connection_provider.dart'; import 'package:meshcore_sar_app/providers/connection_provider.dart';
@@ -12,9 +15,11 @@ import 'package:meshcore_sar_app/providers/drawing_provider.dart';
import 'package:meshcore_sar_app/providers/image_provider.dart' as ip; import 'package:meshcore_sar_app/providers/image_provider.dart' as ip;
import 'package:meshcore_sar_app/providers/messages_provider.dart'; import 'package:meshcore_sar_app/providers/messages_provider.dart';
import 'package:meshcore_sar_app/providers/voice_provider.dart'; import 'package:meshcore_sar_app/providers/voice_provider.dart';
import 'package:meshcore_sar_app/services/location_tracking_service.dart';
import 'package:meshcore_sar_app/services/voice_codec_service.dart'; import 'package:meshcore_sar_app/services/voice_codec_service.dart';
import 'package:meshcore_sar_app/services/voice_player_service.dart'; import 'package:meshcore_sar_app/services/voice_player_service.dart';
import 'package:meshcore_sar_app/widgets/messages/message_bubble.dart'; import 'package:meshcore_sar_app/widgets/messages/message_bubble.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@@ -25,6 +30,7 @@ void main() {
setUp(() { setUp(() {
SharedPreferences.setMockInitialValues({}); SharedPreferences.setMockInitialValues({});
LocationTrackingService().currentPosition = null;
launchedUrls.clear(); launchedUrls.clear();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler( .setMockMethodCallHandler(
@@ -363,6 +369,99 @@ void main() {
await _disposeHarness(tester, harness); await _disposeHarness(tester, harness);
} }
}); });
testWidgets('technical details show a location map for stored snapshots', (
tester,
) async {
final harness = await _TestHarness.create();
try {
final message = Message(
id: 'message-location-details',
messageType: MessageType.contact,
senderPublicKeyPrefix: _prefix(71),
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700000000,
text: 'Location details',
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500),
deliveryStatus: MessageDeliveryStatus.received,
);
harness.messagesProvider.addMessage(
message,
contactLocationSnapshot: MessageContactLocation(
location: const LatLng(46.0569, 14.5058),
source: 'advert',
capturedAt: DateTime.fromMillisecondsSinceEpoch(1700000000600),
sourceTimestamp: DateTime.fromMillisecondsSinceEpoch(1700000000400),
),
);
await tester.pumpWidget(_buildApp(harness, message));
await tester.pumpAndSettle();
await tester.longPress(find.text('Location details'));
await tester.pumpAndSettle();
await tester.tap(find.text('Technical details'));
await tester.pumpAndSettle();
expect(find.byType(flutter_map.FlutterMap), findsOneWidget);
expect(find.text('46.056900, 14.505800'), findsOneWidget);
expect(find.text('shared advert'), findsOneWidget);
} finally {
await _disposeHarness(tester, harness);
}
});
testWidgets(
'technical details show a location map for sent channel messages using live position fallback',
(tester) async {
final harness = await _TestHarness.create();
try {
LocationTrackingService().currentPosition = Position(
latitude: 46.0569,
longitude: 14.5058,
timestamp: DateTime.fromMillisecondsSinceEpoch(1700000003000),
accuracy: 6.0,
altitude: 300.0,
altitudeAccuracy: 6.0,
heading: 0.0,
headingAccuracy: 0.0,
speed: 0.0,
speedAccuracy: 0.0,
);
final message = Message(
id: 'channel-live-fallback',
messageType: MessageType.channel,
senderPublicKeyPrefix: _prefix(81),
channelIdx: 0,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 1700000000,
text: 'Channel fallback',
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500),
deliveryStatus: MessageDeliveryStatus.sent,
);
await tester.pumpWidget(_buildApp(harness, message));
await tester.pumpAndSettle();
await tester.longPress(find.text('Channel fallback'));
await tester.pumpAndSettle();
await tester.tap(find.text('Technical details'));
await tester.pumpAndSettle();
expect(find.byType(flutter_map.FlutterMap), findsOneWidget);
expect(find.text('46.056900, 14.505800'), findsOneWidget);
expect(find.text('shared location'), findsOneWidget);
} finally {
await _disposeHarness(tester, harness);
}
},
);
} }
Widget _buildApp(_TestHarness harness, Message message) { Widget _buildApp(_TestHarness harness, Message message) {

View File

@@ -11,14 +11,11 @@
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@types/leaflet": "^1.9.21",
"astro": "^5.17.1", "astro": "^5.17.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"leaflet": "^1.9.4",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-leaflet": "^5.0.0",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
}, },
@@ -279,8 +276,6 @@
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
"@react-leaflet/core": ["@react-leaflet/core@3.0.0", "", { "peerDependencies": { "leaflet": "^1.9.0", "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
"@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="], "@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="],
@@ -397,12 +392,8 @@
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="],
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
"@types/leaflet": ["@types/leaflet@1.9.21", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w=="],
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
@@ -673,8 +664,6 @@
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"leaflet": ["leaflet@1.9.4", "", {}, "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
@@ -865,8 +854,6 @@
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
"react-leaflet": ["react-leaflet@5.0.0", "", { "dependencies": { "@react-leaflet/core": "^3.0.0" }, "peerDependencies": { "leaflet": "^1.9.0", "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw=="],
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],

View File

@@ -19,14 +19,11 @@
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@types/leaflet": "^1.9.21",
"astro": "^5.17.1", "astro": "^5.17.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"leaflet": "^1.9.4",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-leaflet": "^5.0.0",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18" "tailwindcss": "^4.1.18"
}, },

View File

@@ -4,7 +4,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
type WindowKey = "24h" | "7d" | "30d"; type WindowKey = "24h" | "7d";
type PacketTypeEntry = { type PacketTypeEntry = {
key: string; key: string;
@@ -34,14 +34,6 @@ type ChartPoint = {
reports: number; reports: number;
}; };
type LocationPoint = {
key6: string;
city: string;
country: string;
latitude: number;
longitude: number;
};
type AppVersionEntry = { type AppVersionEntry = {
version: string; version: string;
reporters: number; reporters: number;
@@ -81,7 +73,6 @@ type DashboardResponse = {
pathModeTotals: PathModeEntry[]; pathModeTotals: PathModeEntry[];
recentReporters: ReporterSummary[]; recentReporters: ReporterSummary[];
chartPoints: ChartPoint[]; chartPoints: ChartPoint[];
locationPoints: LocationPoint[];
appVersions: AppVersionEntry[]; appVersions: AppVersionEntry[];
trafficComposition: TrafficComposition; trafficComposition: TrafficComposition;
multiHopRatio: MultiHopRatio; multiHopRatio: MultiHopRatio;
@@ -91,7 +82,6 @@ type DashboardResponse = {
const WINDOW_OPTIONS: Array<{ key: WindowKey; label: string }> = [ const WINDOW_OPTIONS: Array<{ key: WindowKey; label: string }> = [
{ key: "24h", label: "24h" }, { key: "24h", label: "24h" },
{ key: "7d", label: "7 days" }, { key: "7d", label: "7 days" },
{ key: "30d", label: "30 days" },
]; ];
const PACKET_TYPE_INFO: Record<string, { title: string; summary: string }> = { const PACKET_TYPE_INFO: Record<string, { title: string; summary: string }> = {
@@ -396,18 +386,6 @@ export function DashboardShell() {
{/* Map + Traffic trend */} {/* Map + Traffic trend */}
<section className="space-y-4"> <section className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Reporter Locations</CardTitle>
<CardDescription>
Approximate locations from Cloudflare edge nodes, not device GPS.
</CardDescription>
</CardHeader>
<CardContent>
<ReporterMap locations={summary?.locationPoints ?? []} />
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Observations Over Time</CardTitle> <CardTitle>Observations Over Time</CardTitle>
@@ -472,15 +450,40 @@ export function DashboardShell() {
// --- Composition stacked area chart --- // --- Composition stacked area chart ---
const MIN_CHART_W = 320;
const COMPOSITION_CHART_H = 236;
function useResponsiveChartWidth(minWidth = MIN_CHART_W) {
const [element, setElement] = useState<HTMLDivElement | null>(null);
const [width, setWidth] = useState(minWidth);
useEffect(() => {
if (!element) return;
const sync = () => setWidth(Math.max(element.clientWidth, minWidth));
sync();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(sync);
observer.observe(element);
return () => { observer.disconnect(); };
}, [element, minWidth]);
return { setElement, width };
}
function CompositionChart({ points }: { points: CompositionPoint[] }) { function CompositionChart({ points }: { points: CompositionPoint[] }) {
const [hover, setHover] = useState<number | null>(null); const [hover, setHover] = useState<number | null>(null);
const { setElement, width } = useResponsiveChartWidth();
const count = points.length; const count = points.length;
if (count === 0) return null; if (count === 0) return null;
const maxVal = Math.max(...points.map((p) => p.human + p.overhead), 1); const maxVal = Math.max(...points.map((p) => p.human + p.overhead), 1);
const innerW = 100;
const innerH = 188;
const pad = { top: 16, right: 16, bottom: 32, left: 48 }; const pad = { top: 16, right: 16, bottom: 32, left: 48 };
const innerW = Math.max(width - pad.left - pad.right, 240);
const innerH = COMPOSITION_CHART_H - pad.top - pad.bottom;
const xs = points.map((_, i) => i / Math.max(count - 1, 1)); const xs = points.map((_, i) => i / Math.max(count - 1, 1));
@@ -495,11 +498,10 @@ function CompositionChart({ points }: { points: CompositionPoint[] }) {
const gridLines = niceGridLines(maxVal, 3); const gridLines = niceGridLines(maxVal, 3);
return ( return (
<div className="relative select-none"> <div ref={setElement} className="relative select-none">
<svg <svg
viewBox={`${-pad.left} ${-pad.top} ${innerW + pad.left + pad.right} ${innerH + pad.top + pad.bottom}`} viewBox={`${-pad.left} ${-pad.top} ${innerW + pad.left + pad.right} ${innerH + pad.top + pad.bottom}`}
className="h-auto max-h-[280px] w-full" className="h-[236px] w-full"
preserveAspectRatio="xMidYMid meet"
onMouseLeave={() => setHover(null)} onMouseLeave={() => setHover(null)}
> >
<defs> <defs>
@@ -591,6 +593,7 @@ const CHART_PAD = { top: 20, right: 16, bottom: 32, left: 48 };
function TrafficChart({ points, maxValue: _rawMax }: { points: ChartPoint[]; maxValue: number }) { function TrafficChart({ points, maxValue: _rawMax }: { points: ChartPoint[]; maxValue: number }) {
const [hover, setHover] = useState<number | null>(null); const [hover, setHover] = useState<number | null>(null);
const { setElement, width } = useResponsiveChartWidth();
const count = points.length; const count = points.length;
if (count === 0) return null; if (count === 0) return null;
@@ -601,7 +604,7 @@ function TrafficChart({ points, maxValue: _rawMax }: { points: ChartPoint[]; max
})); }));
const maxValue = Math.max(...normalized.map((p) => p.perNode), 1); const maxValue = Math.max(...normalized.map((p) => p.perNode), 1);
const innerW = 100; const innerW = Math.max(width - CHART_PAD.left - CHART_PAD.right, 240);
const innerH = CHART_H - CHART_PAD.top - CHART_PAD.bottom; const innerH = CHART_H - CHART_PAD.top - CHART_PAD.bottom;
const peakIdx = normalized.reduce((best, p, i) => (p.perNode > normalized[best].perNode ? i : best), 0); const peakIdx = normalized.reduce((best, p, i) => (p.perNode > normalized[best].perNode ? i : best), 0);
@@ -621,11 +624,10 @@ function TrafficChart({ points, maxValue: _rawMax }: { points: ChartPoint[]; max
.filter((_, i) => i % labelStep === 0 || i === count - 1); .filter((_, i) => i % labelStep === 0 || i === count - 1);
return ( return (
<div className="relative select-none"> <div ref={setElement} className="relative select-none">
<svg <svg
viewBox={`${-CHART_PAD.left} ${-CHART_PAD.top} ${innerW + CHART_PAD.left + CHART_PAD.right} ${CHART_H}`} viewBox={`${-CHART_PAD.left} ${-CHART_PAD.top} ${innerW + CHART_PAD.left + CHART_PAD.right} ${CHART_H}`}
className="h-auto max-h-[280px] w-full" className="h-[240px] w-full"
preserveAspectRatio="xMidYMid meet"
onMouseLeave={() => setHover(null)} onMouseLeave={() => setHover(null)}
> >
<defs> <defs>
@@ -796,103 +798,6 @@ function EmptyState({ label }: { label: string }) {
); );
} }
function ReporterMap({ locations }: { locations: LocationPoint[] }) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted || typeof window === "undefined") {
return (
<div className="flex min-h-[360px] items-center justify-center rounded-xl bg-secondary/30 text-sm text-muted-foreground">
Loading map...
</div>
);
}
return <LeafletMap locations={locations} />;
}
function LeafletMap({ locations }: { locations: LocationPoint[] }) {
const [leaflet, setLeaflet] = useState<{
MapContainer: typeof import("react-leaflet").MapContainer;
TileLayer: typeof import("react-leaflet").TileLayer;
CircleMarker: typeof import("react-leaflet").CircleMarker;
Tooltip: typeof import("react-leaflet").Tooltip;
L: typeof import("leaflet");
} | null>(null);
useEffect(() => {
Promise.all([
import("react-leaflet"),
import("leaflet"),
]).then(([rl, L]) => {
setLeaflet({
MapContainer: rl.MapContainer,
TileLayer: rl.TileLayer,
CircleMarker: rl.CircleMarker,
Tooltip: rl.Tooltip,
L: L.default ?? L,
});
});
}, []);
if (!leaflet) {
return (
<div className="flex min-h-[360px] items-center justify-center rounded-xl bg-secondary/30 text-sm text-muted-foreground">
Loading map...
</div>
);
}
const { MapContainer, TileLayer, CircleMarker, Tooltip } = leaflet;
const center: [number, number] = locations.length
? [
locations.reduce((s, l) => s + l.latitude, 0) / locations.length,
locations.reduce((s, l) => s + l.longitude, 0) / locations.length,
]
: [46.0, 14.5];
return (
<>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<div className="overflow-hidden rounded-xl">
<MapContainer
center={center}
zoom={locations.length > 1 ? 4 : 8}
scrollWheelZoom={true}
style={{ height: 360, width: "100%" }}
attributionControl={true}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{locations.map((loc) => (
<CircleMarker
key={`${loc.latitude}-${loc.longitude}-${loc.city}`}
center={[loc.latitude, loc.longitude]}
radius={8}
pathOptions={{
color: "hsl(217, 91%, 60%)",
fillColor: "hsl(217, 91%, 70%)",
fillOpacity: 0.6,
weight: 2,
}}
>
<Tooltip>
{loc.city}, {loc.country}
</Tooltip>
</CircleMarker>
))}
</MapContainer>
</div>
</>
);
}
function formatRelative(value: string) { function formatRelative(value: string) {
const diffMs = Date.now() - new Date(value).getTime(); const diffMs = Date.now() - new Date(value).getTime();
const diffMin = Math.floor(diffMs / 60000); const diffMin = Math.floor(diffMs / 60000);

View File

@@ -20,7 +20,7 @@ import "../index.css";
<slot /> <slot />
<script> <script>
const mq = window.matchMedia("(prefers-color-scheme: dark)"); const mq = window.matchMedia("(prefers-color-scheme: dark)");
function apply(e) { document.documentElement.classList.toggle("dark", e.matches); } function apply(e: MediaQueryList | MediaQueryListEvent) { document.documentElement.classList.toggle("dark", e.matches); }
apply(mq); apply(mq);
mq.addEventListener("change", apply); mq.addEventListener("change", apply);
</script> </script>

View File

@@ -4,6 +4,7 @@ import {
extractCfGeo, extractCfGeo,
jsonHeaders, jsonHeaders,
loadDashboardSummary, loadDashboardSummary,
purgeOldReports,
validateIngestPayload, validateIngestPayload,
type Env, type Env,
type IngestPayload, type IngestPayload,
@@ -82,6 +83,9 @@ async function handleIngest(request: Request, env: Env): Promise<Response> {
const result = await env.DB.prepare(REPORT_INSERT_SQL).bind(...values).run(); const result = await env.DB.prepare(REPORT_INSERT_SQL).bind(...values).run();
const changes = Number((result.meta as { changes?: number }).changes ?? 0); const changes = Number((result.meta as { changes?: number }).changes ?? 0);
// Purge reports older than 7 days (best-effort, don't block response)
void purgeOldReports(env).catch(() => {});
return Response.json( return Response.json(
{ {
ok: true, ok: true,
@@ -107,7 +111,7 @@ async function handleDashboard(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url); const url = new URL(request.url);
const windowParam = url.searchParams.get("window"); const windowParam = url.searchParams.get("window");
const summary = await loadDashboardSummary(env, windowParam); const summary = await loadDashboardSummary(env, windowParam);
const cacheTtl = windowParam === "7d" || windowParam === "30d" ? 86400 : 60; const cacheTtl = windowParam === "7d" ? 86400 : 60;
return Response.json( return Response.json(
{ {
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),

View File

@@ -85,13 +85,10 @@ const WINDOW_OPTIONS = {
durationMs: 7 * 24 * 60 * 60 * 1000, durationMs: 7 * 24 * 60 * 60 * 1000,
bucket: "day", bucket: "day",
}, },
"30d": {
label: "Last 30 days",
durationMs: 30 * 24 * 60 * 60 * 1000,
bucket: "day",
},
} as const; } as const;
const RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
export const jsonHeaders = { export const jsonHeaders = {
"content-type": "application/json; charset=utf-8", "content-type": "application/json; charset=utf-8",
} as const; } as const;
@@ -269,8 +266,7 @@ export function buildWindowFilter(
): WindowFilter { ): WindowFilter {
const windowKey = const windowKey =
requestedWindow === "24h" || requestedWindow === "24h" ||
requestedWindow === "7d" || requestedWindow === "7d"
requestedWindow === "30d"
? requestedWindow ? requestedWindow
: "24h"; : "24h";
const option = WINDOW_OPTIONS[windowKey]; const option = WINDOW_OPTIONS[windowKey];
@@ -282,6 +278,19 @@ export function buildWindowFilter(
}; };
} }
export async function purgeOldReports(
env: Env,
now: Date = new Date(),
): Promise<number> {
const cutoff = new Date(now.getTime() - RETENTION_MS).toISOString();
const result = await env.DB.prepare(
"DELETE FROM reports WHERE window_end < ?",
)
.bind(cutoff)
.run();
return Number((result.meta as { changes?: number }).changes ?? 0);
}
export async function loadDashboardSummary( export async function loadDashboardSummary(
env: Env, env: Env,
requestedWindow: string | null, requestedWindow: string | null,