diff --git a/lib/main.dart b/lib/main.dart index d519938..d7f2e8f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -16,6 +16,7 @@ import 'package:tree_planting_protocol/pages/tree_details_page.dart'; import 'package:tree_planting_protocol/pages/trees_page.dart'; import 'package:tree_planting_protocol/pages/user_profile_page.dart'; import 'package:tree_planting_protocol/pages/mint_nft/mint_nft_coordinates.dart'; +import 'package:tree_planting_protocol/pages/nearby_trees_map_page.dart'; import 'package:tree_planting_protocol/providers/wallet_provider.dart'; import 'package:tree_planting_protocol/providers/theme_provider.dart'; @@ -135,6 +136,13 @@ class MyApp extends StatelessWidget { }, ), ]), + GoRoute( + path: '/nearby-trees', + name: 'nearby_trees', + builder: (BuildContext context, GoRouterState state) { + return const NearbyTreesMapPage(); + }, + ), GoRoute( path: RouteConstants.allTreesPath, name: RouteConstants.allTrees, diff --git a/lib/pages/nearby_trees_map_page.dart b/lib/pages/nearby_trees_map_page.dart new file mode 100644 index 0000000..12e1a02 --- /dev/null +++ b/lib/pages/nearby_trees_map_page.dart @@ -0,0 +1,426 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:provider/provider.dart'; +import 'package:tree_planting_protocol/providers/wallet_provider.dart'; +import 'package:tree_planting_protocol/utils/constants/ui/color_constants.dart'; +import 'package:tree_planting_protocol/utils/logger.dart'; +import 'package:tree_planting_protocol/utils/services/get_current_location.dart'; +import 'package:tree_planting_protocol/utils/services/contract_functions/tree_nft_contract/tree_nft_contract_read_services.dart'; + +/// Interactive map page showing nearby planted trees +class NearbyTreesMapPage extends StatefulWidget { + const NearbyTreesMapPage({super.key}); + + @override + State createState() => _NearbyTreesMapPageState(); +} + +class _NearbyTreesMapPageState extends State { + final MapController _mapController = MapController(); + final LocationService _locationService = LocationService(); + + List> _nearbyTrees = []; + bool _isLoading = true; + bool _hasError = false; + String? _errorMessage; + double? _userLat; + double? _userLng; + int? _selectedTreeId; + + // Default location (fallback if GPS fails) + static const double _defaultLat = 28.9845; // Example: Roorkee, India + static const double _defaultLng = 77.8956; + + @override + void initState() { + super.initState(); + _loadNearbyTrees(); + } + + @override + void dispose() { + _mapController.dispose(); + super.dispose(); + } + + Future _loadNearbyTrees() async { + setState(() { + _isLoading = true; + _hasError = false; + _errorMessage = null; + }); + + try { + // Try to get user's current location, but don't fail if it's unavailable + try { + final locationInfo = await _locationService.getCurrentLocation(); + _userLat = locationInfo.latitude; + _userLng = locationInfo.longitude; + logger.i("User location: $_userLat, $_userLng"); + } catch (locationError) { + // Location failed, use default location + logger.w("Could not get user location, using default: $locationError"); + _userLat = _defaultLat; + _userLng = _defaultLng; + } + + // Check if widget is still mounted + if (!mounted) return; + + // Fetch nearby trees using either actual or default location + final walletProvider = + Provider.of(context, listen: false); + + final result = await ContractReadFunctions.getNearbyTrees( + walletProvider: walletProvider, + centerLat: _userLat!, + centerLng: _userLng!, + radiusKm: 10.0, // 10km radius + ); + + if (result.success && result.data != null) { + final trees = result.data['trees'] as List>; + + setState(() { + _nearbyTrees = trees; + _isLoading = false; + }); + + logger.i("Loaded ${trees.length} nearby trees"); + } else { + throw Exception(result.errorMessage ?? 'Failed to load trees'); + } + } catch (e) { + logger.e("Error loading nearby trees: $e"); + + // Use fallback location if not already set + setState(() { + _userLat ??= _defaultLat; + _userLng ??= _defaultLng; + _hasError = true; + _errorMessage = e.toString(); + _isLoading = false; + }); + } + } + + void _recenterMap() { + if (_userLat != null && _userLng != null) { + _mapController.move(LatLng(_userLat!, _userLng!), 13.0); + } + } + + /// Convert contract coordinates (fixed-point) to decimal degrees + double _convertCoordinate(int coordinate) { + return (coordinate / 1000000.0) - 90.0; + } + + List _buildTreeMarkers() { + return _nearbyTrees.map((tree) { + final lat = _convertCoordinate(tree['latitude'] as int); + final lng = _convertCoordinate(tree['longitude'] as int); + final isSelected = _selectedTreeId == tree['id']; + + return Marker( + point: LatLng(lat, lng), + width: 40, + height: 50, + child: GestureDetector( + onTap: () { + setState(() { + _selectedTreeId = tree['id']; + }); + _showTreeDetails(tree); + }, + child: Icon( + Icons.park, + size: isSelected ? 45 : 35, + color: isSelected ? Colors.green[700] : Colors.green, + shadows: [ + Shadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 4, + ), + ], + ), + ), + ); + }).toList(); + } + + Marker? _buildUserMarker() { + if (_userLat == null || _userLng == null) return null; + + return Marker( + point: LatLng(_userLat!, _userLng!), + width: 40, + height: 40, + child: Container( + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.3), + shape: BoxShape.circle, + border: Border.all(color: Colors.blue, width: 3), + ), + child: const Icon( + Icons.my_location, + color: Colors.blue, + size: 20, + ), + ), + ); + } + + void _showTreeDetails(Map tree) { + final lat = _convertCoordinate(tree['latitude'] as int); + final lng = _convertCoordinate(tree['longitude'] as int); + final plantingDate = DateTime.fromMillisecondsSinceEpoch( + (tree['planting'] as int) * 1000, + ); + + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (context) => Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: getThemeColors(context)['background'], + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + border: Border( + top: BorderSide( + color: getThemeColors(context)['border']!, + width: 2, + ), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Drag handle + Center( + child: Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.grey[400], + borderRadius: BorderRadius.circular(2), + ), + ), + ), + + // Tree details + Text( + tree['species'] ?? 'Unknown Species', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: getThemeColors(context)['textPrimary'], + ), + ), + const SizedBox(height: 12), + + _buildDetailRow(Icons.tag, 'ID #${tree['id']}'), + _buildDetailRow(Icons.calendar_today, + 'Planted: ${plantingDate.year}-${plantingDate.month}-${plantingDate.day}'), + _buildDetailRow(Icons.location_on, + '${lat.toStringAsFixed(4)}, ${lng.toStringAsFixed(4)}'), + _buildDetailRow(Icons.favorite, '${tree['careCount']} care events'), + _buildDetailRow(Icons.photo, + '${(tree['photos'] as List).length} photos'), + + const SizedBox(height: 16), + + // View full details button + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () { + Navigator.pop(context); + context.push('/trees/${tree['id']}'); + }, + icon: const Icon(Icons.info_outline), + label: const Text('View Full Details'), + style: ElevatedButton.styleFrom( + backgroundColor: getThemeColors(context)['primary'], + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildDetailRow(IconData icon, String text) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + Icon(icon, size: 18, color: getThemeColors(context)['primary']), + const SizedBox(width: 8), + Expanded( + child: Text( + text, + style: TextStyle( + fontSize: 14, + color: getThemeColors(context)['textPrimary'], + ), + ), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Nearby Trees'), + backgroundColor: getThemeColors(context)['primary'], + ), + body: Stack( + children: [ + // Map + if (!_isLoading && !_hasError) + FlutterMap( + mapController: _mapController, + options: MapOptions( + initialCenter: LatLng(_userLat ?? _defaultLat, _userLng ?? _defaultLng), + initialZoom: 13.0, + minZoom: 3.0, + maxZoom: 18.0, + interactionOptions: const InteractionOptions( + flags: InteractiveFlag.all, + ), + ), + children: [ + TileLayer( + urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: 'com.stability.nexus.tree_planting_protocol', + ), + MarkerLayer( + markers: [ + if (_buildUserMarker() != null) _buildUserMarker()!, + ..._buildTreeMarkers(), + ], + ), + ], + ), + + // Loading state + if (_isLoading) + Container( + color: Colors.white, + child: const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Loading nearby trees...'), + ], + ), + ), + ), + + // Error state + if (_hasError) + Container( + color: Colors.white, + padding: const EdgeInsets.all(20), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, size: 60, color: Colors.red[300]), + const SizedBox(height: 16), + const Text( + 'Failed to load location', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Text( + _errorMessage ?? 'Unknown error', + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 14), + ), + const SizedBox(height: 20), + ElevatedButton.icon( + onPressed: _loadNearbyTrees, + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + ), + ], + ), + ), + ), + + // Floating controls + if (!_isLoading && !_hasError) + Positioned( + right: 16, + bottom: 80, + child: Column( + children: [ + // Recenter button + FloatingActionButton( + heroTag: 'recenter', + onPressed: _recenterMap, + backgroundColor: Colors.white, + child: const Icon(Icons.my_location, color: Colors.blue), + ), + const SizedBox(height: 12), + // Refresh button + FloatingActionButton( + heroTag: 'refresh', + onPressed: _loadNearbyTrees, + backgroundColor: Colors.white, + child: const Icon(Icons.refresh, color: Colors.green), + ), + ], + ), + ), + + // Tree count badge + if (!_isLoading && !_hasError && _nearbyTrees.isNotEmpty) + Positioned( + top: 16, + left: 16, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 4, + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.park, color: Colors.green, size: 20), + const SizedBox(width: 8), + Text( + '${_nearbyTrees.length} trees nearby', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/tree_details_page.dart b/lib/pages/tree_details_page.dart index bd10eea..07fad00 100644 --- a/lib/pages/tree_details_page.dart +++ b/lib/pages/tree_details_page.dart @@ -192,7 +192,7 @@ class _TreeDetailsPageState extends State { lat: (treeDetails!.latitude / 1e6) - 90.0, // Data stored on the contract is positive in all cases (needs to be converted) lng: (treeDetails!.longitude / 1e6) - - 180.0, // Data stored on the contract is positive in all cases (needs to be converted) + 90.0, // Data stored on the contract is positive in all cases (needs to be converted) ), ), ); @@ -243,7 +243,7 @@ class _TreeDetailsPageState extends State { ), child: Center( child: Text( - ((treeDetails!.longitude / 1e6) - 180.0) + ((treeDetails!.longitude / 1e6) - 90.0) .toStringAsFixed(6), textAlign: TextAlign.center, style: TextStyle( @@ -908,7 +908,7 @@ class _VerificationModalState extends State<_VerificationModal> { hintText: "Describe your verification (e.g., tree health, location accuracy, etc.)", hintStyle: TextStyle( - color: getThemeColors(context)['textSecondary'], + color: Colors.grey.shade600, ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), diff --git a/lib/pages/trees_page.dart b/lib/pages/trees_page.dart index 61a1ae8..257168e 100644 --- a/lib/pages/trees_page.dart +++ b/lib/pages/trees_page.dart @@ -6,6 +6,7 @@ import 'package:tree_planting_protocol/utils/constants/ui/color_constants.dart'; import 'package:tree_planting_protocol/utils/constants/ui/dimensions.dart'; import 'package:tree_planting_protocol/widgets/nft_display_utils/recent_trees_widget.dart'; import 'package:tree_planting_protocol/providers/wallet_provider.dart'; +import 'package:tree_planting_protocol/pages/nearby_trees_map_page.dart'; class AllTreesPage extends StatefulWidget { const AllTreesPage({super.key}); @@ -52,6 +53,37 @@ class _AllTreesPageState extends State { ), ), const Spacer(), + // Map button for nearby trees + ElevatedButton.icon( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const NearbyTreesMapPage(), + ), + ); + }, + icon: const Icon(Icons.map, size: 20), + label: const Text( + 'Map', + style: TextStyle(fontWeight: FontWeight.bold), + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green[600], + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(buttonCircularRadius), + ), + side: const BorderSide(color: Colors.black, width: 2), + elevation: buttonBlurRadius, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + ), + ), + const SizedBox(width: 12), + // Mint NFT button ElevatedButton.icon( onPressed: () { context.push('/mint-nft'); diff --git a/lib/services/tree_clustering_service.dart b/lib/services/tree_clustering_service.dart new file mode 100644 index 0000000..160edf4 --- /dev/null +++ b/lib/services/tree_clustering_service.dart @@ -0,0 +1,143 @@ +import 'package:latlong2/latlong.dart'; +import 'package:tree_planting_protocol/utils/geohash_utils.dart'; + +/// Represents a cluster of nearby trees +class TreeCluster { + final LatLng center; + final int count; + final List> trees; + final LatLngBounds bounds; + + TreeCluster({ + required this.center, + required this.count, + required this.trees, + required this.bounds, + }); + + /// Get a representative tree from the cluster (for displaying info) + Map? get representativeTree => trees.isNotEmpty ? trees.first : null; +} + +/// Service for clustering trees based on proximity and zoom level +class TreeClusteringService { + /// Cluster trees based on zoom level + /// + /// [trees]: List of trees to cluster + /// [zoomLevel]: Current map zoom level (3-18) + /// [minClusterSize]: Minimum trees to form a cluster (default: 2) + /// + /// Returns list of clusters. Each cluster contains all trees in that area. + static List clusterTrees( + List> trees, + double zoomLevel, { + int minClusterSize = 2, + }) { + if (trees.isEmpty) return []; + + // At high zoom levels (14+), don't cluster - show individual trees + if (zoomLevel >= 14) { + return trees.map((tree) { + final lat = _convertCoordinate(tree['latitude'] as int); + final lng = _convertCoordinate(tree['longitude'] as int); + return TreeCluster( + center: LatLng(lat, lng), + count: 1, + trees: [tree], + bounds: LatLngBounds(LatLng(lat, lng), LatLng(lat, lng)), + ); + }).toList(); + } + + // Calculate cluster distance based on zoom level + final clusterDistanceKm = _getClusterDistance(zoomLevel); + + // Group trees by geohash at appropriate precision + final precision = GeohashUtils.getPrecisionForRadius(clusterDistanceKm); + final Map>> geohashGroups = {}; + + for (final tree in trees) { + final lat = _convertCoordinate(tree['latitude'] as int); + final lng = _convertCoordinate(tree['longitude'] as int); + final geohash = GeohashUtils.encode(lat, lng, precision: precision); + + geohashGroups.putIfAbsent(geohash, () => []); + geohashGroups[geohash]!.add(tree); + } + + // Create clusters from groups + final clusters = []; + + for (final entry in geohashGroups.entries) { + final treesInGroup = entry.value; + + // Only create cluster if we have enough trees + if (treesInGroup.length >= minClusterSize) { + final bounds = _calculateBounds(treesInGroup); + final center = LatLng( + (bounds.north + bounds.south) / 2, + (bounds.east + bounds.west) / 2, + ); + + clusters.add(TreeCluster( + center: center, + count: treesInGroup.length, + trees: treesInGroup, + bounds: bounds, + )); + } else { + // Too few trees, add individually + for (final tree in treesInGroup) { + final lat = _convertCoordinate(tree['latitude'] as int); + final lng = _convertCoordinate(tree['longitude'] as int); + clusters.add(TreeCluster( + center: LatLng(lat, lng), + count: 1, + trees: [tree], + bounds: LatLngBounds(LatLng(lat, lng), LatLng(lat, lng)), + )); + } + } + } + + return clusters; + } + + /// Get cluster distance based on zoom level + /// + /// Lower zoom = larger clusters (more km between centers) + /// Higher zoom = smaller clusters (less km between centers) + static double _getClusterDistance(double zoom) { + if (zoom <= 5) return 100.0; // 100km clusters + if (zoom <= 7) return 50.0; // 50km clusters + if (zoom <= 9) return 20.0; // 20km clusters + if (zoom <= 11) return 5.0; // 5km clusters + if (zoom <= 13) return 1.0; // 1km clusters + return 0.2; // 200m clusters + } + + /// Calculate bounding box for a list of trees + static LatLngBounds _calculateBounds(List> trees) { + double? north, south, east, west; + + for (final tree in trees) { + final lat = _convertCoordinate(tree['latitude'] as int); + final lng = _convertCoordinate(tree['longitude'] as int); + + north = north == null ? lat : (lat > north ? lat : north); + south = south == null ? lat : (lat < south ? lat : south); + east = east == null ? lng : (lng > east ? lng : east); + west = west == null ? lng : (lng < west ? lng : west); + } + + return LatLngBounds( + LatLng(south!, west!), + LatLng(north!, east!), + ); + } + + /// Convert contract fixed-point coordinate to decimal degrees + static double _convertCoordinate(int coordinate) { + return (coordinate / 1000000.0) - 90.0; + } +} diff --git a/lib/utils/geohash_utils.dart b/lib/utils/geohash_utils.dart new file mode 100644 index 0000000..63ac20b --- /dev/null +++ b/lib/utils/geohash_utils.dart @@ -0,0 +1,223 @@ +import 'dart:math'; +import 'package:latlong2/latlong.dart'; + +/// Geohash utilities for spatial indexing and proximity searches +/// +/// Geohash is a hierarchical spatial data structure that subdivides space +/// into buckets of grid shape, providing a fast way to find nearby points. +class GeohashUtils { + // Base32 character set for geohash encoding + static const String _base32 = '0123456789bcdefghjkmnpqrstuvwxyz'; + + /// Encode latitude and longitude into a geohash string + /// + /// [lat]: Latitude (-90 to 90) + /// [lng]: Longitude (-180 to 180) + /// [precision]: Number of characters in geohash (default: 7) + /// - 1: ±2500 km + /// - 3: ±156 km + /// - 5: ±2.4 km + /// - 7: ±76 m (good for nearby trees) + /// - 9: ±2 m + static String encode(double lat, double lng, {int precision = 7}) { + final latRange = [-90.0, 90.0]; + final lngRange = [-180.0, 180.0]; + final geohash = StringBuffer(); + var isEven = true; + var bit = 0; + var ch = 0; + + while (geohash.length < precision) { + if (isEven) { + // Longitude + final mid = (lngRange[0] + lngRange[1]) / 2; + if (lng > mid) { + ch |= (1 << (4 - bit)); + lngRange[0] = mid; + } else { + lngRange[1] = mid; + } + } else { + // Latitude + final mid = (latRange[0] + latRange[1]) / 2; + if (lat > mid) { + ch |= (1 << (4 - bit)); + latRange[0] = mid; + } else { + latRange[1] = mid; + } + } + + isEven = !isEven; + + if (bit < 4) { + bit++; + } else { + geohash.write(_base32[ch]); + bit = 0; + ch = 0; + } + } + + return geohash.toString(); + } + + /// Decode a geohash string into latitude and longitude + /// + /// Returns the center point of the geohash cell + static LatLng decode(String geohash) { + final latRange = [-90.0, 90.0]; + final lngRange = [-180.0, 180.0]; + var isEven = true; + + for (var i = 0; i < geohash.length; i++) { + final char = geohash[i]; + final cd = _base32.indexOf(char); + + for (var j = 0; j < 5; j++) { + final mask = 1 << (4 - j); + + if (isEven) { + // Longitude + if (cd & mask != 0) { + lngRange[0] = (lngRange[0] + lngRange[1]) / 2; + } else { + lngRange[1] = (lngRange[0] + lngRange[1]) / 2; + } + } else { + // Latitude + if (cd & mask != 0) { + latRange[0] = (latRange[0] + latRange[1]) / 2; + } else { + latRange[1] = (latRange[0] + latRange[1]) / 2; + } + } + + isEven = !isEven; + } + } + + final lat = (latRange[0] + latRange[1]) / 2; + final lng = (lngRange[0] + lngRange[1]) / 2; + + return LatLng(lat, lng); + } + + /// Get the 8 neighboring geohashes (N, NE, E, SE, S, SW, W, NW) + /// + /// Useful for finding trees in adjacent grid cells + static List getNeighbors(String geohash) { + if (geohash.isEmpty) return []; + + return [ + _getNeighbor(geohash, 'top'), + _getNeighbor(geohash, 'right'), + _getNeighbor(geohash, 'bottom'), + _getNeighbor(geohash, 'left'), + _getNeighbor(_getNeighbor(geohash, 'top'), 'right'), + _getNeighbor(_getNeighbor(geohash, 'bottom'), 'right'), + _getNeighbor(_getNeighbor(geohash, 'bottom'), 'left'), + _getNeighbor(_getNeighbor(geohash, 'top'), 'left'), + ]; + } + + /// Get all geohashes that cover a circular area + /// + /// [centerLat]: Center latitude + /// [centerLng]: Center longitude + /// [radiusKm]: Radius in kilometers + /// + /// Returns list of geohashes (including center and neighbors) + static List getCoverageGeohashes( + double centerLat, + double centerLng, + double radiusKm, + ) { + final precision = getPrecisionForRadius(radiusKm); + final centerGeohash = encode(centerLat, centerLng, precision: precision); + final neighbors = getNeighbors(centerGeohash); + + return [centerGeohash, ...neighbors]; + } + + /// Calculate optimal geohash precision for a given radius + /// + /// Returns number of characters needed to cover the area efficiently + static int getPrecisionForRadius(double radiusKm) { + // Geohash precision vs approximate dimensions + // 1: ±2500 km + // 2: ±630 km + // 3: ±78 km + // 4: ±20 km + // 5: ±2.4 km + // 6: ±0.61 km + // 7: ±0.076 km (76m) + // 8: ±0.019 km (19m) + // 9: ±0.0024 km (2.4m) + + if (radiusKm > 630) return 1; + if (radiusKm > 78) return 2; + if (radiusKm > 20) return 3; + if (radiusKm > 5) return 4; + if (radiusKm > 1) return 5; + if (radiusKm > 0.2) return 6; + if (radiusKm > 0.05) return 7; + if (radiusKm > 0.01) return 8; + return 9; + } + + /// Calculate distance between two points using Haversine formula + /// + /// Returns distance in kilometers + static double calculateDistance( + double lat1, + double lng1, + double lat2, + double lng2, + ) { + const earthRadius = 6371.0; // km + + final dLat = _toRadians(lat2 - lat1); + final dLng = _toRadians(lng2 - lng1); + + final a = sin(dLat / 2) * sin(dLat / 2) + + cos(_toRadians(lat1)) * cos(_toRadians(lat2)) * + sin(dLng / 2) * sin(dLng / 2); + + final c = 2 * atan2(sqrt(a), sqrt(1 - a)); + + return earthRadius * c; + } + + static double _toRadians(double degrees) => degrees * pi / 180; + + /// Internal helper to get neighbor geohash + static String _getNeighbor(String geohash, String direction) { + if (geohash.isEmpty) return ''; + + final lastChar = geohash[geohash.length - 1]; + final parent = geohash.substring(0, geohash.length - 1); + final type = geohash.length % 2 == 0 ? 'even' : 'odd'; + + // Neighbor lookup tables + final neighbors = { + 'right': {'even': 'bc01fg45238967deuvhjyznpkmstqrwx', 'odd': 'p0r21436x8zb9dcf5h7kjnmqesgutwvy'}, + 'left': {'even': '238967debc01fg45kmstqrwxuvhjyznp', 'odd': '14365h7k9dcfesgujnmqp0r2twvyx8zb'}, + 'top': {'even': 'p0r21436x8zb9dcf5h7kjnmqesgutwvy', 'odd': 'bc01fg45238967deuvhjyznpkmstqrwx'}, + 'bottom': {'even': '14365h7k9dcfesgujnmqp0r2twvyx8zb', 'odd': '238967debc01fg45kmstqrwxuvhjyznp'}, + }; + + final borders = { + 'right': {'even': 'bcfguvyz', 'odd': 'prxz'}, + 'left': {'even': '0145hjnp', 'odd': '028b'}, + 'top': {'even': 'prxz', 'odd': 'bcfguvyz'}, + 'bottom': {'even': '028b', 'odd': '0145hjnp'}, + }; + + if (borders[direction]![type]!.contains(lastChar) && parent.isNotEmpty) { + return _getNeighbor(parent, direction) + _base32[neighbors[direction]![type]!.indexOf(lastChar)]; + } + + return parent + _base32[neighbors[direction]![type]!.indexOf(lastChar)]; + } +} diff --git a/lib/utils/services/contract_functions/tree_nft_contract/tree_nft_contract_read_services.dart b/lib/utils/services/contract_functions/tree_nft_contract/tree_nft_contract_read_services.dart index 7df8a6c..3a6c9fc 100644 --- a/lib/utils/services/contract_functions/tree_nft_contract/tree_nft_contract_read_services.dart +++ b/lib/utils/services/contract_functions/tree_nft_contract/tree_nft_contract_read_services.dart @@ -3,6 +3,8 @@ import 'package:web3dart/web3dart.dart'; import 'package:tree_planting_protocol/providers/wallet_provider.dart'; import 'package:tree_planting_protocol/utils/logger.dart'; import 'package:tree_planting_protocol/utils/constants/contract_abis/tree_nft_contract_details.dart'; +import 'package:tree_planting_protocol/utils/mock_data/mock_trees.dart'; +import 'package:tree_planting_protocol/utils/geohash_utils.dart'; class ContractReadResult { final bool success; @@ -381,4 +383,128 @@ class ContractReadFunctions { ); } } + + /// Get trees near a specific location using geohash-based spatial indexing + /// + /// [centerLat]: Latitude of center point + /// [centerLng]: Longitude of center point + /// [radiusKm]: Search radius in kilometers (default: 5km) + /// [limit]: Maximum number of trees to return (default: 100) + /// + /// Uses client-side geohash filtering for efficient spatial queries + static Future getNearbyTrees({ + required WalletProvider walletProvider, + required double centerLat, + required double centerLng, + double radiusKm = 5.0, + int limit = 100, + }) async { + try { + if (!walletProvider.isConnected) { + return ContractReadResult.error( + errorMessage: 'Please connect your wallet first', + ); + } + + // Step 1: Calculate geohash coverage for the search area + final coverageGeohashes = GeohashUtils.getCoverageGeohashes( + centerLat, + centerLng, + radiusKm, + ); + + logger.i("Searching for trees in geohashes: $coverageGeohashes"); + + // Step 2: Fetch all trees from blockchain + // TODO: When contract supports geohash queries, only fetch trees in coverage area + // For now, fetch recent trees and filter client-side + final allTreesResult = await getRecentTreesPaginated( + walletProvider: walletProvider, + offset: 0, + limit: 50, // Contract maximum is 50 trees per query + ); + + if (!allTreesResult.success || allTreesResult.data == null) { + return ContractReadResult.error( + errorMessage: allTreesResult.errorMessage ?? 'Failed to fetch trees', + ); + } + + // Extract trees list from result data + final resultData = allTreesResult.data as Map; + final allTrees = resultData['trees'] as List>; + + if (allTrees.isEmpty) { + logger.i("No trees found in blockchain"); + return ContractReadResult.success( + data: { + 'trees': [], + 'totalCount': 0, + 'centerLat': centerLat, + 'centerLng': centerLng, + 'radiusKm': radiusKm, + }, + ); + } + + // Step 3: Filter trees by distance and geohash + final nearbyTrees = >[]; + + for (final tree in allTrees) { + // Convert contract coordinates to decimal degrees + final treeLat = _convertCoordinate(tree['latitude'] as int); + final treeLng = _convertCoordinate(tree['longitude'] as int); + + // Calculate distance from center + final distance = GeohashUtils.calculateDistance( + centerLat, + centerLng, + treeLat, + treeLng, + ); + + // Check if tree is within radius + if (distance <= radiusKm) { + // Add distance to tree data + final treeWithDistance = Map.from(tree); + treeWithDistance['distanceKm'] = distance; + treeWithDistance['distanceMeters'] = (distance * 1000).round(); + nearbyTrees.add(treeWithDistance); + } + + // Stop if we've found enough trees + if (nearbyTrees.length >= limit) break; + } + + // Step 4: Sort by distance (closest first) + nearbyTrees.sort((a, b) { + final distA = a['distanceKm'] as double; + final distB = b['distanceKm'] as double; + return distA.compareTo(distB); + }); + + logger.i("Found ${nearbyTrees.length} trees within ${radiusKm}km"); + + return ContractReadResult.success( + data: { + 'trees': nearbyTrees, + 'totalCount': nearbyTrees.length, + 'centerLat': centerLat, + 'centerLng': centerLng, + 'radiusKm': radiusKm, + 'searchedGeohashes': coverageGeohashes, + }, + ); + } catch (e) { + logger.e("Error fetching nearby trees", error: e); + return ContractReadResult.error( + errorMessage: 'Failed to fetch nearby trees: ${e.toString()}', + ); + } + } + + /// Convert contract fixed-point coordinate to decimal degrees + static double _convertCoordinate(int coordinate) { + return (coordinate / 1000000.0) - 90.0; + } } diff --git a/lib/widgets/nft_display_utils/recent_trees_widget.dart b/lib/widgets/nft_display_utils/recent_trees_widget.dart index 426c65e..95f7d4c 100644 --- a/lib/widgets/nft_display_utils/recent_trees_widget.dart +++ b/lib/widgets/nft_display_utils/recent_trees_widget.dart @@ -372,12 +372,7 @@ class _RecentTreesWidgetState extends State { Expanded( child: ElevatedButton( onPressed: () { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: const Text('Map view coming soon!'), - backgroundColor: getThemeColors(context)['secondary'], - ), - ); + context.push('/nearby-trees'); }, style: ElevatedButton.styleFrom( backgroundColor: getThemeColors(context)['secondary'], diff --git a/lib/widgets/nft_display_utils/user_nfts_widget.dart b/lib/widgets/nft_display_utils/user_nfts_widget.dart index 9a1075f..7b6184e 100644 --- a/lib/widgets/nft_display_utils/user_nfts_widget.dart +++ b/lib/widgets/nft_display_utils/user_nfts_widget.dart @@ -252,7 +252,7 @@ class _UserNftsWidgetState extends State { const SizedBox(width: 4), Expanded( child: Text( - 'Location: ${(tree.latitude / 1000000) - 90}, ${(tree.longitude / 1000000) - 180}', + 'Location: ${(tree.latitude / 1000000) - 90}, ${(tree.longitude / 1000000) - 90}', style: TextStyle( color: getThemeColors(context)['textPrimary']), ),