diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 3236bcb..ea0522a 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -65,9 +65,6 @@ PODS: - ObjectBox (= 4.4.1) - package_info_plus (0.4.5): - Flutter - - path_provider_foundation (0.0.1): - - Flutter - - FlutterMacOS - permission_handler_apple (9.3.0): - Flutter - record_ios (1.2.0): @@ -105,7 +102,6 @@ DEPENDENCIES: - 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`) - - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - record_ios (from `.symlinks/plugins/record_ios/ios`) - share_plus (from `.symlinks/plugins/share_plus/ios`) @@ -153,8 +149,6 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/objectbox_flutter_libs/ios" package_info_plus: :path: ".symlinks/plugins/package_info_plus/ios" - path_provider_foundation: - :path: ".symlinks/plugins/path_provider_foundation/darwin" permission_handler_apple: :path: ".symlinks/plugins/permission_handler_apple/ios" record_ios: @@ -182,14 +176,13 @@ SPEC CHECKSUMS: flutter_background_service_ios: 00d31bdff7b4bfe06d32375df358abe0329cf87e flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3 flutter_compass: b236ab69b61545cce89fd58527f401a7587d5cc1 - flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb + flutter_local_notifications: 643a3eda1ce1c0599413ca31672536d423dee214 geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 nsd_ios: 596ad79109ddd3e52d665f650f36428049e3653e ObjectBox: 7da4aceb5013d041bfafdbc6d744a26918b09757 objectbox_flutter_libs: 09b1dec1b4cd27bf1a5f9bae7ccaa7e43588bf31 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 - path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d record_ios: 412daca2350b228e698fffcd08f1f94ceb1e3844 SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index f8d79fc..8e57aed 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -489,7 +489,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 100; + CURRENT_PROJECT_VERSION = 101; DEVELOPMENT_TEAM = JND55328G8; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -511,7 +511,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 100; + CURRENT_PROJECT_VERSION = 101; DEVELOPMENT_TEAM = JND55328G8; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; @@ -530,7 +530,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 100; + CURRENT_PROJECT_VERSION = 101; DEVELOPMENT_TEAM = JND55328G8; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; @@ -547,7 +547,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 100; + CURRENT_PROJECT_VERSION = 101; DEVELOPMENT_TEAM = JND55328G8; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; @@ -679,7 +679,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 100; + CURRENT_PROJECT_VERSION = 101; DEVELOPMENT_TEAM = JND55328G8; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -702,7 +702,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 100; + CURRENT_PROJECT_VERSION = 101; DEVELOPMENT_TEAM = JND55328G8; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index a371671..81f26d0 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -43,7 +43,7 @@ CFBundleSignature ???? CFBundleVersion - 100 + 101 LSRequiresIPhoneOS ITSAppUsesNonExemptEncryption diff --git a/ios/fastlane/report.xml b/ios/fastlane/report.xml index fed4b04..ea05d7b 100644 --- a/ios/fastlane/report.xml +++ b/ios/fastlane/report.xml @@ -5,17 +5,17 @@ - + - + - + diff --git a/lib/models/contact.dart b/lib/models/contact.dart index 41e4f19..47895a2 100644 --- a/lib/models/contact.dart +++ b/lib/models/contact.dart @@ -231,10 +231,10 @@ extension ContactLocalization on Contact { } String get routeSummary { - if (routeIsUnknown || !routeHasPath) { - return 'Flood/Unknown'; + if (routeIsUnknown) { + return 'Unknown'; } - if (routeHopCount == 0) { + if (!routeHasPath || routeHopCount == 0) { return 'Direct'; } return '$routeHopCount hop${routeHopCount == 1 ? '' : 's'} via $routeHashSize-byte hashes'; diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart index fa50f3c..d251082 100644 --- a/lib/providers/connection_provider.dart +++ b/lib/providers/connection_provider.dart @@ -2015,6 +2015,7 @@ class ConnectionProvider with ChangeNotifier { } try { + _error = null; await _activeService.resetPath(contactPublicKey); } catch (e) { _error = 'Failed to reset path: $e'; @@ -2034,6 +2035,7 @@ class ConnectionProvider with ChangeNotifier { } try { + _error = null; final updatedContact = contact.copyWith( outPathLen: signedEncodedPathLen, outPath: Uint8List.fromList(paddedPathBytes), diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 8b28e10..6350ddd 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -715,36 +715,33 @@ class _HomeScreenState extends State with TickerProviderStateMixin { ], ), ), - // Settings cog - hidden in simple mode - if (!context.watch().isSimpleMode) ...[ - const SizedBox(width: 8), - GestureDetector( - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const DeviceConfigScreen(), + const SizedBox(width: 8), + GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const DeviceConfigScreen(), + ), + ); + }, + onLongPress: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PacketLogScreen( + bleService: provider.bleService, ), - ); - }, - onLongPress: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => PacketLogScreen( - bleService: provider.bleService, - ), - ), - ); - }, - child: Container( - width: 32, - height: 32, - alignment: Alignment.center, - child: const Icon(Icons.settings, size: 18), - ), + ), + ); + }, + child: Container( + width: 32, + height: 32, + alignment: Alignment.center, + child: const Icon(Icons.settings, size: 18), ), - ], + ), ], ), ), diff --git a/lib/screens/map_tab.dart b/lib/screens/map_tab.dart index be80c18..992b747 100644 --- a/lib/screens/map_tab.dart +++ b/lib/screens/map_tab.dart @@ -59,7 +59,7 @@ class MapTab extends StatefulWidget { class _MapTabState extends State with AutomaticKeepAliveClientMixin { final MapController _mapController = MapController(); - final TileCacheService _tileCache = TileCacheService(); + late final TileCacheService _tileCache; // DO NOT create a new LocationTrackingService instance here // Use the singleton from AppProvider instead via _locationService getter final MapMarkerService _markerService = MapMarkerService(); @@ -117,6 +117,7 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { @override void initState() { super.initState(); + _tileCache = context.read(); // Initialize Slovenian WMS layers with CRS _slovenianAerialLayer = MapLayer.getSlovenianAerial2024(slovenianCrs); _dtk25Layer = MapLayer.getDTK25(slovenianCrs); @@ -446,7 +447,6 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { // Restore the original callback instead of setting to null _locationService.onPositionUpdate = _originalLocationCallback; _mapController.dispose(); - _tileCache.dispose(); super.dispose(); } diff --git a/lib/services/tile_cache_service.dart b/lib/services/tile_cache_service.dart index 0aecd68..888abba 100644 --- a/lib/services/tile_cache_service.dart +++ b/lib/services/tile_cache_service.dart @@ -10,47 +10,66 @@ class TileCacheService { // Global flag to ensure ObjectBox is only initialized once static bool _objectBoxInitialized = false; - static final _initLock = >{}; + static Future? _objectBoxInitialization; - late final FMTCStore _store; + Future? _initializeFuture; + FMTCStore? _store; bool _isInitialized = false; bool _isDownloading = false; Future initialize() async { if (_isInitialized) return; + _initializeFuture ??= _initializeInternal(); + await _initializeFuture; + } - // 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; - } - + Future _initializeInternal() async { try { - _store = FMTCStore(_storeName); - await _store.manage.create(); - _isInitialized = true; - } catch (e) { - // Store might already exist - _store = FMTCStore(_storeName); + await _ensureObjectBoxInitialized(); + + final store = FMTCStore(_storeName); + try { + await store.manage.create(); + } catch (_) { + // The store may already exist from a prior initialization. + } + + _store = store; _isInitialized = true; + } catch (_) { + _initializeFuture = null; + rethrow; } } - FMTCTileProvider getTileProvider(MapLayer layer) { - if (!_isInitialized) { + Future _ensureObjectBoxInitialized() async { + if (_objectBoxInitialized) return; + + _objectBoxInitialization ??= () async { + try { + await FMTCObjectBoxBackend().initialise(); + } catch (_) { + // Treat repeated backend initialization as a no-op. + } finally { + _objectBoxInitialized = true; + } + }(); + + await _objectBoxInitialization; + } + + FMTCStore _requireStore() { + final store = _store; + if (!_isInitialized || store == null) { throw StateError( 'TileCacheService not initialized. Call initialize() first.', ); } + return store; + } + + TileProvider getTileProvider(MapLayer layer) { + _requireStore(); return FMTCTileProvider( stores: {_storeName: BrowseStoreStrategy.readUpdateCreate}, loadingStrategy: BrowseLoadingStrategy.cacheFirst, @@ -60,12 +79,8 @@ class TileCacheService { /// 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.', - ); - } + TileProvider getTileProviderForWms(MapLayer layer) { + _requireStore(); if (!layer.isWms) { throw ArgumentError('Layer must be a WMS layer'); } @@ -97,6 +112,7 @@ class TileCacheService { } _isDownloading = true; + final store = _requireStore(); try { final region = RectangleRegion(bounds); @@ -107,7 +123,7 @@ class TileCacheService { options: TileLayer(urlTemplate: layer.urlTemplate), ); - final download = _store.download.startForeground(region: downloadable); + final download = store.download.startForeground(region: downloadable); await for (final progress in download.downloadProgress) { if (onProgress != null && progress.maxTilesCount > 0) { @@ -127,24 +143,25 @@ class TileCacheService { Future cancelDownload() async { if (!_isInitialized) return; - await _store.download.cancel(); + await _requireStore().download.cancel(); } Future clearCache() async { if (!_isInitialized) return; - await _store.manage.delete(); - await _store.manage.create(); + final store = _requireStore(); + await store.manage.delete(); + await store.manage.create(); } Future getCachedTileCount() async { if (!_isInitialized) return 0; - final stats = await _store.stats.length; + final stats = await _requireStore().stats.length; return stats; } Future getCacheSizeMB() async { if (!_isInitialized) return 0.0; - final stats = await _store.stats.size; + final stats = await _requireStore().stats.size; return stats / (1024 * 1024); } @@ -162,8 +179,9 @@ class TileCacheService { Future> getStoreStats() async { if (!_isInitialized) return {}; - final length = await _store.stats.length; - final size = await _store.stats.all.then((a) => a.size); + final store = _requireStore(); + final length = await store.stats.length; + final size = await store.stats.all.then((a) => a.size); return { 'tileCount': length, diff --git a/lib/widgets/contacts/contact_route_dialog.dart b/lib/widgets/contacts/contact_route_dialog.dart index f8f87fb..cdb0c0b 100644 --- a/lib/widgets/contacts/contact_route_dialog.dart +++ b/lib/widgets/contacts/contact_route_dialog.dart @@ -3,6 +3,18 @@ import 'package:flutter/material.dart'; import '../../models/contact.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 { final Contact contact; final List availableContacts; @@ -13,12 +25,12 @@ class ContactRouteDialog extends StatefulWidget { required this.availableContacts, }); - static Future show( + static Future show( BuildContext context, { required Contact contact, required List availableContacts, }) { - return showModalBottomSheet( + return showModalBottomSheet( context: context, isScrollControlled: true, showDragHandle: true, @@ -212,11 +224,20 @@ class _ContactRouteDialogState extends State { onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel'), ), + if (widget.contact.routeHasPath) + TextButton( + onPressed: () => Navigator.of( + context, + ).pop(const ContactRouteDialogResult.clear()), + child: const Text('Clear Route'), + ), const Spacer(), FilledButton( onPressed: _parsedRoute == null ? null - : () => Navigator.of(context).pop(_parsedRoute), + : () => Navigator.of( + context, + ).pop(ContactRouteDialogResult.set(_parsedRoute!)), child: const Text('Set Route'), ), ], diff --git a/lib/widgets/contacts/contact_tile.dart b/lib/widgets/contacts/contact_tile.dart index c7cdc61..42382f3 100644 --- a/lib/widgets/contacts/contact_tile.dart +++ b/lib/widgets/contacts/contact_tile.dart @@ -11,6 +11,7 @@ import '../../providers/map_provider.dart'; import '../../providers/app_provider.dart'; import 'contact_route_dialog.dart'; import 'room_login_sheet.dart'; +import '../common/contact_avatar.dart'; import '../../utils/location_formats.dart'; import '../../utils/toast_logger.dart'; import '../../utils/battery_display_helper.dart'; @@ -147,54 +148,28 @@ class ContactTile extends StatelessWidget { fontWeight: FontWeight.w800, letterSpacing: -0.3, ); + final timeAgoText = _getLocalizedTimeSinceLastSeen(context); + final timeAgoStyle = Theme.of(context).textTheme.labelSmall?.copyWith( + color: contact.isRecentlySeen + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ); final subtitleWidget = isSimpleMode ? Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const SizedBox(height: 2), - Row( - children: [ - Icon( - Icons.access_time, - size: 12, - color: contact.isRecentlySeen ? Colors.green : Colors.grey, - ), - const SizedBox(width: 4), - Text( - '${AppLocalizations.of(context)!.lastSeen}: ${_getLocalizedTimeSinceLastSeen(context)}', - style: Theme.of(context).textTheme.labelSmall, - ), - ], - ), if (location != null) ...[ - const SizedBox(height: 4), - Row( - children: [ - const Icon(Icons.location_on, size: 12, color: Colors.blue), - 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, - ), - ), - ], + const SizedBox(height: 2), + _buildLocationLine( + context, + latitude: location.latitude, + longitude: location.longitude, + distanceText: distanceText, ), - if (contact.type != ContactType.channel || - distanceText != null) ...[ + if (contact.type != ContactType.channel) ...[ 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), - ], - ), + Row(children: [_buildRoutePill(context, contact)]), ], ] else Padding( @@ -285,40 +260,17 @@ class ContactTile extends StatelessWidget { ], 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, + child: _buildLocationLine( + context, + latitude: location.latitude, + longitude: location.longitude, + distanceText: distanceText, + telemetryActive: hasTelemetry, ), ), ] 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( @@ -328,20 +280,9 @@ class ContactTile extends StatelessWidget { ], ], ), - if (contact.type != ContactType.channel || - distanceText != null) ...[ + if (contact.type != ContactType.channel) ...[ 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), - ], - ), + Row(children: [_buildRoutePill(context, contact)]), ], ], ); @@ -378,51 +319,12 @@ class ContactTile extends StatelessWidget { child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), child: Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, children: [ Stack( clipBehavior: Clip.none, children: [ - Container( - 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, - ), - ), - ), + ContactAvatar(contact: contact, radius: 24), if (contact.isNew) Positioned( top: -2, @@ -480,26 +382,7 @@ class ContactTile extends StatelessWidget { ), ), const SizedBox(width: 8), - Container( - 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, - ), - ), - ), + Text(timeAgoText, style: timeAgoStyle), if (!isSimpleMode && battery != null) ...[ const SizedBox(width: 6), _buildBatteryBadge(context, battery), @@ -656,18 +539,7 @@ class ContactTile extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( children: [ - CircleAvatar( - backgroundColor: _getTypeColor(contact.type, context), - child: contact.roleEmoji != null - ? Text( - contact.roleEmoji!, - style: const TextStyle(fontSize: 24), - ) - : Icon( - _getTypeIcon(contact.type), - color: Colors.white, - ), - ), + ContactAvatar(contact: contact, radius: 20), const SizedBox(width: 12), Expanded( child: Text( @@ -1116,17 +988,42 @@ class ContactTile extends StatelessWidget { .where((candidate) => candidate.publicKeyHex != contact.publicKeyHex) .toList(); - final parsedRoute = await ContactRouteDialog.show( + final routeResult = await ContactRouteDialog.show( context, contact: contact, availableContacts: availableContacts, ); - if (parsedRoute == null || !context.mounted) { + if (routeResult == null || !context.mounted) { return; } final previousSignedPathLen = contact.routeSignedPathLen; 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( contact.publicKey, signedEncodedPathLen: parsedRoute.signedEncodedPathLen, @@ -1217,6 +1114,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 String _convertToDMS(double lat, double lon) { String latDir = lat >= 0 ? 'N' : 'S'; @@ -1273,19 +1237,6 @@ class ContactTile extends StatelessWidget { 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) { switch (type) { case ContactType.chat: @@ -1411,6 +1362,7 @@ class ContactTile extends StatelessWidget { Widget _buildRoutePill(BuildContext context, Contact contact) { final hasPath = contact.routeHasPath; + final isDirect = !hasPath || contact.routeHopCount <= 0; final scheme = Theme.of(context).colorScheme; final textColor = Theme.of( context, @@ -1418,9 +1370,7 @@ class ContactTile extends StatelessWidget { final iconColor = Theme.of( context, ).textTheme.labelSmall?.color?.withValues(alpha: 0.7); - final label = !hasPath - ? AppLocalizations.of(context)!.flood - : contact.routeHopCount <= 0 + final label = isDirect ? AppLocalizations.of(context)!.direct : contact.routeCanonicalText; @@ -1434,7 +1384,7 @@ class ContactTile extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Icon( - hasPath ? Icons.alt_route : Icons.waves, + isDirect ? Icons.north_east_rounded : Icons.alt_route, size: 11, color: iconColor, ), @@ -1447,9 +1397,7 @@ class ContactTile extends StatelessWidget { style: Theme.of(context).textTheme.labelSmall?.copyWith( color: textColor, fontWeight: FontWeight.w600, - fontFamily: hasPath && contact.routeHopCount > 0 - ? 'monospace' - : null, + fontFamily: !isDirect ? 'monospace' : null, ), ), ), @@ -1479,7 +1427,7 @@ class ContactTile extends StatelessWidget { ), const SizedBox(width: 5), Text( - '${AppLocalizations.of(context)!.distance}: $distanceText', + distanceText, style: Theme.of(context).textTheme.labelSmall?.copyWith( color: Theme.of( context,