Compare commits

..

10 Commits

Author SHA1 Message Date
Janez T
05153c9ceb Clarify path_provider override 2026-03-08 09:30:30 +01:00
Janez T
cd508d5f74 Modernize device settings screen 2026-03-08 09:24:26 +01:00
Janez T
937f91e496 Preserve contact telemetry data 2026-03-08 09:23:07 +01:00
Janez T
1f826ae4c2 Set simple mode default 2026-03-08 09:19:13 +01:00
Janez T
2e15ccb3f3 Fix outdated packages 2026-03-08 09:11:30 +01:00
Janez T
e904d53d8b Bump Flutter and iOS build versions 2026-03-07 21:13:44 +01:00
Janez T
c4aef05dcf Use flutter_map main source 2026-03-07 21:10:54 +01:00
Janez T
1c3af4f927 Upgrade flutter packages and limit 2026-03-07 20:59:01 +01:00
Janez T
fe2e08c4e4 Add faster location update channel 2026-03-07 20:38:49 +01:00
Janez T
3433eae3a6 Remove unused path_provider entry 2026-03-07 20:23:09 +01:00
39 changed files with 3220 additions and 5190 deletions

View File

@@ -12,7 +12,7 @@ permissions:
id-token: write id-token: write
env: env:
FLUTTER_VERSION: "3.35.6" FLUTTER_VERSION: "3.41.4"
APP_NAME: meshcore-sar APP_NAME: meshcore-sar
ANDROID_NDK_VERSION: "27.0.12077973" ANDROID_NDK_VERSION: "27.0.12077973"

View File

@@ -10,7 +10,7 @@ on:
workflow_dispatch: workflow_dispatch:
env: env:
FLUTTER_VERSION: '3.35.6' FLUTTER_VERSION: '3.41.4'
jobs: jobs:
analyze: analyze:

View File

@@ -59,15 +59,8 @@ PODS:
- Flutter - Flutter
- nsd_ios (0.0.1): - nsd_ios (0.0.1):
- Flutter - Flutter
- ObjectBox (4.4.1)
- objectbox_flutter_libs (0.0.1):
- Flutter
- ObjectBox (= 4.4.1)
- package_info_plus (0.4.5): - package_info_plus (0.4.5):
- Flutter - Flutter
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- permission_handler_apple (9.3.0): - permission_handler_apple (9.3.0):
- Flutter - Flutter
- record_ios (1.2.0): - record_ios (1.2.0):
@@ -103,9 +96,7 @@ DEPENDENCIES:
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`) - geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- nsd_ios (from `.symlinks/plugins/nsd_ios/ios`) - nsd_ios (from `.symlinks/plugins/nsd_ios/ios`)
- objectbox_flutter_libs (from `.symlinks/plugins/objectbox_flutter_libs/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- record_ios (from `.symlinks/plugins/record_ios/ios`) - record_ios (from `.symlinks/plugins/record_ios/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`) - share_plus (from `.symlinks/plugins/share_plus/ios`)
@@ -118,7 +109,6 @@ SPEC REPOS:
trunk: trunk:
- DKImagePickerController - DKImagePickerController
- DKPhotoGallery - DKPhotoGallery
- ObjectBox
- SDWebImage - SDWebImage
- SwiftyGif - SwiftyGif
@@ -149,12 +139,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/image_picker_ios/ios" :path: ".symlinks/plugins/image_picker_ios/ios"
nsd_ios: nsd_ios:
:path: ".symlinks/plugins/nsd_ios/ios" :path: ".symlinks/plugins/nsd_ios/ios"
objectbox_flutter_libs:
:path: ".symlinks/plugins/objectbox_flutter_libs/ios"
package_info_plus: package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios" :path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
permission_handler_apple: permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios" :path: ".symlinks/plugins/permission_handler_apple/ios"
record_ios: record_ios:
@@ -182,14 +168,11 @@ SPEC CHECKSUMS:
flutter_background_service_ios: 00d31bdff7b4bfe06d32375df358abe0329cf87e flutter_background_service_ios: 00d31bdff7b4bfe06d32375df358abe0329cf87e
flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3 flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3
flutter_compass: b236ab69b61545cce89fd58527f401a7587d5cc1 flutter_compass: b236ab69b61545cce89fd58527f401a7587d5cc1
flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb flutter_local_notifications: 643a3eda1ce1c0599413ca31672536d423dee214
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
nsd_ios: 596ad79109ddd3e52d665f650f36428049e3653e nsd_ios: 596ad79109ddd3e52d665f650f36428049e3653e
ObjectBox: 7da4aceb5013d041bfafdbc6d744a26918b09757
objectbox_flutter_libs: 09b1dec1b4cd27bf1a5f9bae7ccaa7e43588bf31
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
record_ios: 412daca2350b228e698fffcd08f1f94ceb1e3844 record_ios: 412daca2350b228e698fffcd08f1f94ceb1e3844
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf

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 = 100; CURRENT_PROJECT_VERSION = 105;
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 = 100; CURRENT_PROJECT_VERSION = 105;
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 = 100; CURRENT_PROJECT_VERSION = 105;
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 = 100; CURRENT_PROJECT_VERSION = 105;
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 = 100; CURRENT_PROJECT_VERSION = 105;
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 = 100; CURRENT_PROJECT_VERSION = 105;
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>100</string> <string>105</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>

View File

@@ -5,17 +5,17 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000199"> <testcase classname="fastlane.lanes" name="0: default_platform" time="0.001342">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.436347"> <testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.40706">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="2: build_app" time="49.579697"> <testcase classname="fastlane.lanes" name="2: build_app" time="70.83361">
</testcase> </testcase>

View File

@@ -17,7 +17,6 @@ import 'providers/app_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';
import 'services/tile_cache_service.dart';
import 'services/notification_service.dart'; import 'services/notification_service.dart';
import 'services/locale_preferences.dart'; import 'services/locale_preferences.dart';
import 'services/update_checker_service.dart'; import 'services/update_checker_service.dart';
@@ -248,18 +247,14 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
// Image provider (fragment reassembly + outgoing session cache) // Image provider (fragment reassembly + outgoing session cache)
ChangeNotifierProvider(create: (_) => ip.ImageProvider()), ChangeNotifierProvider(create: (_) => ip.ImageProvider()),
// Tile cache service
Provider(create: (_) => TileCacheService()),
// App provider that coordinates everything // App provider that coordinates everything
// VoiceProvider is read via context.read inside create since it's already registered above // VoiceProvider is read via context.read inside create since it's already registered above
ChangeNotifierProxyProvider6< ChangeNotifierProxyProvider5<
ConnectionProvider, ConnectionProvider,
ContactsProvider, ContactsProvider,
MessagesProvider, MessagesProvider,
DrawingProvider, DrawingProvider,
ChannelsProvider, ChannelsProvider,
TileCacheService,
AppProvider AppProvider
>( >(
create: (context) => AppProvider( create: (context) => AppProvider(
@@ -270,7 +265,6 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
channelsProvider: context.read<ChannelsProvider>(), channelsProvider: context.read<ChannelsProvider>(),
voiceProvider: context.read<VoiceProvider>(), voiceProvider: context.read<VoiceProvider>(),
imageProvider: context.read<ip.ImageProvider>(), imageProvider: context.read<ip.ImageProvider>(),
tileCacheService: context.read<TileCacheService>(),
), ),
update: update:
( (
@@ -280,7 +274,6 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
messages, messages,
drawings, drawings,
channels, channels,
tileCache,
previous, previous,
) => ) =>
previous ?? previous ??
@@ -292,7 +285,6 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
channelsProvider: channels, channelsProvider: channels,
voiceProvider: context.read<VoiceProvider>(), voiceProvider: context.read<VoiceProvider>(),
imageProvider: context.read<ip.ImageProvider>(), imageProvider: context.read<ip.ImageProvider>(),
tileCacheService: tileCache,
), ),
), ),
], ],

View File

@@ -231,10 +231,10 @@ extension ContactLocalization on Contact {
} }
String get routeSummary { String get routeSummary {
if (routeIsUnknown || !routeHasPath) { if (routeIsUnknown) {
return 'Flood/Unknown'; return 'Unknown';
} }
if (routeHopCount == 0) { if (!routeHasPath || routeHopCount == 0) {
return 'Direct'; return 'Direct';
} }
return '$routeHopCount hop${routeHopCount == 1 ? '' : 's'} via $routeHashSize-byte hashes'; return '$routeHopCount hop${routeHopCount == 1 ? '' : 's'} via $routeHashSize-byte hashes';

View File

@@ -1,4 +1,3 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/flutter_map.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
@@ -10,6 +9,7 @@ enum MapLayerType {
googleHybrid, googleHybrid,
googleRoadmap, googleRoadmap,
googleTerrain, googleTerrain,
// Kept for stored preference compatibility after MBTiles removal.
vectorMbtiles, vectorMbtiles,
wmsBase, wmsBase,
} }
@@ -21,13 +21,6 @@ class MapLayer {
final String attribution; final String attribution;
final double maxZoom; final double maxZoom;
// Vector tile specific properties
final bool isVector;
final File? mbtilesFile;
final String? styleUrl;
final String? sourceName;
final bool? isGzipped;
// WMS specific properties // WMS specific properties
final bool isWms; final bool isWms;
final String? wmsBaseUrl; final String? wmsBaseUrl;
@@ -43,11 +36,6 @@ class MapLayer {
required this.urlTemplate, required this.urlTemplate,
required this.attribution, required this.attribution,
required this.maxZoom, required this.maxZoom,
this.isVector = false,
this.mbtilesFile,
this.styleUrl,
this.sourceName,
this.isGzipped,
this.isWms = false, this.isWms = false,
this.wmsBaseUrl, this.wmsBaseUrl,
this.wmsLayers, this.wmsLayers,
@@ -74,7 +62,7 @@ class MapLayer {
case MapLayerType.googleTerrain: case MapLayerType.googleTerrain:
return localizations.googleTerrain; return localizations.googleTerrain;
case MapLayerType.vectorMbtiles: case MapLayerType.vectorMbtiles:
// For vector tiles, use the name from metadata // Legacy value kept only for preference migration compatibility.
return name; return name;
case MapLayerType.wmsBase: case MapLayerType.wmsBase:
// For WMS layers, use the name (will be localized separately) // For WMS layers, use the name (will be localized separately)
@@ -182,28 +170,4 @@ class MapLayer {
static MapLayer fromType(MapLayerType type) { static MapLayer fromType(MapLayerType type) {
return allLayers.firstWhere((layer) => layer.type == type); return allLayers.firstWhere((layer) => layer.type == type);
} }
/// Create a MapLayer from an MBTiles file
static MapLayer fromMbtilesFile({
required String name,
required File mbtilesFile,
required String styleUrl,
required String sourceName,
required double maxZoom,
required bool isGzipped,
String? attribution,
}) {
return MapLayer(
type: MapLayerType.vectorMbtiles,
name: name,
urlTemplate: '', // Not used for vector tiles
attribution: attribution ?? 'MBTiles',
maxZoom: maxZoom,
isVector: true,
mbtilesFile: mbtilesFile,
styleUrl: styleUrl,
sourceName: sourceName,
isGzipped: isGzipped,
);
}
} }

View File

@@ -11,7 +11,6 @@ import 'voice_provider.dart';
import 'image_provider.dart' as ip; import 'image_provider.dart' as ip;
import 'helpers/fragment_ack_wait_registry.dart'; import 'helpers/fragment_ack_wait_registry.dart';
import 'helpers/session_metadata_restore.dart'; import 'helpers/session_metadata_restore.dart';
import '../services/tile_cache_service.dart';
import '../services/location_tracking_service.dart'; import '../services/location_tracking_service.dart';
import '../services/packet_capture_storage_service.dart'; import '../services/packet_capture_storage_service.dart';
import '../models/contact.dart'; import '../models/contact.dart';
@@ -24,6 +23,7 @@ import '../utils/voice_message_parser.dart';
import '../utils/image_message_parser.dart'; import '../utils/image_message_parser.dart';
import '../utils/media_swarm_protocol.dart'; import '../utils/media_swarm_protocol.dart';
import '../utils/message_airtime_estimator.dart'; import '../utils/message_airtime_estimator.dart';
import '../utils/fast_gps_packet.dart';
/// Main App Provider - coordinates all other providers /// Main App Provider - coordinates all other providers
class AppProvider with ChangeNotifier { class AppProvider with ChangeNotifier {
@@ -35,7 +35,6 @@ class AppProvider with ChangeNotifier {
final ChannelsProvider channelsProvider; final ChannelsProvider channelsProvider;
final VoiceProvider voiceProvider; final VoiceProvider voiceProvider;
final ip.ImageProvider imageProvider; final ip.ImageProvider imageProvider;
final TileCacheService tileCacheService;
final LocationTrackingService locationTrackingService = final LocationTrackingService locationTrackingService =
LocationTrackingService(); LocationTrackingService();
final PacketCaptureStorageService packetCaptureStorageService = final PacketCaptureStorageService packetCaptureStorageService =
@@ -44,8 +43,7 @@ class AppProvider with ChangeNotifier {
bool _isInitialized = false; bool _isInitialized = false;
bool get isInitialized => _isInitialized; bool get isInitialized => _isInitialized;
bool _isSimpleMode = true; bool get isSimpleMode => true;
bool get isSimpleMode => _isSimpleMode;
bool _isMapEnabled = true; bool _isMapEnabled = true;
bool get isMapEnabled => _isMapEnabled; bool get isMapEnabled => _isMapEnabled;
@@ -79,6 +77,7 @@ class AppProvider with ChangeNotifier {
final Map<String, Future<bool>> _pendingMediaSwarmFetches = {}; final Map<String, Future<bool>> _pendingMediaSwarmFetches = {};
final Map<String, Map<String, MediaSwarmAvailability>> final Map<String, Map<String, MediaSwarmAvailability>>
_pendingMediaSwarmResponses = {}; _pendingMediaSwarmResponses = {};
bool _fastLocationScreenActive = false;
Timer? _packetCaptureFlushTimer; Timer? _packetCaptureFlushTimer;
String? _lastPersistedPacketSignature; String? _lastPersistedPacketSignature;
bool _isPersistingPacketCapture = false; bool _isPersistingPacketCapture = false;
@@ -91,12 +90,9 @@ class AppProvider with ChangeNotifier {
required this.channelsProvider, required this.channelsProvider,
required this.voiceProvider, required this.voiceProvider,
required this.imageProvider, required this.imageProvider,
required this.tileCacheService,
}) { }) {
_setupCallbacks(); _setupCallbacks();
_initializeTileCache();
_initializeLocationTracking(); _initializeLocationTracking();
_loadSimpleMode();
_loadMapEnabled(); _loadMapEnabled();
_loadContactsEnabled(); _loadContactsEnabled();
_loadSensorsEnabled(); _loadSensorsEnabled();
@@ -225,29 +221,6 @@ class AppProvider with ChangeNotifier {
return contact?.advName; return contact?.advName;
} }
/// Load simple mode setting from shared preferences
Future<void> _loadSimpleMode() async {
try {
final prefs = await SharedPreferences.getInstance();
_isSimpleMode = prefs.getBool('simple_mode') ?? true;
notifyListeners();
} catch (e) {
debugPrint('Error loading simple mode setting: $e');
}
}
/// Toggle simple mode on/off
Future<void> toggleSimpleMode(bool enabled) async {
try {
_isSimpleMode = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('simple_mode', enabled);
notifyListeners();
} catch (e) {
debugPrint('Error saving simple mode setting: $e');
}
}
/// Load map enabled setting from shared preferences /// Load map enabled setting from shared preferences
Future<void> _loadMapEnabled() async { Future<void> _loadMapEnabled() async {
try { try {
@@ -436,16 +409,6 @@ class AppProvider with ChangeNotifier {
} }
} }
/// Initialize tile cache service
Future<void> _initializeTileCache() async {
try {
await tileCacheService.initialize();
debugPrint('Tile cache initialized');
} catch (e) {
debugPrint('Error initializing tile cache: $e');
}
}
/// Initialize location tracking service /// Initialize location tracking service
Future<void> _initializeLocationTracking() async { Future<void> _initializeLocationTracking() async {
try { try {
@@ -473,6 +436,10 @@ class AppProvider with ChangeNotifier {
); );
}; };
locationTrackingService.onFastLocationUpdate = (position, reason) {
unawaited(_sendFastLocationUpdate(position, reason: reason));
};
debugPrint('✅ [AppProvider] Location tracking service initialized'); debugPrint('✅ [AppProvider] Location tracking service initialized');
} catch (e) { } catch (e) {
debugPrint('❌ [AppProvider] Error initializing location tracking: $e'); debugPrint('❌ [AppProvider] Error initializing location tracking: $e');
@@ -836,6 +803,18 @@ class AppProvider with ChangeNotifier {
// Magic 0x69 'i' = image fetch request; 0x56 'V' = voice packet. // Magic 0x69 'i' = image fetch request; 0x56 'V' = voice packet.
// Magic 0x49 'I' = image packet. // Magic 0x49 'I' = image packet.
connectionProvider.onRawDataReceived = (payload, snrRaw, rssiDbm) { connectionProvider.onRawDataReceived = (payload, snrRaw, rssiDbm) {
final fastGpsPacket = FastGpsPacket.tryParseBinary(payload);
if (fastGpsPacket != null) {
final sender = _resolveContactByPrefixHex(fastGpsPacket.senderKey6);
if (sender != null) {
contactsProvider.updateFastGps(
sender.publicKey.sublist(0, 6),
fastGpsPacket,
);
}
return;
}
final rawProbeRequest = RawRouteProbeRequest.tryParseBinary(payload); final rawProbeRequest = RawRouteProbeRequest.tryParseBinary(payload);
if (rawProbeRequest != null) { if (rawProbeRequest != null) {
debugPrint( debugPrint(
@@ -1210,10 +1189,8 @@ class AppProvider with ChangeNotifier {
// Sync channels to get channel names // Sync channels to get channel names
// In simple mode: only sync first 5 channels for faster startup // In simple mode: only sync first 5 channels for faster startup
// In normal mode: sync all channels (up to device max) // In normal mode: sync all channels (up to device max)
final channelsToSync = _isSimpleMode ? 5 : null; const channelsToSync = 5;
debugPrint( debugPrint('📻 [AppProvider] Syncing channels (simple mode: max 5)...');
'📻 [AppProvider] Syncing channels${_isSimpleMode ? ' (simple mode: max 5)' : ''}...',
);
await connectionProvider.syncChannels(maxChannels: channelsToSync); await connectionProvider.syncChannels(maxChannels: channelsToSync);
debugPrint('✅ [AppProvider] Channel sync complete'); debugPrint('✅ [AppProvider] Channel sync complete');
@@ -1382,6 +1359,46 @@ class AppProvider with ChangeNotifier {
return contactsProvider.findContactByPrefixHex(prefixHex.toLowerCase()); return contactsProvider.findContactByPrefixHex(prefixHex.toLowerCase());
} }
void setFastLocationUiActive(bool isActive) {
if (_fastLocationScreenActive == isActive) return;
_fastLocationScreenActive = isActive;
locationTrackingService.setFastLocationActiveUse(isActive);
}
Future<void> _sendFastLocationUpdate(
dynamic position, {
required String reason,
}) async {
if (!connectionProvider.deviceInfo.isConnected) {
return;
}
final publicKey = connectionProvider.deviceInfo.publicKey;
if (publicKey == null || publicKey.length < 6) {
return;
}
final senderKey6 = publicKey
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
final packet = FastGpsPacket(
senderKey6: senderKey6,
latitude: position.latitude as double,
longitude: position.longitude as double,
timestampSeconds: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
debugPrint(
'📍 [AppProvider] Sending fast GPS update ($reason): '
'${position.latitude}, ${position.longitude}',
);
try {
await connectionProvider.sendRawPrivateMulticast(packet.encodeBinary());
} catch (e) {
debugPrint('⚠️ [AppProvider] Fast GPS send failed: $e');
}
}
Contact? _resolveVoiceFetchRequester(VoiceFetchRequest request) { Contact? _resolveVoiceFetchRequester(VoiceFetchRequest request) {
final liveContact = _resolveContactByPrefixHex(request.requesterKey6); final liveContact = _resolveContactByPrefixHex(request.requesterKey6);
if (liveContact != null) { if (liveContact != null) {
@@ -2176,7 +2193,7 @@ class AppProvider with ChangeNotifier {
await connectionProvider.getContacts(); await connectionProvider.getContacts();
// Sync channels (respect simple mode settings) // Sync channels (respect simple mode settings)
final channelsToSync = _isSimpleMode ? 5 : null; const channelsToSync = 5;
await connectionProvider.syncChannels(maxChannels: channelsToSync); await connectionProvider.syncChannels(maxChannels: channelsToSync);
// Messages are automatically synced via PUSH_CODE_MSG_WAITING events // Messages are automatically synced via PUSH_CODE_MSG_WAITING events
@@ -2294,6 +2311,7 @@ class AppProvider with ChangeNotifier {
locationTrackingService.onBroadcastSent = null; locationTrackingService.onBroadcastSent = null;
locationTrackingService.onError = null; locationTrackingService.onError = null;
locationTrackingService.onTrackingStateChanged = null; locationTrackingService.onTrackingStateChanged = null;
locationTrackingService.onFastLocationUpdate = null;
// Dispose the location tracking service to stop GPS stream and clean up resources // Dispose the location tracking service to stop GPS stream and clean up resources
locationTrackingService.dispose(); locationTrackingService.dispose();
for (final timer in _voiceMissingRetryTimers.values) { for (final timer in _voiceMissingRetryTimers.values) {

View File

@@ -1290,6 +1290,19 @@ class ConnectionProvider with ChangeNotifier {
); );
} }
/// Send a raw private zero-hop payload.
///
/// This wraps the raw custom transport using an empty path to match the
/// firmware's private multicast behavior.
Future<void> sendRawPrivateMulticast(Uint8List payload) async {
if (!_activeService.isConnected) return;
await _activeService.sendRawVoicePacket(
contactPathLen: 0,
contactPath: Uint8List(0),
payload: payload,
);
}
/// Request telemetry from contact /// Request telemetry from contact
/// ///
/// COMPATIBILITY NOTE: This method sends CMD_SEND_TELEMETRY_REQ (39). /// COMPATIBILITY NOTE: This method sends CMD_SEND_TELEMETRY_REQ (39).
@@ -2015,6 +2028,7 @@ class ConnectionProvider with ChangeNotifier {
} }
try { try {
_error = null;
await _activeService.resetPath(contactPublicKey); await _activeService.resetPath(contactPublicKey);
} catch (e) { } catch (e) {
_error = 'Failed to reset path: $e'; _error = 'Failed to reset path: $e';
@@ -2034,6 +2048,7 @@ class ConnectionProvider with ChangeNotifier {
} }
try { try {
_error = null;
final updatedContact = contact.copyWith( final updatedContact = contact.copyWith(
outPathLen: signedEncodedPathLen, outPathLen: signedEncodedPathLen,
outPath: Uint8List.fromList(paddedPathBytes), outPath: Uint8List.fromList(paddedPathBytes),

View File

@@ -4,6 +4,7 @@ import '../models/contact.dart';
import '../models/message_contact_location.dart'; import '../models/message_contact_location.dart';
import '../services/cayenne_lpp_parser.dart'; import '../services/cayenne_lpp_parser.dart';
import '../services/contact_storage_service.dart'; import '../services/contact_storage_service.dart';
import '../utils/fast_gps_packet.dart';
import '../utils/key_comparison.dart'; import '../utils/key_comparison.dart';
class PendingAdvert { class PendingAdvert {
@@ -282,62 +283,16 @@ class ContactsProvider with ChangeNotifier {
} }
// Check if this is a new contact // Check if this is a new contact
final isNewContact = !_contacts.containsKey(contact.publicKeyHex); final existingContact = _contacts[contact.publicKeyHex];
final isNewContact = existingContact == null;
debugPrint( debugPrint(
' isNew: $isNewContact, total contacts before: ${_contacts.length}', ' isNew: $isNewContact, total contacts before: ${_contacts.length}',
); );
Contact updatedContact; final updatedContact = _mergeIncomingContact(
if (isNewContact) { incomingContact: contact,
// New contact - add initial location to history if available existingContact: existingContact,
updatedContact = contact.copyWith(isNew: true); );
if (contact.advertLocation != null) {
final timestamp = DateTime.fromMillisecondsSinceEpoch(
contact.lastAdvert * 1000,
);
updatedContact = updatedContact.addAdvertLocation(
contact.advertLocation!,
timestamp,
);
}
} else {
// Existing contact - preserve history and isNew status
final existingContact = _contacts[contact.publicKeyHex]!;
final mergedTelemetry = _mergeTelemetryForContact(
existingTelemetry: existingContact.telemetry,
incomingTelemetry: contact.telemetry,
);
final incomingAdvertLocation = contact.advertLocation;
final existingAdvertLocation = existingContact.advertLocation;
// Start with existing contact
updatedContact = contact.copyWith(
isNew: existingContact.isNew,
advertHistory: existingContact.advertHistory,
telemetry: mergedTelemetry,
advLat: incomingAdvertLocation != null
? contact.advLat
: existingAdvertLocation != null
? existingContact.advLat
: contact.advLat,
advLon: incomingAdvertLocation != null
? contact.advLon
: existingAdvertLocation != null
? existingContact.advLon
: contact.advLon,
);
// Add new location to history if location has changed
if (contact.advertLocation != null) {
final timestamp = DateTime.fromMillisecondsSinceEpoch(
contact.lastAdvert * 1000,
);
updatedContact = updatedContact.addAdvertLocation(
contact.advertLocation!,
timestamp,
);
}
}
_contacts[contact.publicKeyHex] = updatedContact; _contacts[contact.publicKeyHex] = updatedContact;
_pendingAdverts.remove(contact.publicKeyHex); _pendingAdverts.remove(contact.publicKeyHex);
@@ -363,7 +318,11 @@ class ContactsProvider with ChangeNotifier {
excluded++; excluded++;
continue; continue;
} }
_contacts[contact.publicKeyHex] = contact; final existingContact = _contacts[contact.publicKeyHex];
_contacts[contact.publicKeyHex] = _mergeIncomingContact(
incomingContact: contact,
existingContact: existingContact,
);
_pendingAdverts.remove(contact.publicKeyHex); _pendingAdverts.remove(contact.publicKeyHex);
} }
if (excluded > 0) { if (excluded > 0) {
@@ -375,6 +334,60 @@ class ContactsProvider with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Contact _mergeIncomingContact({
required Contact incomingContact,
Contact? existingContact,
}) {
if (existingContact == null) {
var newContact = incomingContact.copyWith(isNew: true);
if (incomingContact.advertLocation != null) {
final timestamp = DateTime.fromMillisecondsSinceEpoch(
incomingContact.lastAdvert * 1000,
);
newContact = newContact.addAdvertLocation(
incomingContact.advertLocation!,
timestamp,
);
}
return newContact;
}
final mergedTelemetry = _mergeTelemetryForContact(
existingTelemetry: existingContact.telemetry,
incomingTelemetry: incomingContact.telemetry,
);
final incomingAdvertLocation = incomingContact.advertLocation;
final existingAdvertLocation = existingContact.advertLocation;
var updatedContact = incomingContact.copyWith(
isNew: existingContact.isNew,
advertHistory: existingContact.advertHistory,
telemetry: mergedTelemetry,
advLat: incomingAdvertLocation != null
? incomingContact.advLat
: existingAdvertLocation != null
? existingContact.advLat
: incomingContact.advLat,
advLon: incomingAdvertLocation != null
? incomingContact.advLon
: existingAdvertLocation != null
? existingContact.advLon
: incomingContact.advLon,
);
if (incomingAdvertLocation != null) {
final timestamp = DateTime.fromMillisecondsSinceEpoch(
incomingContact.lastAdvert * 1000,
);
updatedContact = updatedContact.addAdvertLocation(
incomingAdvertLocation,
timestamp,
);
}
return updatedContact;
}
/// Update contact telemetry /// Update contact telemetry
void updateTelemetry(Uint8List publicKeyPrefix, Uint8List lppData) { void updateTelemetry(Uint8List publicKeyPrefix, Uint8List lppData) {
debugPrint('📊 [ContactsProvider] updateTelemetry() called'); debugPrint('📊 [ContactsProvider] updateTelemetry() called');
@@ -464,6 +477,42 @@ class ContactsProvider with ChangeNotifier {
} }
} }
void updateFastGps(Uint8List publicKeyPrefix, FastGpsPacket packet) {
final contact = _findContactByPrefix(publicKeyPrefix);
if (contact == null) {
debugPrint(
'⚠️ [ContactsProvider] Fast GPS sender not found: ${packet.senderKey6}',
);
return;
}
final updatedTelemetry = _mergeTelemetryForContact(
existingTelemetry: contact.telemetry,
incomingTelemetry: ContactTelemetry(
gpsLocation: LatLng(packet.latitude, packet.longitude),
batteryPercentage: null,
batteryMilliVolts: null,
temperature: null,
timestamp: DateTime.fromMillisecondsSinceEpoch(
packet.timestampSeconds * 1000,
),
humidity: null,
pressure: null,
extraSensorData: null,
),
);
final updatedContact = contact.copyWith(
telemetry: updatedTelemetry,
lastAdvert: packet.timestampSeconds,
advLat: _coordinateToAdvertMicrodegrees(packet.latitude),
advLon: _coordinateToAdvertMicrodegrees(packet.longitude),
);
_contacts[contact.publicKeyHex] = updatedContact;
_persistContacts();
notifyListeners();
}
bool _isInvalidTelemetryGps(LatLng? location) { bool _isInvalidTelemetryGps(LatLng? location) {
if (location == null) return false; if (location == null) return false;
final lat = location.latitude; final lat = location.latitude;
@@ -504,6 +553,8 @@ class ContactsProvider with ChangeNotifier {
return existingTelemetry; return existingTelemetry;
} }
// Telemetry packets and contact refreshes are often sparse. Preserve the
// last known reading for any field that is omitted in the incoming update.
final incomingGps = _getValidGpsOrNull(incomingTelemetry.gpsLocation); final incomingGps = _getValidGpsOrNull(incomingTelemetry.gpsLocation);
final previousGps = _getValidGpsOrNull(existingTelemetry?.gpsLocation); final previousGps = _getValidGpsOrNull(existingTelemetry?.gpsLocation);
final mergedExtraSensorData = <String, dynamic>{ final mergedExtraSensorData = <String, dynamic>{

View File

@@ -11,6 +11,7 @@ import 'contacts_provider.dart';
enum SensorRefreshState { idle, refreshing, success, timeout, unavailable } enum SensorRefreshState { idle, refreshing, success, timeout, unavailable }
class SensorsProvider with ChangeNotifier { class SensorsProvider with ChangeNotifier {
static const Duration _successStateRetention = Duration(minutes: 1);
static const String _watchedSensorsKey = 'watched_sensor_keys'; static const String _watchedSensorsKey = 'watched_sensor_keys';
static const String _visibleSensorMetricsKey = 'visible_sensor_metrics'; static const String _visibleSensorMetricsKey = 'visible_sensor_metrics';
static const String _fieldSpanKey = 'sensor_field_spans'; static const String _fieldSpanKey = 'sensor_field_spans';
@@ -26,6 +27,7 @@ class SensorsProvider with ChangeNotifier {
final List<String> _watchedSensorKeys = <String>[]; final List<String> _watchedSensorKeys = <String>[];
final Map<String, SensorRefreshState> _refreshStates = final Map<String, SensorRefreshState> _refreshStates =
<String, SensorRefreshState>{}; <String, SensorRefreshState>{};
final Map<String, DateTime> _refreshStateUpdatedAt = <String, DateTime>{};
final Map<String, Set<String>> _visibleFieldsBySensor = final Map<String, Set<String>> _visibleFieldsBySensor =
<String, Set<String>>{}; <String, Set<String>>{};
final Map<String, Map<String, int>> _fieldSpansBySensor = final Map<String, Map<String, int>> _fieldSpansBySensor =
@@ -189,6 +191,7 @@ class SensorsProvider with ChangeNotifier {
Future<void> removeSensor(String publicKeyHex) async { Future<void> removeSensor(String publicKeyHex) async {
_watchedSensorKeys.remove(publicKeyHex); _watchedSensorKeys.remove(publicKeyHex);
_refreshStates.remove(publicKeyHex); _refreshStates.remove(publicKeyHex);
_refreshStateUpdatedAt.remove(publicKeyHex);
_visibleFieldsBySensor.remove(publicKeyHex); _visibleFieldsBySensor.remove(publicKeyHex);
_fieldSpansBySensor.remove(publicKeyHex); _fieldSpansBySensor.remove(publicKeyHex);
await _persistWatchedSensors(); await _persistWatchedSensors();
@@ -220,35 +223,78 @@ class SensorsProvider with ChangeNotifier {
try { try {
for (final key in _watchedSensorKeys) { for (final key in _watchedSensorKeys) {
Contact? contact; await refreshSensor(
for (final entry in contactsProvider.contacts) { publicKeyHex: key,
if (entry.publicKeyHex == key) { contactsProvider: contactsProvider,
contact = entry; connectionProvider: connectionProvider,
break;
}
}
if (contact == null) {
_refreshStates[key] = SensorRefreshState.unavailable;
notifyListeners();
continue;
}
_refreshStates[key] = SensorRefreshState.refreshing;
notifyListeners();
final result = await connectionProvider.smartPing(
contactPublicKey: contact.publicKey,
hasPath: contact.hasPath,
); );
_refreshStates[key] = result.success
? SensorRefreshState.success
: SensorRefreshState.timeout;
notifyListeners();
} }
} finally { } finally {
_isRefreshingAll = false; _isRefreshingAll = false;
notifyListeners(); notifyListeners();
} }
} }
Future<void> refreshSensor({
required String publicKeyHex,
required ContactsProvider contactsProvider,
required ConnectionProvider connectionProvider,
}) async {
Contact? contact;
for (final entry in contactsProvider.contacts) {
if (entry.publicKeyHex == publicKeyHex) {
contact = entry;
break;
}
}
if (contact == null) {
_setRefreshState(publicKeyHex, SensorRefreshState.unavailable);
return;
}
_setRefreshState(publicKeyHex, SensorRefreshState.refreshing);
final result = await connectionProvider.smartPing(
contactPublicKey: contact.publicKey,
hasPath: contact.hasPath,
);
_setRefreshState(
publicKeyHex,
result.success ? SensorRefreshState.success : SensorRefreshState.timeout,
);
}
void clearExpiredRefreshStates({DateTime? now}) {
final cutoff = (now ?? DateTime.now()).subtract(_successStateRetention);
final keysToClear = <String>[];
for (final entry in _refreshStates.entries) {
if (entry.value != SensorRefreshState.success) {
continue;
}
final updatedAt = _refreshStateUpdatedAt[entry.key];
if (updatedAt == null || !updatedAt.isAfter(cutoff)) {
keysToClear.add(entry.key);
}
}
if (keysToClear.isEmpty) {
return;
}
for (final key in keysToClear) {
_refreshStates.remove(key);
_refreshStateUpdatedAt.remove(key);
}
notifyListeners();
}
void _setRefreshState(String publicKeyHex, SensorRefreshState state) {
_refreshStates[publicKeyHex] = state;
_refreshStateUpdatedAt[publicKeyHex] = DateTime.now();
notifyListeners();
}
} }

View File

@@ -398,26 +398,51 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final deviceInfo = context.watch<ConnectionProvider>().deviceInfo; final deviceInfo = context.watch<ConnectionProvider>().deviceInfo;
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final locationSet =
(deviceInfo.advLat != null && deviceInfo.advLat != 0) ||
(deviceInfo.advLon != null && deviceInfo.advLon != 0);
return Scaffold( return Scaffold(
appBar: AppBar(title: Text(AppLocalizations.of(context)!.settings)), appBar: AppBar(title: Text(AppLocalizations.of(context)!.settings)),
body: ListView( body: SafeArea(
padding: const EdgeInsets.all(16), child: ListView(
children: [ padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
// Device Info Card children: [
Card( _ConfigHeroCard(
child: Padding( title: deviceInfo.selfName ?? deviceInfo.deviceName ?? 'MeshCore',
padding: const EdgeInsets.all(16), subtitle:
'${_getDeviceTypeString(context, deviceInfo.deviceType)}${deviceInfo.semanticVersion ?? deviceInfo.manufacturerModel ?? AppLocalizations.of(context)!.unknown}',
chips: [
_StatusChipData(
icon: Icons.bluetooth,
label:
'${AppLocalizations.of(context)!.bleName}: ${deviceInfo.deviceName ?? AppLocalizations.of(context)!.unknown}',
),
_StatusChipData(
icon: Icons.my_location,
label: locationSet ? 'Location ready' : 'Location off',
emphasized: locationSet,
),
_StatusChipData(
icon: Icons.settings_input_antenna,
label: '${_freqController.text} MHz • $_selectedBandwidth',
),
_StatusChipData(
icon: Icons.key,
label: 'FW v${deviceInfo.firmwareVersion?.toString() ?? "?"}',
),
],
),
const SizedBox(height: 20),
_ConfigSectionCard(
title: AppLocalizations.of(context)!.deviceInformation,
subtitle:
'${deviceInfo.manufacturerModel ?? AppLocalizations.of(context)!.unknown}${deviceInfo.firmwareBuildDate ?? AppLocalizations.of(context)!.unknown}',
icon: Icons.memory_rounded,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(
AppLocalizations.of(context)!.deviceInformation,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
_InfoRow( _InfoRow(
AppLocalizations.of(context)!.bleName, AppLocalizations.of(context)!.bleName,
deviceInfo.deviceName ?? deviceInfo.deviceName ??
@@ -467,42 +492,75 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
], ],
), ),
), ),
), const SizedBox(height: 20),
_ConfigSectionCard(
const SizedBox(height: 24), title: AppLocalizations.of(context)!.publicInfo,
subtitle: AppLocalizations.of(context)!.nameBroadcastInMesh,
// Public Info Section icon: Icons.public_rounded,
Card( trailing: FilledButton.icon(
child: Padding( onPressed: _savePublicInfo,
padding: const EdgeInsets.all(16), icon: const Icon(Icons.save_outlined),
label: Text(AppLocalizations.of(context)!.save),
),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Container(
mainAxisAlignment: MainAxisAlignment.spaceBetween, padding: const EdgeInsets.all(14),
children: [ decoration: BoxDecoration(
Text( color: colorScheme.surfaceContainerHighest.withValues(
AppLocalizations.of(context)!.publicInfo, alpha: 0.45,
style: theme.textTheme.titleLarge?.copyWith( ),
fontWeight: FontWeight.bold, borderRadius: BorderRadius.circular(18),
),
child: Row(
children: [
Icon(
_telemetryEnabled
? Icons.travel_explore
: Icons.location_disabled,
color: _telemetryEnabled
? colorScheme.primary
: colorScheme.onSurfaceVariant,
), ),
), const SizedBox(width: 12),
Row( Expanded(
mainAxisSize: MainAxisSize.min, child: Column(
children: [ crossAxisAlignment: CrossAxisAlignment.start,
const SizedBox(width: 8), children: [
IconButton.filled( Text(
onPressed: _savePublicInfo, AppLocalizations.of(
icon: const Icon(Icons.save), context,
tooltip: AppLocalizations.of(context)!.save, )!.telemetryAndLocationSharing,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 2),
Text(
_telemetryEnabled
? 'This device advertises position data to the mesh.'
: 'Position broadcasting is currently disabled.',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
), ),
], ),
), const SizedBox(width: 12),
], Switch(
value: _telemetryEnabled,
onChanged: (value) {
setState(() {
_telemetryEnabled = value;
});
},
),
],
),
), ),
const SizedBox(height: 16), const SizedBox(height: 18),
// Mesh Network Name
TextField( TextField(
controller: _nameController, controller: _nameController,
decoration: InputDecoration( decoration: InputDecoration(
@@ -513,62 +571,21 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
)!.nameBroadcastInMesh, )!.nameBroadcastInMesh,
), ),
), ),
const SizedBox(height: 8),
// Telemetry Toggle - Compact version
Row(
children: [
Expanded(
child: Text(
AppLocalizations.of(
context,
)!.telemetryAndLocationSharing,
style: theme.textTheme.bodyMedium,
),
),
Switch(
value: _telemetryEnabled,
onChanged: (value) {
setState(() {
_telemetryEnabled = value;
});
},
),
],
),
// GPS Coordinates (only show if telemetry enabled)
if (_telemetryEnabled) ...[ if (_telemetryEnabled) ...[
const SizedBox(height: 12), const SizedBox(height: 16),
Row( Row(
children: [ children: [
Expanded( Expanded(
child: TextField( child: _CompactCoordinateField(
controller: _latController, controller: _latController,
decoration: InputDecoration( label: AppLocalizations.of(context)!.lat,
labelText: AppLocalizations.of(context)!.lat,
border: const OutlineInputBorder(),
isDense: true,
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
signed: true,
),
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: TextField( child: _CompactCoordinateField(
controller: _lonController, controller: _lonController,
decoration: InputDecoration( label: AppLocalizations.of(context)!.lon,
labelText: AppLocalizations.of(context)!.lon,
border: const OutlineInputBorder(),
isDense: true,
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
signed: true,
),
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -585,36 +602,45 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
], ],
), ),
), ),
), const SizedBox(height: 20),
_ConfigSectionCard(
const SizedBox(height: 24), title: AppLocalizations.of(context)!.radioSettings,
subtitle:
// Radio Settings Section '${_freqController.text} MHz • SF$_selectedSpreadingFactor • CR$_selectedCodingRate',
Card( icon: Icons.settings_input_antenna_rounded,
child: Padding( trailing: FilledButton.icon(
padding: const EdgeInsets.all(16), onPressed: _saveRadioSettings,
icon: const Icon(Icons.save_outlined),
label: Text(AppLocalizations.of(context)!.save),
),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text( Expanded(
AppLocalizations.of(context)!.radioSettings, child: _RadioMetricTile(
style: theme.textTheme.titleLarge?.copyWith( label: AppLocalizations.of(context)!.bandwidth,
fontWeight: FontWeight.bold, value: _selectedBandwidth,
), ),
), ),
IconButton.filled( const SizedBox(width: 10),
onPressed: _saveRadioSettings, Expanded(
icon: const Icon(Icons.save), child: _RadioMetricTile(
tooltip: AppLocalizations.of(context)!.save, label: AppLocalizations.of(context)!.spreadingFactor,
value: 'SF$_selectedSpreadingFactor',
),
),
const SizedBox(width: 10),
Expanded(
child: _RadioMetricTile(
label: AppLocalizations.of(context)!.codingRate,
value: 'CR$_selectedCodingRate',
),
), ),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// LoRa Frequency
TextField( TextField(
controller: _freqController, controller: _freqController,
decoration: InputDecoration( decoration: InputDecoration(
@@ -629,8 +655,6 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Bandwidth
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
initialValue: _selectedBandwidth, initialValue: _selectedBandwidth,
decoration: InputDecoration( decoration: InputDecoration(
@@ -652,8 +676,6 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
}, },
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Spreading Factor
DropdownButtonFormField<int>( DropdownButtonFormField<int>(
initialValue: _selectedSpreadingFactor, initialValue: _selectedSpreadingFactor,
decoration: InputDecoration( decoration: InputDecoration(
@@ -677,8 +699,6 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
}, },
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Coding Rate
DropdownButtonFormField<int>( DropdownButtonFormField<int>(
initialValue: _selectedCodingRate, initialValue: _selectedCodingRate,
decoration: InputDecoration( decoration: InputDecoration(
@@ -702,8 +722,6 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
}, },
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// TX Power
TextField( TextField(
controller: _txPowerController, controller: _txPowerController,
decoration: InputDecoration( decoration: InputDecoration(
@@ -715,37 +733,42 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
), ),
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
), ),
// Repeat Mode (firmware v9+)
if (deviceInfo.clientRepeat != null) ...[ if (deviceInfo.clientRepeat != null) ...[
const SizedBox(height: 8), const SizedBox(height: 16),
SwitchListTile( Container(
contentPadding: EdgeInsets.zero, padding: const EdgeInsets.all(14),
title: const Text('Client Repeat Mode'), decoration: BoxDecoration(
subtitle: color: colorScheme.surfaceContainerHighest.withValues(
deviceInfo.allowedRepeatFreqRanges != null && alpha: 0.45,
deviceInfo.allowedRepeatFreqRanges!.isNotEmpty ),
? Text( borderRadius: BorderRadius.circular(18),
'Allowed: ${deviceInfo.allowedRepeatFreqRanges!.map((r) => r.lower == r.upper ? '${(r.lower / 1000).toStringAsFixed(3)} MHz' : '${(r.lower / 1000).toStringAsFixed(3)}${(r.upper / 1000).toStringAsFixed(3)} MHz').join(', ')}', ),
) child: SwitchListTile(
: const Text( contentPadding: EdgeInsets.zero,
'Repeat packets on behalf of nearby nodes', title: const Text('Client Repeat Mode'),
), subtitle:
value: _repeatEnabled, deviceInfo.allowedRepeatFreqRanges != null &&
onChanged: (value) { deviceInfo.allowedRepeatFreqRanges!.isNotEmpty
setState(() { ? Text(
_repeatEnabled = value; 'Allowed: ${deviceInfo.allowedRepeatFreqRanges!.map((r) => r.lower == r.upper ? '${(r.lower / 1000).toStringAsFixed(3)} MHz' : '${(r.lower / 1000).toStringAsFixed(3)}${(r.upper / 1000).toStringAsFixed(3)} MHz').join(', ')}',
}); )
}, : const Text(
'Repeat packets on behalf of nearby nodes',
),
value: _repeatEnabled,
onChanged: (value) {
setState(() {
_repeatEnabled = value;
});
},
),
), ),
], ],
], ],
), ),
), ),
), ],
),
const SizedBox(height: 24),
],
), ),
); );
} }
@@ -772,6 +795,276 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
} }
} }
class _ConfigHeroCard extends StatelessWidget {
final String title;
final String subtitle;
final List<_StatusChipData> chips;
const _ConfigHeroCard({
required this.title,
required this.subtitle,
required this.chips,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
colorScheme.primaryContainer,
colorScheme.surfaceContainerHighest,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(28),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
color: colorScheme.onPrimaryContainer.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(18),
),
child: Icon(
Icons.tune_rounded,
color: colorScheme.onPrimaryContainer,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w800,
color: colorScheme.onPrimaryContainer,
),
),
const SizedBox(height: 4),
Text(
subtitle,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onPrimaryContainer.withValues(
alpha: 0.82,
),
),
),
],
),
),
],
),
const SizedBox(height: 18),
Wrap(
spacing: 10,
runSpacing: 10,
children: chips.map(_StatusChip.new).toList(),
),
],
),
);
}
}
class _ConfigSectionCard extends StatelessWidget {
final String title;
final String subtitle;
final IconData icon;
final Widget child;
final Widget? trailing;
const _ConfigSectionCard({
required this.title,
required this.subtitle,
required this.icon,
required this.child,
this.trailing,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Card(
clipBehavior: Clip.antiAlias,
child: Padding(
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: colorScheme.primary.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(14),
),
child: Icon(icon, color: colorScheme.primary),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 3),
Text(
subtitle,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
if (trailing != null) ...[const SizedBox(width: 12), trailing!],
],
),
const SizedBox(height: 18),
child,
],
),
),
);
}
}
class _StatusChipData {
final IconData icon;
final String label;
final bool emphasized;
const _StatusChipData({
required this.icon,
required this.label,
this.emphasized = false,
});
}
class _StatusChip extends StatelessWidget {
final _StatusChipData data;
const _StatusChip(this.data);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final chipColor = data.emphasized
? colorScheme.primary.withValues(alpha: 0.14)
: colorScheme.onPrimaryContainer.withValues(alpha: 0.10);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: chipColor,
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(data.icon, size: 16, color: colorScheme.onPrimaryContainer),
const SizedBox(width: 8),
Text(
data.label,
style: theme.textTheme.labelLarge?.copyWith(
color: colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w700,
),
),
],
),
);
}
}
class _CompactCoordinateField extends StatelessWidget {
final TextEditingController controller;
final String label;
const _CompactCoordinateField({
required this.controller,
required this.label,
});
@override
Widget build(BuildContext context) {
return TextField(
controller: controller,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
isDense: true,
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
signed: true,
),
);
}
}
class _RadioMetricTile extends StatelessWidget {
final String label;
final String value;
const _RadioMetricTile({required this.label, required this.value});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.45),
borderRadius: BorderRadius.circular(18),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: theme.textTheme.labelMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 6),
Text(
value,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
),
),
],
),
);
}
}
class _InfoRow extends StatelessWidget { class _InfoRow extends StatelessWidget {
final String label; final String label;
final String value; final String value;

View File

@@ -14,7 +14,6 @@ import 'messages_tab.dart';
import 'contacts_tab.dart'; import 'contacts_tab.dart';
import 'sensors_tab.dart'; import 'sensors_tab.dart';
import 'map_tab.dart'; import 'map_tab.dart';
import 'map_management_screen.dart';
import 'settings_screen.dart'; import 'settings_screen.dart';
import 'device_config_screen.dart'; import 'device_config_screen.dart';
import 'packet_log_screen.dart'; import 'packet_log_screen.dart';
@@ -27,6 +26,8 @@ import '../utils/battery_display_helper.dart';
enum _HomeTab { messages, contacts, sensors, map } enum _HomeTab { messages, contacts, sensors, map }
enum _AdvertMode { flood, direct }
class HomeScreen extends StatefulWidget { class HomeScreen extends StatefulWidget {
final Function(AppThemeMode) onThemeChanged; final Function(AppThemeMode) onThemeChanged;
final Function(Locale?) onLocaleChanged; final Function(Locale?) onLocaleChanged;
@@ -47,7 +48,8 @@ class HomeScreen extends StatefulWidget {
State<HomeScreen> createState() => _HomeScreenState(); State<HomeScreen> createState() => _HomeScreenState();
} }
class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin { class _HomeScreenState extends State<HomeScreen>
with TickerProviderStateMixin, WidgetsBindingObserver {
late TabController _tabController; late TabController _tabController;
late final AppProvider _appProvider; late final AppProvider _appProvider;
int _currentIndex = 0; int _currentIndex = 0;
@@ -56,6 +58,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
bool _isMapEnabled = true; bool _isMapEnabled = true;
bool _isContactsEnabled = true; bool _isContactsEnabled = true;
bool _isSensorsEnabled = false; bool _isSensorsEnabled = false;
AppLifecycleState _lifecycleState = AppLifecycleState.resumed;
List<_HomeTab> get _enabledTabs { List<_HomeTab> get _enabledTabs {
return [ return [
@@ -77,6 +80,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this);
_appProvider = context.read<AppProvider>(); _appProvider = context.read<AppProvider>();
_isMapEnabled = _appProvider.isMapEnabled; _isMapEnabled = _appProvider.isMapEnabled;
_isContactsEnabled = _appProvider.isContactsEnabled; _isContactsEnabled = _appProvider.isContactsEnabled;
@@ -182,6 +186,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
} }
void _handleTabActivated(_HomeTab tab) { void _handleTabActivated(_HomeTab tab) {
_syncFastLocationUiState();
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; if (!mounted) return;
@@ -200,6 +205,20 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
}); });
} }
void _syncFastLocationUiState() {
final isActiveTab =
_currentTab == _HomeTab.map || _currentTab == _HomeTab.messages;
_appProvider.setFastLocationUiActive(
_lifecycleState == AppLifecycleState.resumed && isActiveTab,
);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
_lifecycleState = state;
_syncFastLocationUiState();
}
Future<void> _loadRxTxPreference() async { Future<void> _loadRxTxPreference() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
if (mounted) { if (mounted) {
@@ -211,6 +230,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
@override @override
void dispose() { void dispose() {
WidgetsBinding.instance.removeObserver(this);
_appProvider.setFastLocationUiActive(false);
_appProvider.removeListener(_handleAppProviderChanged); _appProvider.removeListener(_handleAppProviderChanged);
_tabController.removeListener(_onTabChanged); _tabController.removeListener(_onTabChanged);
_tabController.dispose(); _tabController.dispose();
@@ -245,7 +266,187 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
); );
} }
Future<void> _advertiseDevice(BuildContext context) async { Future<void> _triggerAdvertFeedback() async {
final platform = Theme.of(context).platform;
try {
if (platform == TargetPlatform.iOS) {
await HapticFeedback.lightImpact();
await Future.delayed(const Duration(milliseconds: 50));
await HapticFeedback.lightImpact();
} else {
if (await Vibration.hasVibrator()) {
await Vibration.vibrate(duration: 50);
} else {
await HapticFeedback.mediumImpact();
}
}
} catch (e) {
debugPrint('Haptic feedback error: $e');
await HapticFeedback.vibrate();
}
}
Future<_AdvertMode?> _showAdvertModeSheet(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
return showModalBottomSheet<_AdvertMode>(
context: context,
showDragHandle: true,
builder: (context) => SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Advert mode',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 6),
Text(
'Choose how far this announcement should travel.',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 4,
),
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(14),
),
child: Icon(
Icons.hub_rounded,
color: theme.colorScheme.onPrimaryContainer,
),
),
title: Text(l10n.flood),
subtitle: const Text('Relay through repeaters across the mesh'),
trailing: const Icon(Icons.chevron_right_rounded),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
onTap: () => Navigator.of(context).pop(_AdvertMode.flood),
),
const SizedBox(height: 8),
ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 4,
),
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: theme.colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(14),
),
child: Icon(
Icons.near_me_rounded,
color: theme.colorScheme.onSecondaryContainer,
),
),
title: Text(l10n.direct),
subtitle: const Text('Nearby only, without repeater flooding'),
trailing: const Icon(Icons.chevron_right_rounded),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
onTap: () => Navigator.of(context).pop(_AdvertMode.direct),
),
],
),
),
),
);
}
Widget _buildActivityBadge({
required String label,
required int count,
required bool isActive,
required Color activeColor,
}) {
final color = isActive ? activeColor : Colors.grey;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 7,
height: 7,
decoration: BoxDecoration(shape: BoxShape.circle, color: color),
),
const SizedBox(width: 6),
Text(
'$label:$count',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: color,
),
),
],
),
);
}
Widget _buildCompactActivityIndicator({
required bool rxActive,
required bool txActive,
}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 7,
height: 7,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: rxActive ? Colors.green : Colors.grey,
),
),
const SizedBox(width: 6),
Container(
width: 7,
height: 7,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: txActive ? Colors.blue : Colors.grey,
),
),
],
),
);
}
Future<void> _advertiseDevice(
BuildContext context, {
bool floodMode = true,
}) async {
final connectionProvider = context.read<ConnectionProvider>(); final connectionProvider = context.read<ConnectionProvider>();
if (!connectionProvider.deviceInfo.isConnected) { if (!connectionProvider.deviceInfo.isConnected) {
@@ -325,8 +526,14 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
// Small delay to ensure the lat/lon is set // Small delay to ensure the lat/lon is set
await Future.delayed(const Duration(milliseconds: 100)); await Future.delayed(const Duration(milliseconds: 100));
// Send flood advertisement await connectionProvider.sendSelfAdvert(floodMode: floodMode);
await connectionProvider.sendSelfAdvert(floodMode: true);
if (context.mounted) {
ToastLogger.success(
context,
floodMode ? 'Flood advert sent' : 'Direct advert sent',
);
}
} catch (e) { } catch (e) {
debugPrint('❌ Failed to advertise device: $e'); debugPrint('❌ Failed to advertise device: $e');
if (context.mounted) { if (context.mounted) {
@@ -367,6 +574,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
appBar: shouldHideUI appBar: shouldHideUI
? null ? null
: AppBar( : AppBar(
toolbarHeight: 64,
titleSpacing: 8,
title: _buildCompactStatusBar(), title: _buildCompactStatusBar(),
actions: [ actions: [
Consumer<ConnectionProvider>( Consumer<ConnectionProvider>(
@@ -390,30 +599,6 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
PopupMenuButton( PopupMenuButton(
icon: const Icon(Icons.more_vert), icon: const Icon(Icons.more_vert),
itemBuilder: (context) => [ itemBuilder: (context) => [
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.map),
const SizedBox(width: 8),
Text(AppLocalizations.of(context)!.mapManagement),
],
),
onTap: () {
// Capture context-dependent objects before async gap
final navigator = Navigator.of(context);
final appProvider = context.read<AppProvider>();
Future.delayed(Duration.zero, () {
if (!mounted) return;
navigator.push(
MaterialPageRoute(
builder: (context) => MapManagementScreen(
tileCacheService: appProvider.tileCacheService,
),
),
);
});
},
),
PopupMenuItem( PopupMenuItem(
child: Row( child: Row(
children: [ children: [
@@ -565,7 +750,6 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
final deviceInfo = provider.deviceInfo; final deviceInfo = provider.deviceInfo;
final isConnected = deviceInfo.isConnected; final isConnected = deviceInfo.isConnected;
final isTcpConnected = provider.connectionMode == ConnectionMode.tcp; final isTcpConnected = provider.connectionMode == ConnectionMode.tcp;
final isBleConnected = isConnected && !isTcpConnected;
if (!isConnected) { if (!isConnected) {
// Disconnected state: show connect button // Disconnected state: show connect button
@@ -627,241 +811,244 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
); );
} }
// Connected state: LEFT | CENTER | RIGHT layout final theme = Theme.of(context);
return Row( final subtitleColor = theme.colorScheme.onSurfaceVariant;
children: [ final signalColor = isTcpConnected
// LEFT: Name + BT/Battery + Cog ? Colors.green
Expanded( : (deviceInfo.signalRssi != null
child: Row( ? BatteryDisplayHelper.getSignalColor(deviceInfo.signalRssi!)
mainAxisSize: MainAxisSize.min, : Colors.grey);
children: [
Flexible( return LayoutBuilder(
child: Column( builder: (context, constraints) {
crossAxisAlignment: CrossAxisAlignment.start, final isTight = constraints.maxWidth < 360;
return Row(
children: [
Flexible(
fit: FlexFit.loose,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest
.withValues(alpha: 0.55),
borderRadius: BorderRadius.circular(22),
),
child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Flexible(
deviceInfo.selfName ?? fit: FlexFit.loose,
AppLocalizations.of(context)!.appTitle, child: Column(
style: const TextStyle( mainAxisSize: MainAxisSize.min,
fontSize: 18, crossAxisAlignment: CrossAxisAlignment.start,
fontWeight: FontWeight.bold, children: [
Text(
deviceInfo.selfName ??
AppLocalizations.of(context)!.appTitle,
style:
(isTight
? theme.textTheme.titleSmall
: theme.textTheme.titleMedium)
?.copyWith(fontWeight: FontWeight.w700),
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isTcpConnected
? Icons.wifi_rounded
: Icons.bluetooth_connected_rounded,
size: 13,
color: signalColor,
),
if (!isTcpConnected &&
deviceInfo.signalRssi != null) ...[
const SizedBox(width: 4),
SizedBox(
width: 28,
child: Text(
'${deviceInfo.signalRssi}',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: signalColor,
),
maxLines: 1,
),
),
],
if (deviceInfo.batteryPercent != null) ...[
const SizedBox(width: 8),
Icon(
BatteryDisplayHelper.getBatteryIcon(
deviceInfo.batteryPercent!,
),
size: 13,
color:
BatteryDisplayHelper.getBatteryColor(
deviceInfo.batteryPercent!,
),
),
const SizedBox(width: 4),
SizedBox(
width: 30,
child: Text(
'${deviceInfo.batteryPercent!.round()}%',
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color:
BatteryDisplayHelper.getBatteryColor(
deviceInfo.batteryPercent!,
),
),
),
),
],
],
),
],
), ),
overflow: TextOverflow.ellipsis,
), ),
Row( const SizedBox(width: 4),
mainAxisSize: MainAxisSize.min, IconButton(
children: [ onPressed: () {
Icon( Navigator.push(
isTcpConnected context,
? Icons.wifi MaterialPageRoute(
: Icons.bluetooth_connected, builder: (context) =>
color: isTcpConnected const DeviceConfigScreen(),
? Colors.green ),
: (deviceInfo.signalRssi != null );
? BatteryDisplayHelper.getSignalColor( },
deviceInfo.signalRssi!, onLongPress: () {
) Navigator.push(
: Colors.grey), context,
size: 13, MaterialPageRoute(
builder: (context) => PacketLogScreen(
bleService: provider.bleService,
),
),
);
},
tooltip: AppLocalizations.of(context)!.settings,
icon: const Icon(Icons.tune_rounded),
color: subtitleColor,
style: IconButton.styleFrom(
backgroundColor: theme.colorScheme.surface,
foregroundColor: subtitleColor,
minimumSize: Size.square(isTight ? 38 : 40),
padding: EdgeInsets.zero,
),
),
const SizedBox(width: 4),
GestureDetector(
onTap: () async {
await _triggerAdvertFeedback();
if (!mounted || !context.mounted) return;
await _advertiseDevice(context);
},
onLongPress: () async {
await _triggerAdvertFeedback();
if (!mounted || !context.mounted) return;
final mode = await _showAdvertModeSheet(context);
if (!mounted || !context.mounted || mode == null) {
return;
}
await _advertiseDevice(
context,
floodMode: mode == _AdvertMode.flood,
);
},
child: Container(
width: isTight ? 38 : 40,
height: isTight ? 38 : 40,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
theme.colorScheme.primary,
theme.colorScheme.primary.withValues(
alpha: 0.78,
),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(12),
), ),
if (isBleConnected && child: Icon(
deviceInfo.signalRssi != null) ...[ Icons.campaign_rounded,
const SizedBox(width: 3), color: Colors.white,
Text( size: isTight ? 18 : 20,
'${deviceInfo.signalRssi}', ),
style: TextStyle( ),
fontSize: 11,
color: BatteryDisplayHelper.getSignalColor(
deviceInfo.signalRssi!,
),
),
),
],
if (isTcpConnected) ...[
const SizedBox(width: 3),
Text(
'WiFi',
style: const TextStyle(
fontSize: 11,
color: Colors.green,
),
),
],
if (deviceInfo.batteryPercent != null) ...[
const SizedBox(width: 8),
Icon(
BatteryDisplayHelper.getBatteryIcon(
deviceInfo.batteryPercent!,
),
color: BatteryDisplayHelper.getBatteryColor(
deviceInfo.batteryPercent!,
),
size: 13,
),
const SizedBox(width: 3),
Text(
'${deviceInfo.batteryPercent!.round()}%',
style: TextStyle(
fontSize: 11,
color: BatteryDisplayHelper.getBatteryColor(
deviceInfo.batteryPercent!,
),
),
),
],
],
), ),
], ],
), ),
), ),
// Settings cog - hidden in simple mode ),
if (!context.watch<AppProvider>().isSimpleMode) ...[ SizedBox(width: isTight ? 8 : 12),
const SizedBox(width: 8), if (_showRxTxIndicators)
GestureDetector( GestureDetector(
onTap: () { onLongPress: () {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => const DeviceConfigScreen(), builder: (context) =>
), PacketLogScreen(bleService: provider.bleService),
); ),
}, );
onLongPress: () { },
Navigator.push( child: isTight
context, ? _buildCompactActivityIndicator(
MaterialPageRoute( rxActive: provider.rxActivity,
builder: (context) => PacketLogScreen( txActive: provider.txActivity,
bleService: provider.bleService, )
: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh
.withValues(alpha: 0.85),
borderRadius: BorderRadius.circular(20),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
_buildActivityBadge(
label: 'RX',
count: provider.rxPacketCount,
isActive: provider.rxActivity,
activeColor: Colors.green,
),
const SizedBox(height: 6),
_buildActivityBadge(
label: 'TX',
count: provider.txPacketCount,
isActive: provider.txActivity,
activeColor: Colors.blue,
),
],
), ),
), ),
); )
}, else
child: Container( SizedBox(width: isTight ? 24 : 74),
width: 32, ],
height: 32, );
alignment: Alignment.center, },
child: const Icon(Icons.settings, size: 18),
),
),
],
],
),
),
// CENTER: Broadcast button
const SizedBox(width: 8),
FilledButton(
onPressed: () async {
// Capture platform before async operations
final platform = Theme.of(context).platform;
// iOS: Use haptic feedback (always works)
// Android: Try vibration package for better control
try {
if (platform == TargetPlatform.iOS) {
// iOS: Try multiple haptic types for reliability
await HapticFeedback.lightImpact();
await Future.delayed(const Duration(milliseconds: 50));
await HapticFeedback.lightImpact();
} else {
// Android vibration
if (await Vibration.hasVibrator()) {
await Vibration.vibrate(duration: 50);
} else {
await HapticFeedback.mediumImpact();
}
}
} catch (e) {
// Fallback if anything fails
debugPrint('Haptic feedback error: $e');
await HapticFeedback.vibrate();
}
if (!mounted) return;
if (!context.mounted) return;
_advertiseDevice(context);
},
style: FilledButton.styleFrom(
backgroundColor: Colors.blue.shade700,
foregroundColor: Colors.white,
padding: const EdgeInsets.all(10),
minimumSize: const Size(40, 40),
shape: const CircleBorder(),
),
child: const Icon(Icons.campaign, size: 20),
),
const SizedBox(width: 8),
// RIGHT: RX/TX indicators
if (_showRxTxIndicators)
GestureDetector(
onLongPress: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
PacketLogScreen(bleService: provider.bleService),
),
);
},
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 7,
height: 7,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: provider.rxActivity
? Colors.green
: Colors.grey.withValues(alpha: 0.3),
),
),
const SizedBox(width: 3),
Text(
'RX:${provider.rxPacketCount}',
style: const TextStyle(
fontSize: 10,
color: Colors.grey,
),
),
],
),
const SizedBox(height: 3),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 7,
height: 7,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: provider.txActivity
? Colors.blue
: Colors.grey.withValues(alpha: 0.3),
),
),
const SizedBox(width: 3),
Text(
'TX:${provider.txPacketCount}',
style: const TextStyle(
fontSize: 10,
color: Colors.grey,
),
),
],
),
],
),
)
else
const SizedBox(
width: 52,
), // Placeholder to maintain layout balance
],
); );
}, },
); );

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1436,10 +1436,6 @@ class _MessagesTabState extends State<MessagesTab> {
// Get all recent messages // Get all recent messages
final allMessages = messagesProvider.getRecentMessages(count: 100); final allMessages = messagesProvider.getRecentMessages(count: 100);
// Get simple mode setting from AppProvider
final appProvider = context.read<AppProvider>();
final isSimpleMode = appProvider.isSimpleMode;
List<Message> filteredMessages; List<Message> filteredMessages;
// If channel destination is selected, filter by selected channel. // If channel destination is selected, filter by selected channel.
@@ -1492,14 +1488,9 @@ class _MessagesTabState extends State<MessagesTab> {
filteredMessages = allMessages; filteredMessages = allMessages;
} }
// In simple mode, filter out system messages (toast logs) return filteredMessages
if (isSimpleMode) { .where((message) => !message.isSystemMessage)
filteredMessages = filteredMessages .toList();
.where((message) => !message.isSystemMessage)
.toList();
}
return filteredMessages;
} }
void _handleMessageTap(Message message) { void _handleMessageTap(Message message) {

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart' as flutter_map; import 'package:flutter_map/flutter_map.dart' as flutter_map;
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
@@ -10,9 +12,53 @@ import '../providers/contacts_provider.dart';
import '../providers/sensors_provider.dart'; import '../providers/sensors_provider.dart';
import '../utils/location_formats.dart'; import '../utils/location_formats.dart';
class SensorsTab extends StatelessWidget { class SensorsTab extends StatefulWidget {
const SensorsTab({super.key}); const SensorsTab({super.key});
@override
State<SensorsTab> createState() => _SensorsTabState();
}
class _SensorsTabState extends State<SensorsTab> {
Timer? _minuteTicker;
@override
void initState() {
super.initState();
_scheduleMinuteTicker();
}
@override
void dispose() {
_minuteTicker?.cancel();
super.dispose();
}
void _scheduleMinuteTicker() {
_minuteTicker?.cancel();
final now = DateTime.now();
final nextMinute = DateTime(
now.year,
now.month,
now.day,
now.hour,
now.minute + 1,
);
final delay = nextMinute.difference(now);
_minuteTicker = Timer(delay, () {
if (!mounted) return;
context.read<SensorsProvider>().clearExpiredRefreshStates();
setState(() {});
_minuteTicker = Timer.periodic(const Duration(minutes: 1), (_) {
if (!mounted) return;
context.read<SensorsProvider>().clearExpiredRefreshStates();
setState(() {});
});
});
}
Future<void> _showAddSensorSheet(BuildContext context) async { Future<void> _showAddSensorSheet(BuildContext context) async {
final sensorsProvider = context.read<SensorsProvider>(); final sensorsProvider = context.read<SensorsProvider>();
final contactsProvider = context.read<ContactsProvider>(); final contactsProvider = context.read<ContactsProvider>();
@@ -209,6 +255,11 @@ class SensorsTab extends StatelessWidget {
}, },
onCustomize: () => onCustomize: () =>
_showMetricSelector(context, key, contact), _showMetricSelector(context, key, contact),
onRefresh: () => sensorsProvider.refreshSensor(
publicKeyHex: key,
contactsProvider: contactsProvider,
connectionProvider: context.read<ConnectionProvider>(),
),
); );
}), }),
], ],
@@ -302,6 +353,7 @@ class _SensorCard extends StatelessWidget {
final Set<String> visibleFields; final Set<String> visibleFields;
final Map<String, int> fieldSpans; final Map<String, int> fieldSpans;
final Future<void> Function() onRemove; final Future<void> Function() onRemove;
final Future<void> Function() onRefresh;
final VoidCallback onCustomize; final VoidCallback onCustomize;
const _SensorCard({ const _SensorCard({
@@ -310,6 +362,7 @@ class _SensorCard extends StatelessWidget {
required this.visibleFields, required this.visibleFields,
required this.fieldSpans, required this.fieldSpans,
required this.onRemove, required this.onRemove,
required this.onRefresh,
required this.onCustomize, required this.onCustomize,
}); });
@@ -404,18 +457,24 @@ class _SensorCard extends StatelessWidget {
), ),
PopupMenuButton<String>( PopupMenuButton<String>(
onSelected: (value) async { onSelected: (value) async {
if (value == 'remove') { if (value == 'refresh') {
await onRefresh();
} else if (value == 'remove') {
await onRemove(); await onRemove();
} else if (value == 'customize') { } else if (value == 'customize') {
onCustomize(); onCustomize();
} }
}, },
itemBuilder: (context) => const [ itemBuilder: (context) => [
PopupMenuItem<String>( PopupMenuItem<String>(
value: 'refresh',
child: Text(l10n.refresh),
),
const PopupMenuItem<String>(
value: 'customize', value: 'customize',
child: Text('Customize fields'), child: Text('Customize fields'),
), ),
PopupMenuItem<String>( const PopupMenuItem<String>(
value: 'remove', value: 'remove',
child: Text('Remove'), child: Text('Remove'),
), ),
@@ -429,7 +488,9 @@ class _SensorCard extends StatelessWidget {
'This node is no longer available in the contact list.', 'This node is no longer available in the contact list.',
) )
else if (telemetry == null) else if (telemetry == null)
const Text('No telemetry received yet. Pull down to fetch it.') const Text(
'No telemetry received yet. Use Refresh from the menu or pull down to fetch it.',
)
else if (metrics.isEmpty) else if (metrics.isEmpty)
const Text( const Text(
'All fields are hidden. Use Visible fields to choose what to show.', 'All fields are hidden. Use Visible fields to choose what to show.',
@@ -573,7 +634,7 @@ class _SensorCard extends StatelessWidget {
String _formatTelemetryTime(DateTime timestamp) { String _formatTelemetryTime(DateTime timestamp) {
final diff = DateTime.now().difference(timestamp); final diff = DateTime.now().difference(timestamp);
if (diff.inMinutes < 1) return 'just now'; if (diff.inMinutes < 1) return 'now';
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago'; if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
if (diff.inHours < 24) return '${diff.inHours}h ago'; if (diff.inHours < 24) return '${diff.inHours}h ago';
return '${diff.inDays}d ago'; return '${diff.inDays}d ago';
@@ -673,49 +734,33 @@ class _MetricTile extends StatelessWidget {
final location = data.mapLocation; final location = data.mapLocation;
if (location == null) return; if (location == null) return;
await showDialog<void>( await Navigator.of(context).push(
context: context, MaterialPageRoute<void>(
builder: (dialogContext) { builder: (pageContext) {
return Dialog( return Scaffold(
insetPadding: const EdgeInsets.all(16), appBar: AppBar(
clipBehavior: Clip.antiAlias, title: Column(
child: SizedBox( crossAxisAlignment: CrossAxisAlignment.start,
height: 420, children: [
child: Column( Text(data.label),
Text(
data.value,
style: Theme.of(pageContext).textTheme.bodySmall,
),
],
),
),
body: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Padding( if (data.secondaryValue != null)
padding: const EdgeInsets.fromLTRB(16, 14, 8, 8), Padding(
child: Row( padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
children: [ child: Text(
Expanded( data.secondaryValue!,
child: Column( style: Theme.of(pageContext).textTheme.bodyMedium,
crossAxisAlignment: CrossAxisAlignment.start, ),
children: [
Text(
data.label,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
data.value,
style: Theme.of(context).textTheme.bodyMedium,
),
if (data.secondaryValue != null)
Text(
data.secondaryValue!,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
IconButton(
onPressed: () => Navigator.of(dialogContext).pop(),
icon: const Icon(Icons.close),
),
],
), ),
),
Expanded( Expanded(
child: flutter_map.FlutterMap( child: flutter_map.FlutterMap(
options: flutter_map.MapOptions( options: flutter_map.MapOptions(
@@ -748,9 +793,10 @@ class _MetricTile extends StatelessWidget {
), ),
], ],
), ),
), );
); },
}, fullscreenDialog: true,
),
); );
} }

View File

@@ -67,6 +67,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
Uint8List? _previewCompressedBytes; Uint8List? _previewCompressedBytes;
bool _isPreviewLoading = false; bool _isPreviewLoading = false;
bool _showCurrentImagePreview = true; bool _showCurrentImagePreview = true;
bool _fastLocationUpdatesEnabled = false;
double _fastLocationMovementThresholdMeters = 10.0;
int _fastLocationActiveCadenceSeconds = 10;
final ImagePicker _imagePicker = ImagePicker(); final ImagePicker _imagePicker = ImagePicker();
final LocationTrackingService _locationService = LocationTrackingService(); final LocationTrackingService _locationService = LocationTrackingService();
@@ -81,6 +84,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
_loadVoiceBitratePreference(); _loadVoiceBitratePreference();
_loadRouteHashSizePreference(); _loadRouteHashSizePreference();
_loadImagePreferences(); _loadImagePreferences();
_loadFastLocationSettings();
} }
@override @override
@@ -169,6 +173,108 @@ class _SettingsScreenState extends State<SettingsScreen> {
await _refreshImageModePreview(); await _refreshImageModePreview();
} }
Future<void> _loadFastLocationSettings() async {
await _locationService.loadSettings();
if (!mounted) return;
setState(() {
_fastLocationUpdatesEnabled = _locationService.fastLocationUpdatesEnabled;
_fastLocationMovementThresholdMeters =
_locationService.fastLocationMovementThresholdMeters;
_fastLocationActiveCadenceSeconds =
_locationService.fastLocationActiveCadenceSeconds;
});
}
Future<void> _setFastLocationUpdatesEnabled(bool enabled) async {
await _locationService.setFastLocationUpdatesEnabled(enabled);
if (!mounted) return;
setState(() {
_fastLocationUpdatesEnabled = _locationService.fastLocationUpdatesEnabled;
});
}
Future<void> _editFastLocationMovementThreshold() async {
final controller = TextEditingController(
text: _fastLocationMovementThresholdMeters.toStringAsFixed(0),
);
final value = await showDialog<double>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Fast GPS movement threshold'),
content: TextField(
controller: controller,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(
labelText: 'Meters',
helperText: 'Valid range: 1 to 1000 meters',
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(AppLocalizations.of(context)!.cancel),
),
FilledButton(
onPressed: () {
final parsed = double.tryParse(controller.text.trim());
if (parsed == null) return;
Navigator.pop(context, parsed.clamp(1.0, 1000.0));
},
child: const Text('Save'),
),
],
),
);
if (value == null) return;
await _locationService.updateFastLocationMovementThreshold(value);
if (!mounted) return;
setState(() {
_fastLocationMovementThresholdMeters =
_locationService.fastLocationMovementThresholdMeters;
});
}
Future<void> _editFastLocationActiveCadence() async {
final controller = TextEditingController(
text: _fastLocationActiveCadenceSeconds.toString(),
);
final value = await showDialog<int>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Fast GPS active-use interval'),
content: TextField(
controller: controller,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Seconds',
helperText: 'Valid range: 5 to 60 seconds',
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(AppLocalizations.of(context)!.cancel),
),
FilledButton(
onPressed: () {
final parsed = int.tryParse(controller.text.trim());
if (parsed == null) return;
Navigator.pop(context, parsed.clamp(5, 60));
},
child: const Text('Save'),
),
],
),
);
if (value == null) return;
await _locationService.updateFastLocationActiveCadenceSeconds(value);
if (!mounted) return;
setState(() {
_fastLocationActiveCadenceSeconds =
_locationService.fastLocationActiveCadenceSeconds;
});
}
Future<void> _saveImageMaxSize(int size) async { Future<void> _saveImageMaxSize(int size) async {
await ImagePreferences.setMaxSize(size); await ImagePreferences.setMaxSize(size);
if (!mounted) return; if (!mounted) return;
@@ -758,19 +864,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
_buildSectionHeader('Navigation'), _buildSectionHeader('Navigation'),
_buildSettingsCard([ _buildSettingsCard([
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.visibility_off),
title: Text(AppLocalizations.of(context)!.simpleMode),
subtitle: Text(
AppLocalizations.of(context)!.simpleModeDescription,
),
value: appProvider.isSimpleMode,
onChanged: (value) async {
await appProvider.toggleSimpleMode(value);
},
),
),
Consumer<AppProvider>( Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile( builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.map_outlined), secondary: const Icon(Icons.map_outlined),
@@ -1018,6 +1111,31 @@ class _SettingsScreenState extends State<SettingsScreen> {
_buildSectionHeader(AppLocalizations.of(context)!.permissionsSection), _buildSectionHeader(AppLocalizations.of(context)!.permissionsSection),
_buildSettingsCard([ _buildSettingsCard([
SwitchListTile(
secondary: const Icon(Icons.gps_fixed),
title: const Text('Fast private GPS updates'),
subtitle: const Text(
'Use private zero-hop updates while moving significantly or while actively using map/messages.',
),
value: _fastLocationUpdatesEnabled,
onChanged: _setFastLocationUpdatesEnabled,
),
ListTile(
leading: const Icon(Icons.straighten),
title: const Text('Movement threshold'),
subtitle: Text(
'${_fastLocationMovementThresholdMeters.toStringAsFixed(0)} m',
),
trailing: const Icon(Icons.chevron_right),
onTap: _editFastLocationMovementThreshold,
),
ListTile(
leading: const Icon(Icons.timer),
title: const Text('Active-use update interval'),
subtitle: Text('$_fastLocationActiveCadenceSeconds s'),
trailing: const Icon(Icons.chevron_right),
onTap: _editFastLocationActiveCadence,
),
ListTile( ListTile(
leading: const Icon(Icons.location_on), leading: const Icon(Icons.location_on),
title: Text(AppLocalizations.of(context)!.locationPermission), title: Text(AppLocalizations.of(context)!.locationPermission),

View File

@@ -15,7 +15,7 @@ class WelcomeWizardScreen extends StatefulWidget {
class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> { class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
final PageController _pageController = PageController(); final PageController _pageController = PageController();
int _currentPage = 0; int _currentPage = 0;
static const int _totalPages = 6; static const int _totalPages = 5;
@override @override
void dispose() { void dispose() {
@@ -99,7 +99,6 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
children: [ children: [
_buildWelcomePage(context, l10n, colorScheme), _buildWelcomePage(context, l10n, colorScheme),
_buildConnectingPage(context, l10n, colorScheme), _buildConnectingPage(context, l10n, colorScheme),
_buildSimpleModePage(context, l10n, colorScheme),
_buildChannelPage(context, l10n, colorScheme), _buildChannelPage(context, l10n, colorScheme),
_buildContactsPage(context, l10n, colorScheme), _buildContactsPage(context, l10n, colorScheme),
_buildMapPage(context, l10n, colorScheme), _buildMapPage(context, l10n, colorScheme),
@@ -182,42 +181,9 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
title: l10n.wizardConnectingTitle, title: l10n.wizardConnectingTitle,
description: l10n.wizardConnectingDescription, description: l10n.wizardConnectingDescription,
features: [ features: [
_FeatureItem( _FeatureItem(icon: Icons.radio, text: l10n.wizardConnectingFeature1),
icon: Icons.radio, _FeatureItem(icon: Icons.link, text: l10n.wizardConnectingFeature2),
text: l10n.wizardConnectingFeature1, _FeatureItem(icon: Icons.wifi_off, text: l10n.wizardConnectingFeature3),
),
_FeatureItem(
icon: Icons.link,
text: l10n.wizardConnectingFeature2,
),
_FeatureItem(
icon: Icons.wifi_off,
text: l10n.wizardConnectingFeature3,
),
],
colorScheme: colorScheme,
);
}
Widget _buildSimpleModePage(
BuildContext context,
AppLocalizations l10n,
ColorScheme colorScheme,
) {
return _buildPage(
icon: Icons.toggle_on,
iconColor: Colors.green,
title: l10n.wizardSimpleModeTitle,
description: l10n.wizardSimpleModeDescription,
features: [
_FeatureItem(
icon: Icons.check_circle_outline,
text: l10n.wizardSimpleModeFeature1,
),
_FeatureItem(
icon: Icons.settings,
text: l10n.wizardSimpleModeFeature2,
),
], ],
colorScheme: colorScheme, colorScheme: colorScheme,
); );
@@ -234,18 +200,9 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
title: l10n.wizardChannelTitle, title: l10n.wizardChannelTitle,
description: l10n.wizardChannelDescription, description: l10n.wizardChannelDescription,
features: [ features: [
_FeatureItem( _FeatureItem(icon: Icons.public, text: l10n.wizardChannelFeature1),
icon: Icons.public, _FeatureItem(icon: Icons.groups, text: l10n.wizardChannelFeature2),
text: l10n.wizardChannelFeature1, _FeatureItem(icon: Icons.send, text: l10n.wizardChannelFeature3),
),
_FeatureItem(
icon: Icons.groups,
text: l10n.wizardChannelFeature2,
),
_FeatureItem(
icon: Icons.send,
text: l10n.wizardChannelFeature3,
),
], ],
colorScheme: colorScheme, colorScheme: colorScheme,
); );
@@ -262,14 +219,8 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
title: l10n.wizardContactsTitle, title: l10n.wizardContactsTitle,
description: l10n.wizardContactsDescription, description: l10n.wizardContactsDescription,
features: [ features: [
_FeatureItem( _FeatureItem(icon: Icons.person_add, text: l10n.wizardContactsFeature1),
icon: Icons.person_add, _FeatureItem(icon: Icons.chat, text: l10n.wizardContactsFeature2),
text: l10n.wizardContactsFeature1,
),
_FeatureItem(
icon: Icons.chat,
text: l10n.wizardContactsFeature2,
),
_FeatureItem( _FeatureItem(
icon: Icons.battery_std, icon: Icons.battery_std,
text: l10n.wizardContactsFeature3, text: l10n.wizardContactsFeature3,
@@ -290,22 +241,13 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
title: l10n.wizardMapTitle, title: l10n.wizardMapTitle,
description: l10n.wizardMapDescription, description: l10n.wizardMapDescription,
features: [ features: [
_FeatureItem( _FeatureItem(icon: Icons.location_on, text: l10n.wizardMapFeature1),
icon: Icons.location_on,
text: l10n.wizardMapFeature1,
),
_FeatureItem( _FeatureItem(
icon: Icons.person_pin_circle, icon: Icons.person_pin_circle,
text: l10n.wizardMapFeature2, text: l10n.wizardMapFeature2,
), ),
_FeatureItem( _FeatureItem(icon: Icons.offline_pin, text: l10n.wizardMapFeature3),
icon: Icons.offline_pin, _FeatureItem(icon: Icons.draw, text: l10n.wizardMapFeature4),
text: l10n.wizardMapFeature3,
),
_FeatureItem(
icon: Icons.draw,
text: l10n.wizardMapFeature4,
),
], ],
colorScheme: colorScheme, colorScheme: colorScheme,
); );
@@ -332,20 +274,16 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
color: iconColor.withValues(alpha: 0.1), color: iconColor.withValues(alpha: 0.1),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: Icon( child: Icon(icon, size: 80, color: iconColor),
icon,
size: 80,
color: iconColor,
),
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
// Title // Title
Text( Text(
title, title,
style: Theme.of(context).textTheme.headlineMedium?.copyWith( style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: colorScheme.onSurface, color: colorScheme.onSurface,
), ),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -353,36 +291,33 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
Text( Text(
description, description,
style: Theme.of(context).textTheme.bodyLarge?.copyWith( style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.7), color: colorScheme.onSurface.withValues(alpha: 0.7),
height: 1.5, height: 1.5,
), ),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
if (features != null && features.isNotEmpty) ...[ if (features != null && features.isNotEmpty) ...[
const SizedBox(height: 32), const SizedBox(height: 32),
// Features list // Features list
...features.map((feature) => Padding( ...features.map(
padding: const EdgeInsets.symmetric(vertical: 8.0), (feature) => Padding(
child: Row( padding: const EdgeInsets.symmetric(vertical: 8.0),
children: [ child: Row(
Icon( children: [
feature.icon, Icon(feature.icon, color: colorScheme.primary, size: 24),
color: colorScheme.primary, const SizedBox(width: 16),
size: 24, Expanded(
), child: Text(
const SizedBox(width: 16), feature.text,
Expanded( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
child: Text( color: colorScheme.onSurface,
feature.text,
style:
Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface,
),
), ),
), ),
], ),
), ],
)), ),
),
),
], ],
const SizedBox(height: 20), const SizedBox(height: 20),
], ],

View File

@@ -41,6 +41,12 @@ class LocationTrackingService {
static const String _prefKeyGpsUpdateDistance = 'map_gps_update_distance'; static const String _prefKeyGpsUpdateDistance = 'map_gps_update_distance';
static const String _prefKeyLastLat = 'background_last_lat'; static const String _prefKeyLastLat = 'background_last_lat';
static const String _prefKeyLastLon = 'background_last_lon'; static const String _prefKeyLastLon = 'background_last_lon';
static const String _prefKeyFastLocationEnabled =
'fast_location_updates_enabled';
static const String _prefKeyFastMovementThreshold =
'fast_location_movement_threshold_meters';
static const String _prefKeyFastActiveCadence =
'fast_location_active_cadence_seconds';
// ============================================================================ // ============================================================================
// Configuration Properties // Configuration Properties
@@ -58,6 +64,15 @@ class LocationTrackingService {
/// GPS update distance filter for position stream /// GPS update distance filter for position stream
double gpsUpdateDistance = 10.0; double gpsUpdateDistance = 10.0;
/// Whether private fast GPS updates are enabled
bool fastLocationUpdatesEnabled = false;
/// Distance threshold for fast GPS updates
double fastLocationMovementThresholdMeters = 10.0;
/// Cadence for active-use fast GPS updates
int fastLocationActiveCadenceSeconds = 10;
// ============================================================================ // ============================================================================
// State Properties // State Properties
// ============================================================================ // ============================================================================
@@ -84,6 +99,11 @@ class LocationTrackingService {
/// Position stream subscription /// Position stream subscription
StreamSubscription<Position>? _positionSubscription; StreamSubscription<Position>? _positionSubscription;
Timer? _fastLocationTimer;
bool _isFastLocationActiveUse = false;
DateTime? _lastFastLocationSentAt;
Position? _lastFastLocationSentPosition;
// ============================================================================ // ============================================================================
// Callback Properties // Callback Properties
// ============================================================================ // ============================================================================
@@ -100,6 +120,9 @@ class LocationTrackingService {
/// Called when tracking state changes /// Called when tracking state changes
void Function(bool isTracking)? onTrackingStateChanged; void Function(bool isTracking)? onTrackingStateChanged;
/// Called when a fast private GPS update should be sent
void Function(Position position, String reason)? onFastLocationUpdate;
// ============================================================================ // ============================================================================
// Initialization // Initialization
// ============================================================================ // ============================================================================
@@ -178,7 +201,9 @@ class LocationTrackingService {
for (int attempt = 0; attempt <= retryCount; attempt++) { for (int attempt = 0; attempt <= retryCount; attempt++) {
try { try {
if (attempt > 0) { if (attempt > 0) {
debugPrint('🔄 [LocationTracking] Retry attempt $attempt/$retryCount'); debugPrint(
'🔄 [LocationTracking] Retry attempt $attempt/$retryCount',
);
// Exponential backoff: wait 2^attempt seconds before retry // Exponential backoff: wait 2^attempt seconds before retry
await Future.delayed(Duration(seconds: 1 << attempt)); await Future.delayed(Duration(seconds: 1 << attempt));
} }
@@ -192,21 +217,29 @@ class LocationTrackingService {
currentPosition = position; currentPosition = position;
if (attempt > 0) { if (attempt > 0) {
debugPrint('✅ [LocationTracking] Position acquired after $attempt retries'); debugPrint(
'✅ [LocationTracking] Position acquired after $attempt retries',
);
} }
return position; return position;
} catch (e) { } catch (e) {
final isLastAttempt = attempt == retryCount; final isLastAttempt = attempt == retryCount;
if (isLastAttempt) { if (isLastAttempt) {
debugPrint('❌ [LocationTracking] Failed to get position after $retryCount retries: $e'); debugPrint(
'❌ [LocationTracking] Failed to get position after $retryCount retries: $e',
);
// Only call error callback on final failure, and make it user-friendly // Only call error callback on final failure, and make it user-friendly
if (e.toString().contains('TimeoutException')) { if (e.toString().contains('TimeoutException')) {
onError?.call('GPS signal weak. Position stream will continue trying...'); onError?.call(
'GPS signal weak. Position stream will continue trying...',
);
} else { } else {
onError?.call('Failed to get GPS position. Check device settings.'); onError?.call('Failed to get GPS position. Check device settings.');
} }
} else { } else {
debugPrint('⚠️ [LocationTracking] Position attempt $attempt failed: $e'); debugPrint(
'⚠️ [LocationTracking] Position attempt $attempt failed: $e',
);
} }
if (isLastAttempt) { if (isLastAttempt) {
@@ -244,16 +277,16 @@ class LocationTrackingService {
/// GPS tracking works WITHOUT BLE connection - device broadcasts are simply skipped. /// GPS tracking works WITHOUT BLE connection - device broadcasts are simply skipped.
Future<bool> startTracking({double? distanceThreshold}) async { Future<bool> startTracking({double? distanceThreshold}) async {
if (!_isInitialized) { if (!_isInitialized) {
debugPrint( debugPrint('⚠️ [LocationTracking] Service not initialized');
'⚠️ [LocationTracking] Service not initialized',
);
onError?.call('Location tracking service not initialized'); onError?.call('Location tracking service not initialized');
return false; return false;
} }
// Allow tracking without BLE connection - broadcasts will be skipped // Allow tracking without BLE connection - broadcasts will be skipped
if (_bleService == null || !_bleService!.isConnected) { if (_bleService == null || !_bleService!.isConnected) {
debugPrint(' [LocationTracking] Starting GPS tracking without BLE connection (broadcasts disabled)'); debugPrint(
' [LocationTracking] Starting GPS tracking without BLE connection (broadcasts disabled)',
);
} }
// Check permissions // Check permissions
@@ -271,17 +304,20 @@ class LocationTrackingService {
// Try to get initial position in background (non-blocking) // Try to get initial position in background (non-blocking)
// This will populate currentPosition but won't block tracking startup // This will populate currentPosition but won't block tracking startup
getCurrentPosition( getCurrentPosition(timeLimit: const Duration(seconds: 10), retryCount: 1)
timeLimit: const Duration(seconds: 10), .then((position) {
retryCount: 1, if (position != null) {
).then((position) { debugPrint(
if (position != null) { '✅ [LocationTracking] Initial position acquired in background',
debugPrint('✅ [LocationTracking] Initial position acquired in background'); );
} }
}).catchError((error) { })
debugPrint('⚠️ [LocationTracking] Background initial position failed: $error'); .catchError((error) {
// Not critical - position stream will eventually provide position debugPrint(
}); '⚠️ [LocationTracking] Background initial position failed: $error',
);
// Not critical - position stream will eventually provide position
});
// Start position stream immediately (don't wait for initial position) // Start position stream immediately (don't wait for initial position)
try { try {
@@ -296,6 +332,7 @@ class LocationTrackingService {
isTracking = true; isTracking = true;
onTrackingStateChanged?.call(true); onTrackingStateChanged?.call(true);
_refreshFastLocationTimer();
debugPrint( debugPrint(
'✅ [LocationTracking] Tracking started with ${threshold}m threshold', '✅ [LocationTracking] Tracking started with ${threshold}m threshold',
@@ -318,6 +355,7 @@ class LocationTrackingService {
isTracking = false; isTracking = false;
onTrackingStateChanged?.call(false); onTrackingStateChanged?.call(false);
_refreshFastLocationTimer();
// Reset first position flag so next connection starts fresh // Reset first position flag so next connection starts fresh
_firstPositionSet = false; _firstPositionSet = false;
@@ -361,6 +399,8 @@ class LocationTrackingService {
// Notify listeners // Notify listeners
onPositionUpdate?.call(position); onPositionUpdate?.call(position);
_evaluateFastLocationMovement(position);
// SPECIAL CASE: First stable position after connection // SPECIAL CASE: First stable position after connection
// Set lat/lon on device WITHOUT broadcasting to mesh network // Set lat/lon on device WITHOUT broadcasting to mesh network
if (!_firstPositionSet) { if (!_firstPositionSet) {
@@ -378,12 +418,16 @@ class LocationTrackingService {
/// Updates the device's advertised lat/lon but does NOT send an advertisement. /// Updates the device's advertised lat/lon but does NOT send an advertisement.
void _setInitialPosition(Position position) async { void _setInitialPosition(Position position) async {
if (_bleService == null || !_bleService!.isConnected) { if (_bleService == null || !_bleService!.isConnected) {
debugPrint('⚠️ [LocationTracking] Cannot set initial position: BLE not connected'); debugPrint(
'⚠️ [LocationTracking] Cannot set initial position: BLE not connected',
);
return; return;
} }
try { try {
debugPrint('📍 [LocationTracking] Setting initial position (no broadcast)'); debugPrint(
'📍 [LocationTracking] Setting initial position (no broadcast)',
);
// Update device's advertised location WITHOUT sending advertisement // Update device's advertised location WITHOUT sending advertisement
await _bleService!.setAdvertLatLon( await _bleService!.setAdvertLatLon(
@@ -414,7 +458,94 @@ class LocationTrackingService {
void _checkAndBroadcast(Position position) { void _checkAndBroadcast(Position position) {
// Automatic broadcasting disabled // Automatic broadcasting disabled
// Use the manual advert button instead // Use the manual advert button instead
debugPrint(' ⏸️ [LocationTracking] Automatic broadcasting disabled (use advert button)'); debugPrint(
' ⏸️ [LocationTracking] Automatic broadcasting disabled (use advert button)',
);
}
void setFastLocationActiveUse(bool isActive) {
if (_isFastLocationActiveUse == isActive) return;
_isFastLocationActiveUse = isActive;
_refreshFastLocationTimer();
}
Future<void> setFastLocationUpdatesEnabled(bool enabled) async {
fastLocationUpdatesEnabled = enabled;
await saveSettings();
_refreshFastLocationTimer();
}
Future<void> updateFastLocationMovementThreshold(double meters) async {
fastLocationMovementThresholdMeters = meters.clamp(1.0, 1000.0);
await saveSettings();
}
Future<void> updateFastLocationActiveCadenceSeconds(int seconds) async {
fastLocationActiveCadenceSeconds = seconds.clamp(5, 60);
await saveSettings();
_refreshFastLocationTimer();
}
void _evaluateFastLocationMovement(Position position) {
if (!fastLocationUpdatesEnabled) return;
final previous = _lastFastLocationSentPosition;
if (previous == null) {
_emitFastLocationUpdate(position, reason: 'initial');
return;
}
final distance = Geolocator.distanceBetween(
previous.latitude,
previous.longitude,
position.latitude,
position.longitude,
);
if (distance >= fastLocationMovementThresholdMeters) {
_emitFastLocationUpdate(position, reason: 'movement');
}
}
void _refreshFastLocationTimer() {
_fastLocationTimer?.cancel();
_fastLocationTimer = null;
if (!isTracking ||
!fastLocationUpdatesEnabled ||
!_isFastLocationActiveUse) {
return;
}
_fastLocationTimer = Timer.periodic(
Duration(seconds: fastLocationActiveCadenceSeconds),
(_) {
final position = currentPosition;
if (position == null) return;
_emitFastLocationUpdate(position, reason: 'active_use');
},
);
}
void _emitFastLocationUpdate(Position position, {required String reason}) {
if (!fastLocationUpdatesEnabled) return;
final now = DateTime.now();
final previous = _lastFastLocationSentPosition;
final previousTime = _lastFastLocationSentAt;
if (previous != null && previousTime != null) {
final distance = Geolocator.distanceBetween(
previous.latitude,
previous.longitude,
position.latitude,
position.longitude,
);
final elapsedMs = now.difference(previousTime).inMilliseconds;
if (distance < 1.0 && elapsedMs < 3000) {
return;
}
}
_lastFastLocationSentPosition = position;
_lastFastLocationSentAt = now;
onFastLocationUpdate?.call(position, reason);
} }
// ============================================================================ // ============================================================================
@@ -457,7 +588,9 @@ class LocationTrackingService {
await _bleService!.sendSelfAdvert(floodMode: true); await _bleService!.sendSelfAdvert(floodMode: true);
debugPrint('✅ [LocationTracking] Manual broadcast successful'); debugPrint('✅ [LocationTracking] Manual broadcast successful');
debugPrint(' Automatic broadcasts will resume after ${minTimeIntervalSeconds}s'); debugPrint(
' Automatic broadcasts will resume after ${minTimeIntervalSeconds}s',
);
onBroadcastSent?.call(position); onBroadcastSent?.call(position);
return true; return true;
@@ -480,12 +613,24 @@ class LocationTrackingService {
maxDistanceMeters = prefs.getDouble(_prefKeyMaxDistance) ?? 100.0; maxDistanceMeters = prefs.getDouble(_prefKeyMaxDistance) ?? 100.0;
minTimeIntervalSeconds = prefs.getInt(_prefKeyMinTimeInterval) ?? 30; minTimeIntervalSeconds = prefs.getInt(_prefKeyMinTimeInterval) ?? 30;
gpsUpdateDistance = prefs.getDouble(_prefKeyGpsUpdateDistance) ?? 10.0; gpsUpdateDistance = prefs.getDouble(_prefKeyGpsUpdateDistance) ?? 10.0;
fastLocationUpdatesEnabled =
prefs.getBool(_prefKeyFastLocationEnabled) ?? false;
fastLocationMovementThresholdMeters =
(prefs.getDouble(_prefKeyFastMovementThreshold) ?? gpsUpdateDistance)
.clamp(1.0, 1000.0);
fastLocationActiveCadenceSeconds =
(prefs.getInt(_prefKeyFastActiveCadence) ?? 10).clamp(5, 60);
debugPrint('✅ [LocationTracking] Settings loaded'); debugPrint('✅ [LocationTracking] Settings loaded');
debugPrint(' Min distance: ${minDistanceMeters}m'); debugPrint(' Min distance: ${minDistanceMeters}m');
debugPrint(' Max distance: ${maxDistanceMeters}m'); debugPrint(' Max distance: ${maxDistanceMeters}m');
debugPrint(' Min time interval: ${minTimeIntervalSeconds}s'); debugPrint(' Min time interval: ${minTimeIntervalSeconds}s');
debugPrint(' GPS update distance: ${gpsUpdateDistance}m'); debugPrint(' GPS update distance: ${gpsUpdateDistance}m');
debugPrint(' Fast updates enabled: $fastLocationUpdatesEnabled');
debugPrint(
' Fast movement threshold: ${fastLocationMovementThresholdMeters}m',
);
debugPrint(' Fast active cadence: ${fastLocationActiveCadenceSeconds}s');
} }
/// Save settings to SharedPreferences /// Save settings to SharedPreferences
@@ -497,6 +642,18 @@ class LocationTrackingService {
await prefs.setInt(_prefKeyMinTimeInterval, minTimeIntervalSeconds); await prefs.setInt(_prefKeyMinTimeInterval, minTimeIntervalSeconds);
await prefs.setDouble(_prefKeyGpsUpdateDistance, gpsUpdateDistance); await prefs.setDouble(_prefKeyGpsUpdateDistance, gpsUpdateDistance);
await prefs.setBool(_prefKeyEnabled, isTracking); await prefs.setBool(_prefKeyEnabled, isTracking);
await prefs.setBool(
_prefKeyFastLocationEnabled,
fastLocationUpdatesEnabled,
);
await prefs.setDouble(
_prefKeyFastMovementThreshold,
fastLocationMovementThresholdMeters,
);
await prefs.setInt(
_prefKeyFastActiveCadence,
fastLocationActiveCadenceSeconds,
);
debugPrint('✅ [LocationTracking] Settings saved'); debugPrint('✅ [LocationTracking] Settings saved');
} }
@@ -509,6 +666,7 @@ class LocationTrackingService {
void dispose() { void dispose() {
debugPrint('🗑️ [LocationTracking] Disposing service'); debugPrint('🗑️ [LocationTracking] Disposing service');
_positionSubscription?.cancel(); _positionSubscription?.cancel();
_fastLocationTimer?.cancel();
_positionSubscription = null; _positionSubscription = null;
_bleService = null; _bleService = null;
_isInitialized = false; _isInitialized = false;

View File

@@ -1,273 +0,0 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:mbtiles/mbtiles.dart';
/// Metadata information extracted from an MBTiles file
class MbtilesMetadata {
final String name;
final String? description;
final String? version;
final String? attribution;
final String? bounds; // "minLon,minLat,maxLon,maxLat"
final String? center; // "lon,lat,zoom"
final int? minZoom;
final int? maxZoom;
final String? format; // "pbf", "png", "jpg", etc.
final String? type; // "overlay", "baselayer"
final String? json; // Additional metadata JSON
final File file;
final int fileSize;
const MbtilesMetadata({
required this.name,
this.description,
this.version,
this.attribution,
this.bounds,
this.center,
this.minZoom,
this.maxZoom,
this.format,
this.type,
this.json,
required this.file,
required this.fileSize,
});
/// Check if this is a vector tile MBTiles file
bool get isVector => format == 'pbf' || format == 'mvt';
/// Parse bounds string into [minLon, minLat, maxLon, maxLat]
List<double>? get boundsCoordinates {
if (bounds == null) return null;
try {
final parts = bounds!.split(',');
if (parts.length != 4) return null;
return parts.map((s) => double.parse(s.trim())).toList();
} catch (e) {
debugPrint('Error parsing bounds: $e');
return null;
}
}
/// Parse center string into [lon, lat, zoom]
List<double>? get centerCoordinates {
if (center == null) return null;
try {
final parts = center!.split(',');
if (parts.length < 2) return null;
return parts.map((s) => double.parse(s.trim())).toList();
} catch (e) {
debugPrint('Error parsing center: $e');
return null;
}
}
/// Get file size in human-readable format
String get fileSizeFormatted {
if (fileSize < 1024) {
return '$fileSize B';
} else if (fileSize < 1024 * 1024) {
return '${(fileSize / 1024).toStringAsFixed(1)} KB';
} else if (fileSize < 1024 * 1024 * 1024) {
return '${(fileSize / (1024 * 1024)).toStringAsFixed(1)} MB';
} else {
return '${(fileSize / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
}
}
}
/// Service for managing MBTiles files for offline vector maps
class MbtilesService {
static const String _mbtilesDirectory = 'offline_maps';
/// Get the directory where MBTiles files are stored
Future<Directory> getMbtilesDirectory() async {
final appDocDir = await getApplicationDocumentsDirectory();
final mbtilesDir = Directory('${appDocDir.path}/$_mbtilesDirectory');
// Create directory if it doesn't exist
if (!await mbtilesDir.exists()) {
await mbtilesDir.create(recursive: true);
}
return mbtilesDir;
}
/// List all MBTiles files in the offline maps directory
Future<List<File>> listMbtilesFiles() async {
final dir = await getMbtilesDirectory();
try {
final files = await dir
.list()
.where((entity) => entity is File && entity.path.endsWith('.mbtiles'))
.map((entity) => entity as File)
.toList();
return files;
} catch (e) {
debugPrint('Error listing MBTiles files: $e');
return [];
}
}
/// Get metadata from an MBTiles file
Future<MbtilesMetadata?> getMetadata(File file) async {
try {
// Check if file exists
if (!await file.exists()) {
debugPrint('MBTiles file does not exist: ${file.path}');
return null;
}
// Get file size
final fileSize = await file.length();
// Open MBTiles file
final mbtiles = MbTiles(mbtilesPath: file.path);
// Get metadata from MBTiles
final metadata = mbtiles.getMetadata();
// Convert bounds object to string if available
String? boundsStr;
if (metadata.bounds != null) {
boundsStr = metadata.bounds.toString();
}
return MbtilesMetadata(
name: metadata.name,
description: metadata.description,
version: metadata.version?.toString(),
attribution: null, // Not available in new API
bounds: boundsStr,
center: null, // Not available in new API
minZoom: metadata.minZoom?.toInt(),
maxZoom: metadata.maxZoom?.toInt(),
format: metadata.format,
type: metadata.type?.name,
json: null, // Not available in new API
file: file,
fileSize: fileSize,
);
} catch (e) {
debugPrint('Error reading MBTiles metadata from ${file.path}: $e');
return null;
}
}
/// Get metadata for all MBTiles files
Future<List<MbtilesMetadata>> getAllMetadata() async {
final files = await listMbtilesFiles();
final metadataList = <MbtilesMetadata>[];
for (final file in files) {
final metadata = await getMetadata(file);
if (metadata != null) {
metadataList.add(metadata);
}
}
return metadataList;
}
/// Import an MBTiles file from an external location
Future<File?> importMbtilesFile(String sourcePath) async {
try {
final sourceFile = File(sourcePath);
// Verify source file exists
if (!await sourceFile.exists()) {
debugPrint('Source file does not exist: $sourcePath');
return null;
}
// Get destination directory
final destDir = await getMbtilesDirectory();
final fileName = _getFileName(sourceFile);
final destPath = '${destDir.path}/$fileName';
// Copy file to destination
final destFile = await sourceFile.copy(destPath);
debugPrint('Imported MBTiles file to: $destPath');
return destFile;
} catch (e) {
debugPrint('Error importing MBTiles file: $e');
return null;
}
}
/// Delete an MBTiles file
Future<bool> deleteMbtilesFile(File file) async {
try {
if (await file.exists()) {
await file.delete();
debugPrint('Deleted MBTiles file: ${file.path}');
return true;
}
return false;
} catch (e) {
debugPrint('Error deleting MBTiles file: $e');
return false;
}
}
/// Check if data in MBTiles is gzip compressed
Future<bool> isGzipCompressed(File file) async {
try {
// Open MBTiles and check a sample tile
final mbtiles = MbTiles(mbtilesPath: file.path);
// Try to get metadata to check for compression hints
final metadata = mbtiles.getMetadata();
final format = metadata.format;
// For Geofabrik files, format is 'pbf' and data is gzipped
// We can infer this from common patterns, but ideally we'd check actual tile data
if (format == 'pbf') {
// Geofabrik MBTiles are typically gzipped
// Could also check tile data headers, but this is a reasonable heuristic
return true;
}
return false;
} catch (e) {
debugPrint('Error checking gzip compression: $e');
return false;
}
}
/// Determine the vector tile schema from metadata
String? getVectorSchema(MbtilesMetadata metadata) {
// Try to infer schema from metadata
final json = metadata.json;
if (json != null) {
if (json.contains('shortbread')) {
return 'shortbread';
} else if (json.contains('openmaptiles')) {
return 'openmaptiles';
}
}
// Check description
final description = metadata.description?.toLowerCase();
if (description != null) {
if (description.contains('shortbread')) {
return 'shortbread';
} else if (description.contains('openmaptiles')) {
return 'openmaptiles';
}
}
// Default to unknown
return null;
}
/// Helper: Get file name from path
String _getFileName(File file) {
return file.path.split(Platform.pathSeparator).last;
}
}

View File

@@ -1,284 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart';
import 'package:vector_map_tiles_mbtiles/vector_map_tiles_mbtiles.dart';
import 'package:mbtiles/mbtiles.dart';
import '../models/map_layer.dart';
class TileCacheService {
static const String _storeName = 'meshcore_sar_tiles';
// Global flag to ensure ObjectBox is only initialized once
static bool _objectBoxInitialized = false;
static final _initLock = <String, Future<void>>{};
late final FMTCStore _store;
bool _isInitialized = false;
bool _isDownloading = false;
Future<void> initialize() async {
if (_isInitialized) return;
// Ensure we only initialize ObjectBox once globally
if (!_objectBoxInitialized) {
// Use a lock to prevent concurrent initialization attempts
final initFuture = _initLock.putIfAbsent('objectbox', () async {
try {
await FMTCObjectBoxBackend().initialise();
_objectBoxInitialized = true;
} catch (e) {
// Already initialized or error - that's okay
_objectBoxInitialized = true;
}
});
await initFuture;
}
try {
_store = FMTCStore(_storeName);
await _store.manage.create();
_isInitialized = true;
} catch (e) {
// Store might already exist
_store = FMTCStore(_storeName);
_isInitialized = true;
}
}
FMTCTileProvider getTileProvider(MapLayer layer) {
if (!_isInitialized) {
throw StateError(
'TileCacheService not initialized. Call initialize() first.',
);
}
return FMTCTileProvider(
stores: {_storeName: BrowseStoreStrategy.readUpdateCreate},
loadingStrategy: BrowseLoadingStrategy.cacheFirst,
cachedValidDuration: const Duration(days: 30),
);
}
/// Get tile provider for WMS layers with caching support
/// WMS layers require special handling because they use WMSTileLayerOptions
FMTCTileProvider getTileProviderForWms(MapLayer layer) {
if (!_isInitialized) {
throw StateError(
'TileCacheService not initialized. Call initialize() first.',
);
}
if (!layer.isWms) {
throw ArgumentError('Layer must be a WMS layer');
}
// Return the same cached tile provider
// The WMS URL construction is handled by flutter_map's WMSTileLayerOptions
return FMTCTileProvider(
stores: {_storeName: BrowseStoreStrategy.readUpdateCreate},
loadingStrategy: BrowseLoadingStrategy.cacheFirst,
cachedValidDuration: const Duration(days: 30),
);
}
Future<void> downloadRegion({
required MapLayer layer,
required LatLngBounds bounds,
required int minZoom,
required int maxZoom,
Function(double progress)? onProgress,
}) async {
if (!_isInitialized) {
throw StateError(
'TileCacheService not initialized. Call initialize() first.',
);
}
if (_isDownloading) {
throw StateError('A download is already in progress. Cancel it first.');
}
_isDownloading = true;
try {
final region = RectangleRegion(bounds);
final downloadable = region.toDownloadable(
minZoom: minZoom,
maxZoom: maxZoom,
options: TileLayer(urlTemplate: layer.urlTemplate),
);
final download = _store.download.startForeground(region: downloadable);
await for (final progress in download.downloadProgress) {
if (onProgress != null && progress.maxTilesCount > 0) {
// Use attemptedTilesCount instead of successfulTilesCount
// attemptedTilesCount includes successful + buffered + skipped tiles
final percentage = progress.percentageProgress;
debugPrint(
'Download progress: ${progress.attemptedTilesCount}/${progress.maxTilesCount} = ${percentage.toStringAsFixed(1)}% (successful: ${progress.successfulTilesCount}, buffered: ${progress.bufferedTilesCount}, skipped: ${progress.skippedTilesCount})',
);
onProgress(percentage);
}
}
} finally {
_isDownloading = false;
}
}
Future<void> cancelDownload() async {
if (!_isInitialized) return;
await _store.download.cancel();
}
Future<void> clearCache() async {
if (!_isInitialized) return;
await _store.manage.delete();
await _store.manage.create();
}
Future<int> getCachedTileCount() async {
if (!_isInitialized) return 0;
final stats = await _store.stats.length;
return stats;
}
Future<double> getCacheSizeMB() async {
if (!_isInitialized) return 0.0;
final stats = await _store.stats.size;
return stats / (1024 * 1024);
}
Future<List<String>> getAvailableStores() async {
if (!_isInitialized) {
throw StateError(
'TileCacheService not initialized. Call initialize() first.',
);
}
final stores = await FMTCRoot.stats.storesAvailable;
return stores.map((store) => store.storeName).toList();
}
Future<Map<String, dynamic>> getStoreStats() async {
if (!_isInitialized) return {};
final length = await _store.stats.length;
final size = await _store.stats.all.then((a) => a.size);
return {
'tileCount': length,
'sizeMB': size / 1024,
'storeName': _storeName,
};
}
/// Get vector tile provider for MBTiles layers
MbTilesVectorTileProvider? getVectorTileProvider(MapLayer layer) {
if (!layer.isVector || layer.mbtilesFile == null) {
return null;
}
try {
final mbtiles = MbTiles(
mbtilesPath: layer.mbtilesFile!.path,
gzip: layer.isGzipped ?? false,
);
return MbTilesVectorTileProvider(
mbtiles: mbtiles,
);
} catch (e) {
debugPrint('Error creating vector tile provider: $e');
return null;
}
}
/// Export the current tile cache store to an archive file
///
/// [outputPath] - Full path where the archive should be saved (e.g., '/path/to/export.fmtc')
///
/// Returns the number of tiles exported
Future<int> exportStore(String outputPath) async {
if (!_isInitialized) {
throw StateError(
'TileCacheService not initialized. Call initialize() first.',
);
}
try {
final external = FMTCRoot.external(pathToArchive: outputPath);
final result = await external.export(storeNames: [_storeName]);
debugPrint('Export completed: $result tiles exported to $outputPath');
return result;
} catch (e) {
debugPrint('Error exporting store: $e');
rethrow;
}
}
/// Import a tile cache store from an archive file
///
/// [filePath] - Path to the .fmtc archive file to import
/// [storeNames] - Optional list of store names to import (null = import all)
/// [strategy] - Conflict resolution strategy (default: merge)
///
/// Returns a map with import statistics (e.g., tile count, stores imported)
Future<Map<String, dynamic>> importStore(
String filePath, {
List<String>? storeNames,
ImportConflictStrategy strategy = ImportConflictStrategy.merge,
}) async {
if (!_isInitialized) {
throw StateError(
'TileCacheService not initialized. Call initialize() first.',
);
}
try {
final external = FMTCRoot.external(pathToArchive: filePath);
final result = external.import(storeNames: storeNames, strategy: strategy);
// Wait for the import to complete and get tile count
final tileCount = await result.complete;
// Wait for store states
final storesToStates = await result.storesToStates;
debugPrint('Import completed: $tileCount tiles imported, ${storesToStates.length} stores');
// Count successful stores (those that weren't skipped)
final successfulCount = storesToStates.values.where((state) => state.name != null).length;
return {
'successfulStores': successfulCount,
'tileCount': tileCount,
'storesToStates': storesToStates,
};
} catch (e) {
debugPrint('Error importing store: $e');
rethrow;
}
}
/// List all stores available in an archive file without importing
///
/// [filePath] - Path to the .fmtc archive file to inspect
///
/// Returns a list of store names contained in the archive
Future<List<String>> listArchiveStores(String filePath) async {
try {
final external = FMTCRoot.external(pathToArchive: filePath);
final stores = await external.listStores;
debugPrint('Archive contains ${stores.length} stores: $stores');
return stores;
} catch (e) {
debugPrint('Error listing archive stores: $e');
rethrow;
}
}
void dispose() {
_isInitialized = false;
}
}

View File

@@ -0,0 +1,69 @@
import 'dart:typed_data';
class FastGpsPacket {
static const int magic = 0x47; // 'G'
static const int _payloadLength = 19;
// Store coordinates in microdegrees. This preserves sub-meter precision,
// which comfortably satisfies the meter-accuracy requirement.
static const double coordinateScale = 1e6;
final String senderKey6;
final double latitude;
final double longitude;
final int timestampSeconds;
const FastGpsPacket({
required this.senderKey6,
required this.latitude,
required this.longitude,
required this.timestampSeconds,
});
static bool isFastGpsBinary(Uint8List payload) =>
payload.length == _payloadLength && payload[0] == magic;
static FastGpsPacket? tryParseBinary(Uint8List payload) {
if (!isFastGpsBinary(payload)) return null;
final key6 = payload
.sublist(1, 7)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
final data = ByteData.sublistView(payload);
final latitude = data.getInt32(7, Endian.little) / coordinateScale;
final longitude = data.getInt32(11, Endian.little) / coordinateScale;
final timestampSeconds = data.getUint32(15, Endian.little);
if (!_isValidCoordinate(latitude, longitude)) {
return null;
}
return FastGpsPacket(
senderKey6: key6,
latitude: latitude,
longitude: longitude,
timestampSeconds: timestampSeconds,
);
}
Uint8List encodeBinary() {
final out = Uint8List(_payloadLength);
final data = ByteData.sublistView(out);
out[0] = magic;
for (var i = 0; i < 6; i++) {
out[1 + i] = int.parse(senderKey6.substring(i * 2, i * 2 + 2), radix: 16);
}
data.setInt32(7, (latitude * coordinateScale).round(), Endian.little);
data.setInt32(11, (longitude * coordinateScale).round(), Endian.little);
data.setUint32(15, timestampSeconds, Endian.little);
return out;
}
static bool _isValidCoordinate(double latitude, double longitude) {
if (!latitude.isFinite || !longitude.isFinite) return false;
return latitude >= -90.0 &&
latitude <= 90.0 &&
longitude >= -180.0 &&
longitude <= 180.0;
}
}

View File

@@ -3,6 +3,18 @@ import 'package:flutter/material.dart';
import '../../models/contact.dart'; import '../../models/contact.dart';
import '../../services/route_hash_preferences.dart'; import '../../services/route_hash_preferences.dart';
class ContactRouteDialogResult {
final ParsedContactRoute? route;
final bool shouldClear;
const ContactRouteDialogResult._({this.route, required this.shouldClear});
const ContactRouteDialogResult.set(ParsedContactRoute route)
: this._(route: route, shouldClear: false);
const ContactRouteDialogResult.clear() : this._(shouldClear: true);
}
class ContactRouteDialog extends StatefulWidget { class ContactRouteDialog extends StatefulWidget {
final Contact contact; final Contact contact;
final List<Contact> availableContacts; final List<Contact> availableContacts;
@@ -13,12 +25,12 @@ class ContactRouteDialog extends StatefulWidget {
required this.availableContacts, required this.availableContacts,
}); });
static Future<ParsedContactRoute?> show( static Future<ContactRouteDialogResult?> show(
BuildContext context, { BuildContext context, {
required Contact contact, required Contact contact,
required List<Contact> availableContacts, required List<Contact> availableContacts,
}) { }) {
return showModalBottomSheet<ParsedContactRoute>( return showModalBottomSheet<ContactRouteDialogResult>(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
showDragHandle: true, showDragHandle: true,
@@ -212,11 +224,20 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'), child: const Text('Cancel'),
), ),
if (widget.contact.routeHasPath)
TextButton(
onPressed: () => Navigator.of(
context,
).pop(const ContactRouteDialogResult.clear()),
child: const Text('Clear Route'),
),
const Spacer(), const Spacer(),
FilledButton( FilledButton(
onPressed: _parsedRoute == null onPressed: _parsedRoute == null
? null ? null
: () => Navigator.of(context).pop(_parsedRoute), : () => Navigator.of(
context,
).pop(ContactRouteDialogResult.set(_parsedRoute!)),
child: const Text('Set Route'), child: const Text('Set Route'),
), ),
], ],

View File

@@ -8,12 +8,11 @@ import '../../models/room_login_state.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';
import '../../providers/app_provider.dart';
import 'contact_route_dialog.dart'; import 'contact_route_dialog.dart';
import 'room_login_sheet.dart'; import 'room_login_sheet.dart';
import '../common/contact_avatar.dart';
import '../../utils/location_formats.dart'; import '../../utils/location_formats.dart';
import '../../utils/toast_logger.dart'; import '../../utils/toast_logger.dart';
import '../../utils/battery_display_helper.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
class ContactTile extends StatelessWidget { class ContactTile extends StatelessWidget {
@@ -45,12 +44,6 @@ class ContactTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final appProvider = context.watch<AppProvider>();
final isSimpleMode = appProvider.isSimpleMode;
final hasTelemetry =
contact.telemetry != null && contact.telemetry!.isRecent;
final battery = contact.displayBattery;
final location = contact.displayLocation; final location = contact.displayLocation;
// Calculate distance if both positions are available // Calculate distance if both positions are available
String? distanceText; String? distanceText;
@@ -101,13 +94,11 @@ class ContactTile extends StatelessWidget {
) )
: null; : null;
void handleTap() { void handleTap() {
if (isSimpleMode && contact.type == ContactType.chat) { if (contact.type == ContactType.chat) {
_showSetRouteDialog(context, contact); _showSetRouteDialog(context, contact);
} else if (isSimpleMode && contact.type == ContactType.repeater) { } else if (contact.type == ContactType.repeater) {
_jumpToMapForRepeater(context, contact); _jumpToMapForRepeater(context, contact);
} else if (isSimpleMode && } else if (contact.type == ContactType.room && !contact.isPublicChannel) {
contact.type == ContactType.room &&
!contact.isPublicChannel) {
_showRoomLoginDialog(context, contact); _showRoomLoginDialog(context, contact);
} else { } else {
_showContactDetails(context, contact); _showContactDetails(context, contact);
@@ -147,204 +138,40 @@ class ContactTile extends StatelessWidget {
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
letterSpacing: -0.3, letterSpacing: -0.3,
); );
final subtitleWidget = isSimpleMode final timeAgoText = _getLocalizedTimeSinceLastSeen(context);
? Column( final timeAgoStyle = Theme.of(context).textTheme.labelSmall?.copyWith(
crossAxisAlignment: CrossAxisAlignment.start, color: contact.isRecentlySeen
children: [ ? colorScheme.primary
const SizedBox(height: 2), : colorScheme.onSurfaceVariant,
Row( fontWeight: FontWeight.w600,
children: [ );
Icon( final subtitleWidget = Column(
Icons.access_time, crossAxisAlignment: CrossAxisAlignment.start,
size: 12, children: [
color: contact.isRecentlySeen ? Colors.green : Colors.grey, if (location != null) ...[
), const SizedBox(height: 2),
const SizedBox(width: 4), _buildLocationLine(
Text( context,
'${AppLocalizations.of(context)!.lastSeen}: ${_getLocalizedTimeSinceLastSeen(context)}', latitude: location.latitude,
style: Theme.of(context).textTheme.labelSmall, longitude: location.longitude,
), distanceText: distanceText,
], ),
), if (contact.type != ContactType.channel) ...[
if (location != null) ...[ const SizedBox(height: 6),
const SizedBox(height: 4), Row(children: [_buildRoutePill(context, contact)]),
Row( ],
children: [ ] else
const Icon(Icons.location_on, size: 12, color: Colors.blue), Padding(
const SizedBox(width: 4), padding: const EdgeInsets.only(top: 4),
Expanded( child: Text(
child: Text( AppLocalizations.of(context)!.noGpsData,
'GPS: ${location.latitude.toStringAsFixed(5)}, ${location.longitude.toStringAsFixed(5)}', style: Theme.of(
style: Theme.of(context).textTheme.labelSmall, context,
overflow: TextOverflow.ellipsis, ).textTheme.labelSmall?.copyWith(color: Colors.grey),
), ),
), ),
], ],
), );
if (contact.type != ContactType.channel ||
distanceText != null) ...[
const SizedBox(height: 6),
Wrap(
spacing: 8,
runSpacing: 6,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
if (contact.type != ContactType.channel)
_buildRoutePill(context, contact),
if (distanceText != null)
_buildDistancePill(context, distanceText),
],
),
],
] else
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
AppLocalizations.of(context)!.noGpsData,
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: Colors.grey),
),
),
],
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 2),
if (roomLoginState != null && roomLoginState.isLoggedIn) ...[
Row(
children: [
if (roomLoginState.isAdmin)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.red.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.red, width: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.admin_panel_settings,
size: 10,
color: Colors.red,
),
const SizedBox(width: 2),
Text(
AppLocalizations.of(context)!.admin,
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
color: Colors.red,
fontWeight: FontWeight.bold,
fontSize: 10,
),
),
],
),
),
if (roomLoginState.isAdmin) const SizedBox(width: 4),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.green, width: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.check_circle,
size: 10,
color: Colors.green,
),
const SizedBox(width: 2),
Text(
AppLocalizations.of(context)!.loggedIn,
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
color: Colors.green,
fontWeight: FontWeight.bold,
fontSize: 10,
),
),
],
),
),
],
),
const SizedBox(height: 4),
],
Row(
children: [
Icon(
Icons.access_time,
size: 12,
color: contact.isRecentlySeen ? Colors.green : Colors.grey,
),
const SizedBox(width: 4),
Text(
_getLocalizedTimeSinceLastSeen(context),
style: Theme.of(context).textTheme.labelSmall,
),
if (location != null) ...[
const SizedBox(width: 8),
const Text('', style: TextStyle(color: Colors.grey)),
const SizedBox(width: 8),
if (hasTelemetry)
const Icon(Icons.sensors, size: 12, color: Colors.green)
else
const Icon(
Icons.sensors_off,
size: 12,
color: Colors.grey,
),
const SizedBox(width: 4),
Expanded(
child: Text(
'GPS: ${location.latitude.toStringAsFixed(5)}, ${location.longitude.toStringAsFixed(5)}',
style: Theme.of(context).textTheme.labelSmall,
overflow: TextOverflow.ellipsis,
),
),
] else ...[
const SizedBox(width: 8),
const Text('', style: TextStyle(color: Colors.grey)),
const SizedBox(width: 8),
const Icon(Icons.sensors_off, size: 12, color: Colors.grey),
const SizedBox(width: 4),
Text(
AppLocalizations.of(context)!.noGpsData,
style: Theme.of(context).textTheme.labelSmall,
),
],
],
),
if (contact.type != ContactType.channel ||
distanceText != null) ...[
const SizedBox(height: 6),
Wrap(
spacing: 8,
runSpacing: 6,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
if (contact.type != ContactType.channel)
_buildRoutePill(context, contact),
if (distanceText != null)
_buildDistancePill(context, distanceText),
],
),
],
],
);
return Container( return Container(
margin: const EdgeInsets.only(bottom: 8), margin: const EdgeInsets.only(bottom: 8),
@@ -378,51 +205,12 @@ class ContactTile extends StatelessWidget {
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Stack( Stack(
clipBehavior: Clip.none, clipBehavior: Clip.none,
children: [ children: [
Container( ContactAvatar(contact: contact, radius: 24),
width: 48,
height: 48,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
_getTypeColor(contact.type, context),
_getTypeColor(
contact.type,
context,
).withValues(alpha: 0.72),
],
),
boxShadow: [
BoxShadow(
color: _getTypeColor(
contact.type,
context,
).withValues(alpha: 0.22),
blurRadius: 8,
offset: const Offset(0, 3),
),
],
),
child: Center(
child: contact.roleEmoji != null
? Text(
contact.roleEmoji!,
style: const TextStyle(fontSize: 22),
)
: Icon(
_getTypeIcon(contact.type),
color: Colors.white,
size: 20,
),
),
),
if (contact.isNew) if (contact.isNew)
Positioned( Positioned(
top: -2, top: -2,
@@ -480,30 +268,7 @@ class ContactTile extends StatelessWidget {
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Container( Text(timeAgoText, style: timeAgoStyle),
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 3,
),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest
.withValues(alpha: 0.75),
borderRadius: BorderRadius.circular(999),
),
child: Text(
contact.type.displayName,
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
letterSpacing: 0.1,
),
),
),
if (!isSimpleMode && battery != null) ...[
const SizedBox(width: 6),
_buildBatteryBadge(context, battery),
],
if (isPingInProgress) ...[ if (isPingInProgress) ...[
const SizedBox(width: 6), const SizedBox(width: 6),
SizedBox( SizedBox(
@@ -656,18 +421,7 @@ class ContactTile extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row( child: Row(
children: [ children: [
CircleAvatar( ContactAvatar(contact: contact, radius: 20),
backgroundColor: _getTypeColor(contact.type, context),
child: contact.roleEmoji != null
? Text(
contact.roleEmoji!,
style: const TextStyle(fontSize: 24),
)
: Icon(
_getTypeIcon(contact.type),
color: Colors.white,
),
),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Text( child: Text(
@@ -1116,17 +870,42 @@ class ContactTile extends StatelessWidget {
.where((candidate) => candidate.publicKeyHex != contact.publicKeyHex) .where((candidate) => candidate.publicKeyHex != contact.publicKeyHex)
.toList(); .toList();
final parsedRoute = await ContactRouteDialog.show( final routeResult = await ContactRouteDialog.show(
context, context,
contact: contact, contact: contact,
availableContacts: availableContacts, availableContacts: availableContacts,
); );
if (parsedRoute == null || !context.mounted) { if (routeResult == null || !context.mounted) {
return; return;
} }
final previousSignedPathLen = contact.routeSignedPathLen; final previousSignedPathLen = contact.routeSignedPathLen;
final previousPathBytes = Uint8List.fromList(contact.outPath); final previousPathBytes = Uint8List.fromList(contact.outPath);
if (routeResult.shouldClear) {
contactsProvider.resetContactRouteLocal(contact.publicKey);
await connectionProvider.resetPath(contact.publicKey);
final error = connectionProvider.error;
if (error != null) {
contactsProvider.setContactRouteLocal(
contact.publicKey,
signedEncodedPathLen: previousSignedPathLen,
paddedPathBytes: previousPathBytes,
);
if (context.mounted) {
ToastLogger.error(context, 'Failed to clear route: $error');
}
return;
}
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Route cleared')));
}
return;
}
final parsedRoute = routeResult.route!;
contactsProvider.setContactRouteLocal( contactsProvider.setContactRouteLocal(
contact.publicKey, contact.publicKey,
signedEncodedPathLen: parsedRoute.signedEncodedPathLen, signedEncodedPathLen: parsedRoute.signedEncodedPathLen,
@@ -1217,6 +996,73 @@ class ContactTile extends StatelessWidget {
); );
} }
Widget _buildLocationMeta(
BuildContext context,
double latitude,
double longitude, {
bool telemetryActive = true,
}) {
final colorScheme = Theme.of(context).colorScheme;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 18,
height: 18,
decoration: BoxDecoration(
color: telemetryActive
? colorScheme.primary.withValues(alpha: 0.12)
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(999),
),
child: Icon(
telemetryActive
? Icons.navigation_rounded
: Icons.location_searching,
size: 11,
color: telemetryActive
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
),
const SizedBox(width: 6),
Text(
'${latitude.toStringAsFixed(5)}, ${longitude.toStringAsFixed(5)}',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
fontFamily: 'monospace',
color: colorScheme.onSurfaceVariant,
letterSpacing: 0.1,
),
overflow: TextOverflow.ellipsis,
),
],
);
}
Widget _buildLocationLine(
BuildContext context, {
required double latitude,
required double longitude,
String? distanceText,
bool telemetryActive = true,
}) {
return Wrap(
spacing: 8,
runSpacing: 6,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
_buildLocationMeta(
context,
latitude,
longitude,
telemetryActive: telemetryActive,
),
if (distanceText != null) _buildDistancePill(context, distanceText),
],
);
}
/// Convert to Degrees Minutes Seconds (DMS) format /// Convert to Degrees Minutes Seconds (DMS) format
String _convertToDMS(double lat, double lon) { String _convertToDMS(double lat, double lon) {
String latDir = lat >= 0 ? 'N' : 'S'; String latDir = lat >= 0 ? 'N' : 'S';
@@ -1273,19 +1119,6 @@ class ContactTile extends StatelessWidget {
return '$zone$letter (approximate)'; return '$zone$letter (approximate)';
} }
IconData _getTypeIcon(ContactType type) {
switch (type) {
case ContactType.chat:
return Icons.person;
case ContactType.repeater:
return Icons.router;
case ContactType.room:
return Icons.tag;
default:
return Icons.help;
}
}
Color _getTypeColor(ContactType type, BuildContext context) { Color _getTypeColor(ContactType type, BuildContext context) {
switch (type) { switch (type) {
case ContactType.chat: case ContactType.chat:
@@ -1411,6 +1244,7 @@ class ContactTile extends StatelessWidget {
Widget _buildRoutePill(BuildContext context, Contact contact) { Widget _buildRoutePill(BuildContext context, Contact contact) {
final hasPath = contact.routeHasPath; final hasPath = contact.routeHasPath;
final isDirect = !hasPath || contact.routeHopCount <= 0;
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
final textColor = Theme.of( final textColor = Theme.of(
context, context,
@@ -1418,9 +1252,7 @@ class ContactTile extends StatelessWidget {
final iconColor = Theme.of( final iconColor = Theme.of(
context, context,
).textTheme.labelSmall?.color?.withValues(alpha: 0.7); ).textTheme.labelSmall?.color?.withValues(alpha: 0.7);
final label = !hasPath final label = isDirect
? AppLocalizations.of(context)!.flood
: contact.routeHopCount <= 0
? AppLocalizations.of(context)!.direct ? AppLocalizations.of(context)!.direct
: contact.routeCanonicalText; : contact.routeCanonicalText;
@@ -1434,7 +1266,7 @@ class ContactTile extends StatelessWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon( Icon(
hasPath ? Icons.alt_route : Icons.waves, isDirect ? Icons.north_east_rounded : Icons.alt_route,
size: 11, size: 11,
color: iconColor, color: iconColor,
), ),
@@ -1447,9 +1279,7 @@ class ContactTile extends StatelessWidget {
style: Theme.of(context).textTheme.labelSmall?.copyWith( style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: textColor, color: textColor,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
fontFamily: hasPath && contact.routeHopCount > 0 fontFamily: !isDirect ? 'monospace' : null,
? 'monospace'
: null,
), ),
), ),
), ),
@@ -1479,7 +1309,7 @@ class ContactTile extends StatelessWidget {
), ),
const SizedBox(width: 5), const SizedBox(width: 5),
Text( Text(
'${AppLocalizations.of(context)!.distance}: $distanceText', distanceText,
style: Theme.of(context).textTheme.labelSmall?.copyWith( style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of( color: Theme.of(
context, context,
@@ -1491,33 +1321,4 @@ class ContactTile extends StatelessWidget {
), ),
); );
} }
Widget _buildBatteryBadge(BuildContext context, double battery) {
final color = BatteryDisplayHelper.getBatteryColor(battery);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
BatteryDisplayHelper.getBatteryIcon(battery),
size: 12,
color: color,
),
const SizedBox(width: 4),
Text(
'${battery.round()}%',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: color,
fontWeight: FontWeight.w700,
),
),
],
),
);
}
} }

View File

@@ -1,108 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
/// Overlay widget that displays controls for download area selection.
/// The actual polygon should be rendered inside FlutterMap's children.
class DownloadAreaOverlay extends StatelessWidget {
final LatLngBounds bounds;
final VoidCallback onConfirm;
final VoidCallback onCancel;
const DownloadAreaOverlay({
super.key,
required this.bounds,
required this.onConfirm,
required this.onCancel,
});
@override
Widget build(BuildContext context) {
return Stack(
children: [
// Control buttons at the top
Positioned(
top: 16,
left: 16,
right: 16,
child: Card(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Download Area Selection',
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'The blue rectangle shows the area to be downloaded. '
'To change the area, tap Cancel and select download again.',
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: onCancel,
icon: const Icon(Icons.close),
label: const Text('Cancel'),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton.icon(
onPressed: onConfirm,
icon: const Icon(Icons.check),
label: const Text('Confirm'),
),
),
],
),
],
),
),
),
),
// Area info at the bottom
Positioned(
bottom: 16,
left: 16,
right: 16,
child: Card(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Area Bounds',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 4),
Text(
'N: ${bounds.north.toStringAsFixed(4)}° '
'S: ${bounds.south.toStringAsFixed(4)}°',
style: Theme.of(context).textTheme.bodySmall,
),
Text(
'E: ${bounds.east.toStringAsFixed(4)}° '
'W: ${bounds.west.toStringAsFixed(4)}°',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
),
),
],
);
}
}

View File

@@ -8,14 +8,8 @@ import '../../l10n/app_localizations.dart';
class DrawingLayer extends StatelessWidget { class DrawingLayer extends StatelessWidget {
final List<MapDrawing> drawings; final List<MapDrawing> drawings;
final MapDrawing? previewDrawing; final MapDrawing? previewDrawing;
final bool isSimpleMode;
const DrawingLayer({ const DrawingLayer({super.key, required this.drawings, this.previewDrawing});
super.key,
required this.drawings,
this.previewDrawing,
this.isSimpleMode = false,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -48,8 +42,7 @@ class DrawingLayer extends StatelessWidget {
strokeWidth = 4.0; strokeWidth = 4.0;
} else if (drawing.isReceived) { } else if (drawing.isReceived) {
// Received drawing from another node // Received drawing from another node
// In simple mode: solid (opacity 1.0), in normal mode: translucent (0.7) opacity = 1.0;
opacity = isSimpleMode ? 1.0 : 0.7;
strokeWidth = 3.0; strokeWidth = 3.0;
} else { } else {
// Local drawing (solid line, normal thickness) // Local drawing (solid line, normal thickness)
@@ -87,7 +80,6 @@ class DrawingMarkersLayer extends StatelessWidget {
final Function(String drawingId)? onDeleteDrawing; final Function(String drawingId)? onDeleteDrawing;
final Function(MapDrawing drawing)? onTapDrawing; final Function(MapDrawing drawing)? onTapDrawing;
final bool showDeleteButtons; final bool showDeleteButtons;
final bool isSimpleMode;
const DrawingMarkersLayer({ const DrawingMarkersLayer({
super.key, super.key,
@@ -95,7 +87,6 @@ class DrawingMarkersLayer extends StatelessWidget {
this.onDeleteDrawing, this.onDeleteDrawing,
this.onTapDrawing, this.onTapDrawing,
this.showDeleteButtons = false, this.showDeleteButtons = false,
this.isSimpleMode = false,
}); });
@override @override
@@ -132,73 +123,7 @@ class DrawingMarkersLayer extends StatelessWidget {
), ),
], ],
), ),
child: const Icon( child: const Icon(Icons.close, color: Colors.white, size: 20),
Icons.close,
color: Colors.white,
size: 20,
),
),
),
),
);
} else if (drawing.isReceived && drawing.senderName != null && !isSimpleMode) {
// Show sender badge for received drawings (when not in drawing mode and not in simple mode)
// Make it tappable if message ID is available
markers.add(
Marker(
point: centerPoint,
width: 120,
height: 30,
child: GestureDetector(
onTap: drawing.messageId != null && onTapDrawing != null
? () => onTapDrawing!(drawing)
: null,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: drawing.color.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white, width: 1.5),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.person,
color: Colors.white,
size: 14,
),
const SizedBox(width: 4),
Flexible(
child: Text(
drawing.senderName!,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
// Add indicator that this is tappable
if (drawing.messageId != null && onTapDrawing != null) ...[
const SizedBox(width: 4),
const Icon(
Icons.arrow_forward_ios,
color: Colors.white,
size: 10,
),
],
],
),
), ),
), ),
), ),
@@ -236,9 +161,7 @@ class DrawingMarkersLayer extends StatelessWidget {
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: Text(AppLocalizations.of(context)!.deleteDrawing), title: Text(AppLocalizations.of(context)!.deleteDrawing),
content: Text( content: Text('Delete this ${drawing.type.name}?'),
'Delete this ${drawing.type.name}?',
),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
@@ -249,9 +172,7 @@ class DrawingMarkersLayer extends StatelessWidget {
Navigator.pop(context); Navigator.pop(context);
onDeleteDrawing?.call(drawing.id); onDeleteDrawing?.call(drawing.id);
}, },
style: TextButton.styleFrom( style: TextButton.styleFrom(foregroundColor: Colors.red),
foregroundColor: Colors.red,
),
child: Text(AppLocalizations.of(context)!.delete), child: Text(AppLocalizations.of(context)!.delete),
), ),
], ],

View File

@@ -2,8 +2,6 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../providers/map_provider.dart'; import '../../providers/map_provider.dart';
import '../../providers/contacts_provider.dart'; import '../../providers/contacts_provider.dart';
import '../../providers/app_provider.dart';
import '../../services/gpx_service.dart';
import '../../services/trail_color_service.dart'; import '../../services/trail_color_service.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
@@ -13,10 +11,11 @@ class TrailControls extends StatelessWidget {
void _showTrailMenu(BuildContext context) { void _showTrailMenu(BuildContext context) {
final mapProvider = Provider.of<MapProvider>(context, listen: false); final mapProvider = Provider.of<MapProvider>(context, listen: false);
final contactsProvider = Provider.of<ContactsProvider>(context, listen: false); final contactsProvider = Provider.of<ContactsProvider>(
final appProvider = Provider.of<AppProvider>(context, listen: false); context,
listen: false,
);
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final isSimpleMode = appProvider.isSimpleMode;
// Get contacts with trails (advertHistory >= 2 points) // Get contacts with trails (advertHistory >= 2 points)
final contactsWithTrails = contactsProvider.contactsWithLocation final contactsWithTrails = contactsProvider.contactsWithLocation
@@ -34,258 +33,232 @@ class TrailControls extends StatelessWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Row( Row(
children: [
const Icon(Icons.timeline, size: 24),
const SizedBox(width: 12),
Text(
l10n.locationTrail,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 20),
// Trail visibility toggle
SwitchListTile(
secondary: const Icon(Icons.visibility),
title: Text(l10n.showTrailOnMap),
subtitle: Text(
mapProvider.isTrailVisible
? l10n.trailVisible
: l10n.trailHiddenRecording,
),
value: mapProvider.isTrailVisible,
onChanged: (value) {
mapProvider.toggleTrailVisibility();
setModalState(() {}); // Update modal UI
},
),
const Divider(),
const SizedBox(height: 8),
// Trail stats
if (mapProvider.currentTrail != null && mapProvider.currentTrail!.points.isNotEmpty)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue.withValues(alpha: 0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildStatRow( const Icon(Icons.timeline, size: 24),
icon: Icons.straighten, const SizedBox(width: 12),
label: l10n.distance, Text(
value: _formatDistance(mapProvider.totalTrailDistance), l10n.locationTrail,
), style: const TextStyle(
const SizedBox(height: 8), fontSize: 20,
_buildStatRow( fontWeight: FontWeight.bold,
icon: Icons.access_time, ),
label: l10n.duration,
value: _formatDuration(mapProvider.trailDuration),
),
const SizedBox(height: 8),
_buildStatRow(
icon: Icons.place,
label: l10n.points,
value: '${mapProvider.currentTrail!.points.length}',
), ),
], ],
), ),
), const SizedBox(height: 20),
const SizedBox(height: 16), // Trail visibility toggle
SwitchListTile(
// GPX Export/Import buttons (hidden in simple mode) secondary: const Icon(Icons.visibility),
if (!isSimpleMode) ...[ title: Text(l10n.showTrailOnMap),
if (mapProvider.currentTrail != null && mapProvider.currentTrail!.points.isNotEmpty) subtitle: Text(
ElevatedButton.icon( mapProvider.isTrailVisible
onPressed: () async { ? l10n.trailVisible
final success = await GpxService.exportTrailToFile(mapProvider.currentTrail!); : l10n.trailHiddenRecording,
if (context.mounted) { ),
ScaffoldMessenger.of(context).showSnackBar( value: mapProvider.isTrailVisible,
SnackBar( onChanged: (value) {
content: Text(success mapProvider.toggleTrailVisibility();
? l10n.trailExportedSuccessfully setModalState(() {}); // Update modal UI
: l10n.failedToExportTrail),
backgroundColor: success ? Colors.green : Colors.red,
),
);
}
}, },
icon: const Icon(Icons.upload),
label: Text(l10n.exportTrailToGpx),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.all(16),
),
), ),
const Divider(),
const SizedBox(height: 8),
const SizedBox(height: 8), // Trail stats
if (mapProvider.currentTrail != null &&
ElevatedButton.icon( mapProvider.currentTrail!.points.isNotEmpty)
onPressed: () async { Container(
try { padding: const EdgeInsets.all(12),
final trail = await GpxService.importTrailFromFile(); decoration: BoxDecoration(
if (trail != null && context.mounted) { color: Colors.blue.withValues(alpha: 0.1),
_showImportDialog(context, mapProvider, trail, l10n); borderRadius: BorderRadius.circular(8),
} border: Border.all(
} catch (e) { color: Colors.blue.withValues(alpha: 0.3),
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.failedToImportTrail(e.toString())),
backgroundColor: Colors.red,
),
);
}
}
},
icon: const Icon(Icons.download),
label: Text(l10n.importTrailFromGpx),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.all(16),
),
),
const SizedBox(height: 16),
],
// Clear trail button
if (mapProvider.currentTrail != null && mapProvider.currentTrail!.points.isNotEmpty)
ElevatedButton.icon(
onPressed: () {
_showClearConfirmation(context, mapProvider, l10n);
},
icon: const Icon(Icons.delete_outline),
label: Text(l10n.clearTrail),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
padding: const EdgeInsets.all(16),
),
),
// No trail message
if (mapProvider.currentTrail == null || mapProvider.currentTrail!.points.isEmpty)
Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: Column(
children: [
const Icon(Icons.timeline, size: 48, color: Colors.grey),
const SizedBox(height: 8),
Text(
l10n.noTrailRecorded,
style: const TextStyle(
color: Colors.grey,
fontSize: 16,
),
), ),
const SizedBox(height: 8), ),
Text( child: Column(
l10n.startTrackingToRecord, crossAxisAlignment: CrossAxisAlignment.start,
style: const TextStyle(
color: Colors.grey,
fontSize: 12,
),
textAlign: TextAlign.center,
),
],
),
),
),
const SizedBox(height: 8),
const Divider(),
const SizedBox(height: 8),
// Contact Trails Section
Row(
children: [
const Icon(Icons.people, size: 20),
const SizedBox(width: 8),
Text(
l10n.contactTrails,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 12),
// Show All Contact Trails toggle
SwitchListTile(
secondary: const Icon(Icons.route),
title: Text(l10n.showAllContactTrails),
subtitle: Text(contactsWithTrails.isEmpty
? l10n.noContactsWithLocationHistory
: mapProvider.showAllContactTrails
? l10n.showingTrailsForContacts(contactsWithTrails.length)
: l10n.individualContactTrails),
value: mapProvider.showAllContactTrails,
onChanged: contactsWithTrails.isNotEmpty
? (value) {
mapProvider.toggleAllContactTrails();
setModalState(() {}); // Update modal UI
}
: null, // Disable if no contacts with trails
),
// Individual contact trails (when "show all" is OFF)
if (!mapProvider.showAllContactTrails && contactsWithTrails.isNotEmpty)
ExpansionTile(
title: Text(l10n.individualContactTrails),
initiallyExpanded: false,
children: contactsWithTrails.map((contact) {
final trailColor = TrailColorService.getTrailColor(contact);
final isVisible = mapProvider.isContactPathVisible(contact.publicKeyHex);
return SwitchListTile(
// Color indicator with emoji
secondary: Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
if (contact.roleEmoji != null) _buildStatRow(
Text(contact.roleEmoji!, style: const TextStyle(fontSize: 18)), icon: Icons.straighten,
const SizedBox(width: 4), label: l10n.distance,
Container( value: _formatDistance(
width: 16, mapProvider.totalTrailDistance,
height: 16,
decoration: BoxDecoration(
color: trailColor,
border: Border.all(color: Colors.white, width: 2),
borderRadius: BorderRadius.circular(3),
), ),
), ),
const SizedBox(height: 8),
_buildStatRow(
icon: Icons.access_time,
label: l10n.duration,
value: _formatDuration(mapProvider.trailDuration),
),
const SizedBox(height: 8),
_buildStatRow(
icon: Icons.place,
label: l10n.points,
value: '${mapProvider.currentTrail!.points.length}',
),
], ],
), ),
title: Text(contact.displayName), ),
subtitle: Text('${contact.advertHistory.length} points'),
value: isVisible, const SizedBox(height: 16),
onChanged: (value) {
mapProvider.toggleContactPath(contact.publicKeyHex); // Clear trail button
setModalState(() {}); // Update modal UI if (mapProvider.currentTrail != null &&
mapProvider.currentTrail!.points.isNotEmpty)
ElevatedButton.icon(
onPressed: () {
_showClearConfirmation(context, mapProvider, l10n);
}, },
); icon: const Icon(Icons.delete_outline),
}).toList(), label: Text(l10n.clearTrail),
), style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
padding: const EdgeInsets.all(16),
),
),
const SizedBox(height: 8), // No trail message
if (mapProvider.currentTrail == null ||
mapProvider.currentTrail!.points.isEmpty)
Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: Column(
children: [
const Icon(
Icons.timeline,
size: 48,
color: Colors.grey,
),
const SizedBox(height: 8),
Text(
l10n.noTrailRecorded,
style: const TextStyle(
color: Colors.grey,
fontSize: 16,
),
),
const SizedBox(height: 8),
Text(
l10n.startTrackingToRecord,
style: const TextStyle(
color: Colors.grey,
fontSize: 12,
),
textAlign: TextAlign.center,
),
],
),
),
),
// Close button const SizedBox(height: 8),
TextButton( const Divider(),
onPressed: () => Navigator.pop(context), const SizedBox(height: 8),
child: Text(l10n.close),
), // Contact Trails Section
], Row(
children: [
const Icon(Icons.people, size: 20),
const SizedBox(width: 8),
Text(
l10n.contactTrails,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 12),
// Show All Contact Trails toggle
SwitchListTile(
secondary: const Icon(Icons.route),
title: Text(l10n.showAllContactTrails),
subtitle: Text(
contactsWithTrails.isEmpty
? l10n.noContactsWithLocationHistory
: mapProvider.showAllContactTrails
? l10n.showingTrailsForContacts(
contactsWithTrails.length,
)
: l10n.individualContactTrails,
),
value: mapProvider.showAllContactTrails,
onChanged: contactsWithTrails.isNotEmpty
? (value) {
mapProvider.toggleAllContactTrails();
setModalState(() {}); // Update modal UI
}
: null, // Disable if no contacts with trails
),
// Individual contact trails (when "show all" is OFF)
if (!mapProvider.showAllContactTrails &&
contactsWithTrails.isNotEmpty)
ExpansionTile(
title: Text(l10n.individualContactTrails),
initiallyExpanded: false,
children: contactsWithTrails.map((contact) {
final trailColor = TrailColorService.getTrailColor(
contact,
);
final isVisible = mapProvider.isContactPathVisible(
contact.publicKeyHex,
);
return SwitchListTile(
// Color indicator with emoji
secondary: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (contact.roleEmoji != null)
Text(
contact.roleEmoji!,
style: const TextStyle(fontSize: 18),
),
const SizedBox(width: 4),
Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: trailColor,
border: Border.all(
color: Colors.white,
width: 2,
),
borderRadius: BorderRadius.circular(3),
),
),
],
),
title: Text(contact.displayName),
subtitle: Text(
'${contact.advertHistory.length} points',
),
value: isVisible,
onChanged: (value) {
mapProvider.toggleContactPath(contact.publicKeyHex);
setModalState(() {}); // Update modal UI
},
);
}).toList(),
),
const SizedBox(height: 8),
// Close button
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l10n.close),
),
],
), ),
), ),
), ),
@@ -293,7 +266,11 @@ class TrailControls extends StatelessWidget {
); );
} }
void _showClearConfirmation(BuildContext context, MapProvider mapProvider, AppLocalizations l10n) { void _showClearConfirmation(
BuildContext context,
MapProvider mapProvider,
AppLocalizations l10n,
) {
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
@@ -318,50 +295,6 @@ class TrailControls extends StatelessWidget {
); );
} }
void _showImportDialog(BuildContext context, MapProvider mapProvider, trail, AppLocalizations l10n) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.importTrail),
content: Text(l10n.importTrailQuestion(trail.points.length)),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l10n.cancel),
),
TextButton(
onPressed: () {
mapProvider.setImportedTrail(trail);
Navigator.pop(context); // Close dialog
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.trailImported(trail.points.length)),
backgroundColor: Colors.green,
),
);
},
child: Text(l10n.viewAlongside),
),
TextButton(
onPressed: () {
mapProvider.replaceCurrentTrailWithImport(trail);
Navigator.pop(context); // Close dialog
Navigator.pop(context); // Close bottom sheet
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.trailReplaced(trail.points.length)),
backgroundColor: Colors.green,
),
);
},
style: TextButton.styleFrom(foregroundColor: Colors.blue),
child: Text(l10n.replaceCurrent),
),
],
),
);
}
Widget _buildStatRow({ Widget _buildStatRow({
required IconData icon, required IconData icon,
required String label, required String label,
@@ -373,18 +306,12 @@ class TrailControls extends StatelessWidget {
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
label, label,
style: const TextStyle( style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 14),
fontWeight: FontWeight.w500,
fontSize: 14,
),
), ),
const Spacer(), const Spacer(),
Text( Text(
value, value,
style: const TextStyle( style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
fontWeight: FontWeight.bold,
fontSize: 14,
),
), ),
], ],
); );

View File

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

View File

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

View File

@@ -14,7 +14,6 @@ import flutter_blue_plus_darwin
import flutter_local_notifications import flutter_local_notifications
import geolocator_apple import geolocator_apple
import nsd_macos import nsd_macos
import objectbox_flutter_libs
import package_info_plus import package_info_plus
import record_macos import record_macos
import share_plus import share_plus
@@ -32,7 +31,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
NsdMacosPlugin.register(with: registry.registrar(forPlugin: "NsdMacosPlugin")) NsdMacosPlugin.register(with: registry.registrar(forPlugin: "NsdMacosPlugin"))
ObjectboxFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "ObjectboxFlutterLibsPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin")) RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))

View File

@@ -226,14 +226,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.3" version: "7.0.3"
executor_lib:
dependency: transitive
description:
name: executor_lib
sha256: "95ddf2957d9942d9702855b38dd49677f0ee6a8b77d7b16c0e509c7669d17386"
url: "https://pub.dev"
source: hosted
version: "1.1.2"
exif: exif:
dependency: transitive dependency: transitive
description: description:
@@ -314,14 +306,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
flat_buffers:
dependency: transitive
description:
name: flat_buffers
sha256: "380bdcba5664a718bfd4ea20a45d39e13684f5318fcd8883066a55e21f37f4c3"
url: "https://pub.dev"
source: hosted
version: "23.5.26"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@@ -551,19 +535,12 @@ packages:
flutter_map: flutter_map:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_map path: "."
sha256: "391e7dc95cc3f5190748210a69d4cfeb5d8f84dcdfa9c3235d0a9d7742ccb3f8" ref: master
url: "https://pub.dev" resolved-ref: fdc089aeb4fad05a4f2314f75bbd66c9b7a70668
source: hosted url: "https://github.com/fleaflet/flutter_map.git"
source: git
version: "8.2.2" version: "8.2.2"
flutter_map_tile_caching:
dependency: "direct main"
description:
name: flutter_map_tile_caching
sha256: "90e097223d8ab74425cf15b449a03adfa4d4c28406dc757e1c396aff0f9beba7"
url: "https://pub.dev"
source: hosted
version: "10.1.1"
flutter_plugin_android_lifecycle: flutter_plugin_android_lifecycle:
dependency: transitive dependency: transitive
description: description:
@@ -694,14 +671,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.2" version: "4.1.2"
idb_shim:
dependency: transitive
description:
name: idb_shim
sha256: "921301da0a735f336a28fc35c3abdbd4498895cc205fa1ea9f7e785e7d854ceb"
url: "https://pub.dev"
source: hosted
version: "2.8.2+4"
image: image:
dependency: transitive dependency: transitive
description: description:
@@ -830,22 +799,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.0" version: "6.1.0"
lists:
dependency: transitive
description:
name: lists
sha256: "4ca5c19ae4350de036a7e996cdd1ee39c93ac0a2b840f4915459b7d0a7d4ab27"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
logger:
dependency: transitive
description:
name: logger
sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3
url: "https://pub.dev"
source: hosted
version: "2.6.2"
logging: logging:
dependency: transitive dependency: transitive
description: description:
@@ -858,10 +811,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.18" version: "0.12.19"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
@@ -870,14 +823,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.13.0" version: "0.13.0"
mbtiles:
dependency: "direct main"
description:
name: mbtiles
sha256: "316af1f8db8ce95888ca70f5dd3f6914906b4e17ceeca8501206d28e78612af8"
url: "https://pub.dev"
source: hosted
version: "0.4.2"
meshcore_client: meshcore_client:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -899,10 +844,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: mgrs_dart name: mgrs_dart
sha256: fb89ae62f05fa0bb90f70c31fc870bcbcfd516c843fb554452ab3396f78586f7 sha256: "385e7168ecc77eb545220223c49eef8ab249da7bf57f22781c40a04d23fb196f"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.0" version: "3.0.0"
mime: mime:
dependency: transitive dependency: transitive
description: description:
@@ -975,22 +920,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.1" version: "3.0.1"
objectbox:
dependency: transitive
description:
name: objectbox
sha256: "3cc186749178a3556e1020c9082d0897d0f9ecbdefcc27320e65c5bc650f0e57"
url: "https://pub.dev"
source: hosted
version: "4.3.1"
objectbox_flutter_libs:
dependency: transitive
description:
name: objectbox_flutter_libs
sha256: cd754766e04229a4f51250f121813d9a3c1a74fc21cd68e48b3c6085cbcd6c85
url: "https://pub.dev"
source: hosted
version: "4.3.1"
objective_c: objective_c:
dependency: transitive dependency: transitive
description: description:
@@ -1040,7 +969,7 @@ packages:
source: hosted source: hosted
version: "2.2.22" version: "2.2.22"
path_provider_foundation: path_provider_foundation:
dependency: "direct overridden" dependency: transitive
description: description:
name: path_provider_foundation name: path_provider_foundation
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
@@ -1163,10 +1092,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: proj4dart name: proj4dart
sha256: c8a659ac9b6864aa47c171e78d41bbe6f5e1d7bd790a5814249e6b68bc44324e sha256: ddcedc1f7876e62717de43ab3491e2829bdad0b028261805f94aa080967e5859
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.0" version: "3.0.0"
protobuf: protobuf:
dependency: transitive dependency: transitive
description: description:
@@ -1263,14 +1192,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.28.0" version: "0.28.0"
sembast:
dependency: transitive
description:
name: sembast
sha256: "139cf71496105de32e7a08a4e3a1ead0f81c4a616ec9703ed07e8f0d10cdd505"
url: "https://pub.dev"
source: hosted
version: "3.8.6"
share_plus: share_plus:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1359,6 +1280,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.4" version: "1.1.4"
simple_sparse_list:
dependency: transitive
description:
name: simple_sparse_list
sha256: aa648fd240fa39b49dcd11c19c266990006006de6699a412de485695910fbc1f
url: "https://pub.dev"
source: hosted
version: "0.1.4"
sky_engine: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@@ -1420,14 +1349,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.0" version: "2.4.0"
sqlite3:
dependency: transitive
description:
name: sqlite3
sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2"
url: "https://pub.dev"
source: hosted
version: "2.9.4"
stack_trace: stack_trace:
dependency: transitive dependency: transitive
description: description:
@@ -1472,10 +1393,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.9" version: "0.7.10"
timezone: timezone:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1496,10 +1417,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: unicode name: unicode
sha256: "0f69e46593d65245774d4f17125c6084d2c20b4e473a983f6e21b7d7762218f1" sha256: a6f7bcfc8ea1d5ce1f6c0b1c39117a9919f4953edd9fd7a64090a9796c499b57
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.3.1" version: "1.1.9"
url_launcher: url_launcher:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1572,23 +1493,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.5.3" version: "4.5.3"
vector_map_tiles:
dependency: "direct main"
description:
name: vector_map_tiles
sha256: e35f090c428f05e44dd525fa4fedaafd1dbcd28b656cb0ea908528c6ce84a87d
url: "https://pub.dev"
source: hosted
version: "9.0.0-beta.8"
vector_map_tiles_mbtiles:
dependency: "direct main"
description:
path: vector_map_tiles_mbtiles
ref: HEAD
resolved-ref: a09543b7590b373f3ac53f4776e343fee41c7dc6
url: "https://github.com/josxha/flutter_map_plugins.git"
source: git
version: "1.2.1"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
@@ -1597,30 +1501,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" version: "2.2.0"
vector_tile:
dependency: transitive
description:
name: vector_tile
sha256: "7ae290246e3a8734422672dbe791d3f7b8ab631734489fc6d405f1cc2080e38c"
url: "https://pub.dev"
source: hosted
version: "2.0.1"
vector_tile_dem:
dependency: transitive
description:
name: vector_tile_dem
sha256: "81a3568d2213817bd2698f919357e5107c0261491ae1014e821ed4fc3c2bf740"
url: "https://pub.dev"
source: hosted
version: "0.0.2"
vector_tile_renderer:
dependency: "direct main"
description:
name: vector_tile_renderer
sha256: "99530edb073c1cea3c6a4bdb5ca9a5c6779c25ddf1a645678458504708dc221c"
url: "https://pub.dev"
source: hosted
version: "6.0.0"
vibration: vibration:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1702,5 +1582,5 @@ packages:
source: hosted source: hosted
version: "3.1.3" version: "3.1.3"
sdks: sdks:
dart: ">=3.10.0 <4.0.0" dart: ">=3.10.3 <4.0.0"
flutter: ">=3.38.1" flutter: ">=3.38.4"

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.0307.6+19 version: 2026.0308.1+21
environment: environment:
sdk: ^3.9.2 sdk: ^3.9.2
@@ -66,24 +66,16 @@ dependencies:
provider: ^6.1.0 provider: ^6.1.0
# Map display # Map display
flutter_map: ^8.2.2 flutter_map:
git:
url: https://github.com/fleaflet/flutter_map.git
ref: master
latlong2: ^0.9.0 latlong2: ^0.9.0
# Offline tile caching
flutter_map_tile_caching: ^10.1.1
# Vector map tiles
vector_map_tiles: ^9.0.0-beta.8
vector_map_tiles_mbtiles:
git:
url: https://github.com/josxha/flutter_map_plugins.git
path: vector_map_tiles_mbtiles
vector_tile_renderer: ^6.0.0
mbtiles: ^0.4.0
http: ^1.2.0 http: ^1.2.0
# Coordinate system projections for WMS (EPSG:3794) # Coordinate system projections for WMS (EPSG:3794)
proj4dart: ^2.1.0 proj4dart: ^3.0.0
# Permissions # Permissions
permission_handler: ^12.0.1 permission_handler: ^12.0.1
@@ -146,9 +138,6 @@ dev_dependencies:
flutter_launcher_icons: "^0.14.4" flutter_launcher_icons: "^0.14.4"
fake_async: ^1.3.3 fake_async: ^1.3.3
dependency_overrides:
path_provider_foundation: 2.6.0
flutter_launcher_icons: flutter_launcher_icons:
android: "launcher_icon" android: "launcher_icon"
ios: true ios: true

View File

@@ -1,9 +1,11 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:latlong2/latlong.dart';
import 'package:meshcore_sar_app/models/contact.dart'; import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/providers/contacts_provider.dart'; import 'package:meshcore_sar_app/providers/contacts_provider.dart';
import 'package:meshcore_sar_app/services/cayenne_lpp_parser.dart'; import 'package:meshcore_sar_app/services/cayenne_lpp_parser.dart';
import 'package:meshcore_sar_app/utils/fast_gps_packet.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
void main() { void main() {
@@ -241,6 +243,129 @@ void main() {
}, },
); );
test('retains existing telemetry when contact refresh omits telemetry', () {
final initialTelemetry = ContactTelemetry(
gpsLocation: const LatLng(45.1234, 13.8765),
batteryPercentage: 76.5,
batteryMilliVolts: 3890,
temperature: 21.5,
timestamp: DateTime.now().subtract(const Duration(minutes: 5)),
humidity: 62.0,
pressure: 1008.4,
extraSensorData: const {'co2': 415.0},
);
provider.addOrUpdateContact(
createContact(
key: publicKey,
type: ContactType.chat,
).copyWith(telemetry: initialTelemetry),
);
provider.addOrUpdateContact(
Contact(
publicKey: publicKey,
type: ContactType.chat,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: 'Test Contact Refreshed',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
),
);
final updated = provider.findContactByKey(publicKey)!;
expect(updated.telemetry, isNotNull);
expect(updated.telemetry!.gpsLocation, const LatLng(45.1234, 13.8765));
expect(updated.telemetry!.batteryPercentage, equals(76.5));
expect(updated.telemetry!.batteryMilliVolts, equals(3890));
expect(updated.telemetry!.temperature, equals(21.5));
expect(updated.telemetry!.humidity, equals(62.0));
expect(updated.telemetry!.pressure, equals(1008.4));
expect(updated.telemetry!.extraSensorData, containsPair('co2', 415.0));
});
test('retains existing telemetry during bulk contacts sync', () {
final initialTelemetry = ContactTelemetry(
gpsLocation: const LatLng(45.1234, 13.8765),
batteryPercentage: 76.5,
batteryMilliVolts: 3890,
temperature: 21.5,
timestamp: DateTime.now().subtract(const Duration(minutes: 5)),
humidity: 62.0,
pressure: 1008.4,
extraSensorData: const {'co2': 415.0},
);
provider.addOrUpdateContact(
createContact(
key: publicKey,
type: ContactType.chat,
).copyWith(telemetry: initialTelemetry),
);
provider.addContacts([
Contact(
publicKey: publicKey,
type: ContactType.chat,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: 'Synced Contact',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
),
]);
final updated = provider.findContactByKey(publicKey)!;
expect(updated.telemetry, isNotNull);
expect(updated.telemetry!.gpsLocation, const LatLng(45.1234, 13.8765));
expect(updated.telemetry!.batteryPercentage, equals(76.5));
expect(updated.telemetry!.batteryMilliVolts, equals(3890));
expect(updated.telemetry!.temperature, equals(21.5));
expect(updated.telemetry!.humidity, equals(62.0));
expect(updated.telemetry!.pressure, equals(1008.4));
expect(updated.telemetry!.extraSensorData, containsPair('co2', 415.0));
});
test('retains prior telemetry fields across sparse telemetry updates', () {
final fullTelemetry = ContactTelemetry(
gpsLocation: const LatLng(46.0569, 14.5058),
batteryPercentage: 54.0,
batteryMilliVolts: 3780,
temperature: 19.5,
timestamp: DateTime.now().subtract(const Duration(minutes: 2)),
humidity: 58.0,
pressure: 1011.2,
extraSensorData: const {'pm25': 8.0},
);
provider.addOrUpdateContact(
createContact(
key: publicKey,
type: ContactType.chat,
).copyWith(telemetry: fullTelemetry),
);
final batteryOnly = CayenneLppParser.createBatteryData(3.95);
provider.updateTelemetry(publicKey.sublist(0, 6), batteryOnly);
final updated = provider.findContactByKey(publicKey)!;
expect(updated.telemetry, isNotNull);
expect(updated.telemetry!.gpsLocation, const LatLng(46.0569, 14.5058));
expect(updated.telemetry!.batteryMilliVolts, isNotNull);
expect(updated.telemetry!.batteryPercentage, isNotNull);
expect(updated.telemetry!.temperature, equals(19.5));
expect(updated.telemetry!.humidity, equals(58.0));
expect(updated.telemetry!.pressure, equals(1011.2));
expect(updated.telemetry!.extraSensorData, containsPair('pm25', 8.0));
});
test('builds message snapshot from latest valid telemetry', () { test('builds message snapshot from latest valid telemetry', () {
final telemetryData = CayenneLppParser.createGpsData( final telemetryData = CayenneLppParser.createGpsData(
latitude: 45.0001, latitude: 45.0001,
@@ -347,4 +472,65 @@ void main() {
expect(updated.routeSummary, 'Flood/Unknown'); expect(updated.routeSummary, 'Flood/Unknown');
}); });
}); });
group('ContactsProvider.updateFastGps', () {
late ContactsProvider provider;
late Uint8List publicKey;
setUp(() {
SharedPreferences.setMockInitialValues({});
provider = ContactsProvider();
publicKey = createPublicKey(50);
provider.addOrUpdateContact(
createContact(key: publicKey, type: ContactType.chat, name: 'Fast GPS'),
);
});
test('updates gps while preserving other telemetry fields', () {
final batteryOnly = CayenneLppParser.createBatteryData(3.9);
provider.updateTelemetry(publicKey.sublist(0, 6), batteryOnly);
provider.updateFastGps(
publicKey.sublist(0, 6),
const FastGpsPacket(
senderKey6: '323334353637',
latitude: 44.123456,
longitude: 13.654321,
timestampSeconds: 1700001234,
),
);
final updated = provider.findContactByKey(publicKey)!;
expect(updated.telemetry, isNotNull);
expect(updated.telemetry!.gpsLocation, isNotNull);
expect(
updated.telemetry!.gpsLocation!.latitude,
closeTo(44.123456, 0.000001),
);
expect(
updated.telemetry!.gpsLocation!.longitude,
closeTo(13.654321, 0.000001),
);
expect(updated.telemetry!.batteryMilliVolts, isNotNull);
expect(updated.advLat, equals((44.123456 * 1e6).round()));
expect(updated.advLon, equals((13.654321 * 1e6).round()));
expect(updated.lastAdvert, equals(1700001234));
});
test('ignores unknown sender prefix safely', () {
final before = provider.findContactByKey(publicKey)!;
provider.updateFastGps(
Uint8List.fromList([1, 2, 3, 4, 5, 6]),
const FastGpsPacket(
senderKey6: '010203040506',
latitude: 10,
longitude: 20,
timestampSeconds: 99,
),
);
final after = provider.findContactByKey(publicKey)!;
expect(after.advLat, equals(before.advLat));
expect(after.advLon, equals(before.advLon));
});
});
} }

View File

@@ -0,0 +1,97 @@
import 'dart:typed_data';
import 'dart:math' as math;
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/utils/fast_gps_packet.dart';
void main() {
group('FastGpsPacket', () {
test('encodes and parses a valid packet', () {
final packet = FastGpsPacket(
senderKey6: 'aabbccddeeff',
latitude: 46.0569,
longitude: 14.5058,
timestampSeconds: 1700000000,
);
final encoded = packet.encodeBinary();
final parsed = FastGpsPacket.tryParseBinary(encoded);
expect(parsed, isNotNull);
expect(parsed!.senderKey6, equals('aabbccddeeff'));
expect(parsed.latitude, closeTo(46.0569, 0.000001));
expect(parsed.longitude, closeTo(14.5058, 0.000001));
expect(parsed.timestampSeconds, equals(1700000000));
});
test('supports negative coordinates', () {
final packet = FastGpsPacket(
senderKey6: '001122334455',
latitude: -33.8688,
longitude: -151.2093,
timestampSeconds: 42,
);
final parsed = FastGpsPacket.tryParseBinary(packet.encodeBinary());
expect(parsed, isNotNull);
expect(parsed!.latitude, closeTo(-33.8688, 0.000001));
expect(parsed.longitude, closeTo(-151.2093, 0.000001));
});
test('rejects malformed payloads', () {
expect(
FastGpsPacket.tryParseBinary(Uint8List.fromList([0x47, 0x01])),
isNull,
);
expect(
FastGpsPacket.tryParseBinary(
Uint8List.fromList(List<int>.filled(19, 0)..[0] = 0x48),
),
isNull,
);
});
test('rejects invalid coordinate ranges', () {
final payload = Uint8List(19);
payload[0] = FastGpsPacket.magic;
payload.setRange(1, 7, [0, 1, 2, 3, 4, 5]);
final data = ByteData.sublistView(payload);
data.setInt32(
7,
(91.0 * FastGpsPacket.coordinateScale).round(),
Endian.little,
);
data.setInt32(
11,
(14.5 * FastGpsPacket.coordinateScale).round(),
Endian.little,
);
data.setUint32(15, 1, Endian.little);
expect(FastGpsPacket.tryParseBinary(payload), isNull);
});
test('preserves at least meter accuracy', () {
const latitude = 46.0569123;
const longitude = 14.5058123;
final packet = FastGpsPacket(
senderKey6: 'aabbccddeeff',
latitude: latitude,
longitude: longitude,
timestampSeconds: 1700000000,
);
final parsed = FastGpsPacket.tryParseBinary(packet.encodeBinary());
expect(parsed, isNotNull);
final latMeters = (parsed!.latitude - latitude).abs() * 111320.0;
final lonMeters =
(parsed.longitude - longitude).abs() *
111320.0 *
math.cos(latitude * math.pi / 180.0);
expect(latMeters, lessThan(1.0));
expect(lonMeters, lessThan(1.0));
});
});
}

View File

@@ -12,7 +12,6 @@
#include <flutter_blue_plus_winrt/flutter_blue_plus_plugin.h> #include <flutter_blue_plus_winrt/flutter_blue_plus_plugin.h>
#include <geolocator_windows/geolocator_windows.h> #include <geolocator_windows/geolocator_windows.h>
#include <nsd_windows/nsd_windows_plugin_c_api.h> #include <nsd_windows/nsd_windows_plugin_c_api.h>
#include <objectbox_flutter_libs/objectbox_flutter_libs_plugin.h>
#include <permission_handler_windows/permission_handler_windows_plugin.h> #include <permission_handler_windows/permission_handler_windows_plugin.h>
#include <record_windows/record_windows_plugin_c_api.h> #include <record_windows/record_windows_plugin_c_api.h>
#include <share_plus/share_plus_windows_plugin_c_api.h> #include <share_plus/share_plus_windows_plugin_c_api.h>
@@ -31,8 +30,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("GeolocatorWindows")); registry->GetRegistrarForPlugin("GeolocatorWindows"));
NsdWindowsPluginCApiRegisterWithRegistrar( NsdWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("NsdWindowsPluginCApi")); registry->GetRegistrarForPlugin("NsdWindowsPluginCApi"));
ObjectboxFlutterLibsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ObjectboxFlutterLibsPlugin"));
PermissionHandlerWindowsPluginRegisterWithRegistrar( PermissionHandlerWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
RecordWindowsPluginCApiRegisterWithRegistrar( RecordWindowsPluginCApiRegisterWithRegistrar(

View File

@@ -9,7 +9,6 @@ list(APPEND FLUTTER_PLUGIN_LIST
flutter_blue_plus_winrt flutter_blue_plus_winrt
geolocator_windows geolocator_windows
nsd_windows nsd_windows
objectbox_flutter_libs
permission_handler_windows permission_handler_windows
record_windows record_windows
share_plus share_plus