From dc4c87a1f4a7a552665ad651a4e71456334bb80e Mon Sep 17 00:00:00 2001 From: Kartikey Gupta Date: Sat, 13 Dec 2025 21:51:41 +0530 Subject: [PATCH 1/8] feat: Add dynamic tree map with geohash-based efficient fetching - Add ExploreTreesMapPage with interactive flutter_map integration - Implement GeohashService for efficient spatial queries and tree clustering - Add TreeMapService for blockchain data fetching with geohash caching - Create NearbyTreesWidget showing trees around user's location - Add MapFilterWidget with species, status, and date filters - Add MapSearchWidget for searching trees by ID, species, or coordinates - Add TreeHeatmapLayer for density visualization - Update home page with quick actions and nearby trees section - Update trees page with 'Explore Map' button - Add route for /explore-map Features: - Geohash-based spatial indexing for efficient tree queries - Tree clustering at lower zoom levels for better performance - Real-time location tracking with user position marker - Filter trees by alive/deceased status, species, care count - Search by tree ID, species name, geohash, or coordinates - Responsive tree detail panel with quick navigation - Pagination support for loading large datasets - Cache management for optimized data fetching --- lib/main.dart | 8 + lib/pages/explore_trees_map_page.dart | 1027 +++++++++++++++++ lib/pages/home_page.dart | 113 ++ lib/pages/trees_page.dart | 103 +- lib/services/geohash_service.dart | 158 +++ lib/services/tree_map_service.dart | 316 +++++ lib/utils/constants/route_constants.dart | 2 + .../map_widgets/map_filter_widget.dart | 426 +++++++ .../map_widgets/map_search_widget.dart | 345 ++++++ .../map_widgets/nearby_trees_widget.dart | 415 +++++++ .../map_widgets/tree_heatmap_layer.dart | 186 +++ .../recent_trees_widget.dart | 8 +- 12 files changed, 3067 insertions(+), 40 deletions(-) create mode 100644 lib/pages/explore_trees_map_page.dart create mode 100644 lib/services/geohash_service.dart create mode 100644 lib/services/tree_map_service.dart create mode 100644 lib/widgets/map_widgets/map_filter_widget.dart create mode 100644 lib/widgets/map_widgets/map_search_widget.dart create mode 100644 lib/widgets/map_widgets/nearby_trees_widget.dart create mode 100644 lib/widgets/map_widgets/tree_heatmap_layer.dart diff --git a/lib/main.dart b/lib/main.dart index d519938..191e8a0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,7 @@ import 'package:provider/provider.dart'; import 'package:go_router/go_router.dart'; import 'package:tree_planting_protocol/pages/home_page.dart'; +import 'package:tree_planting_protocol/pages/explore_trees_map_page.dart'; import 'package:tree_planting_protocol/pages/mint_nft/mint_nft_details.dart'; import 'package:tree_planting_protocol/pages/mint_nft/mint_nft_images.dart'; import 'package:tree_planting_protocol/pages/mint_nft/mint_nft_organisation.dart'; @@ -68,6 +69,13 @@ class MyApp extends StatelessWidget { return const SettingsPage(); }, ), + GoRoute( + path: RouteConstants.exploreMapPath, + name: RouteConstants.exploreMap, + builder: (BuildContext context, GoRouterState state) { + return const ExploreTreesMapPage(); + }, + ), GoRoute( path: '/organisations', name: 'organisations_page', diff --git a/lib/pages/explore_trees_map_page.dart b/lib/pages/explore_trees_map_page.dart new file mode 100644 index 0000000..eda3ae3 --- /dev/null +++ b/lib/pages/explore_trees_map_page.dart @@ -0,0 +1,1027 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:go_router/go_router.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/services/geohash_service.dart'; +import 'package:tree_planting_protocol/services/tree_map_service.dart'; +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/utils/logger.dart'; +import 'package:tree_planting_protocol/utils/services/get_current_location.dart'; +import 'package:tree_planting_protocol/widgets/basic_scaffold.dart'; +import 'package:tree_planting_protocol/widgets/map_widgets/map_filter_widget.dart'; +import 'package:tree_planting_protocol/widgets/map_widgets/map_search_widget.dart'; + +class ExploreTreesMapPage extends StatefulWidget { + const ExploreTreesMapPage({super.key}); + + @override + State createState() => _ExploreTreesMapPageState(); +} + +class _ExploreTreesMapPageState extends State { + late MapController _mapController; + final TreeMapService _treeMapService = TreeMapService(); + final GeohashService _geohashService = GeohashService(); + final LocationService _locationService = LocationService(); + + List _clusters = []; + List _visibleTrees = []; + bool _isLoading = true; + bool _isLoadingMore = false; + String? _errorMessage; + LatLng? _userLocation; + double _currentZoom = 10.0; + MapTreeData? _selectedTree; + bool _showTreeDetails = false; + MapFilterOptions _filterOptions = const MapFilterOptions(); + List _availableSpecies = []; + + // Default center (can be changed based on user location) + static const LatLng _defaultCenter = LatLng(28.6139, 77.2090); // Delhi, India + + @override + void initState() { + super.initState(); + _mapController = MapController(); + _initializeMap(); + } + + Future _initializeMap() async { + setState(() => _isLoading = true); + + try { + // Try to get user location + await _getUserLocation(); + + // Load initial trees + await _loadTrees(); + } catch (e) { + logger.e('Error initializing map: $e'); + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + Future _getUserLocation() async { + try { + final locationInfo = await _locationService.getCurrentLocationWithTimeout( + timeout: const Duration(seconds: 10), + ); + + if (locationInfo.isValid && mounted) { + setState(() { + _userLocation = LatLng(locationInfo.latitude!, locationInfo.longitude!); + }); + + // Move map to user location + _mapController.move(_userLocation!, 12.0); + } + } catch (e) { + logger.w('Could not get user location: $e'); + // Use default center + } + } + + Future _loadTrees() async { + final walletProvider = Provider.of(context, listen: false); + + if (!walletProvider.isConnected) { + setState(() { + _errorMessage = 'Please connect your wallet to view trees'; + _isLoading = false; + }); + return; + } + + try { + // Fetch all trees initially + await _treeMapService.fetchAllTrees( + walletProvider: walletProvider, + limit: 100, + ); + + _updateVisibleTrees(); + } catch (e) { + logger.e('Error loading trees: $e'); + if (mounted) { + setState(() { + _errorMessage = 'Failed to load trees: $e'; + }); + } + } + } + + void _updateVisibleTrees() { + if (!mounted) return; + + final bounds = _mapController.camera.visibleBounds; + final zoom = _mapController.camera.zoom; + + // Filter trees in visible bounds + var treesInBounds = _treeMapService.allTrees.where((tree) { + return tree.latitude >= bounds.south && + tree.latitude <= bounds.north && + tree.longitude >= bounds.west && + tree.longitude <= bounds.east; + }).toList(); + + // Apply filters + treesInBounds = _applyFilters(treesInBounds); + + // Update available species for filter dropdown + _updateAvailableSpecies(); + + // Cluster trees based on zoom level + final clusters = _treeMapService.clusterTrees(treesInBounds, zoom); + + setState(() { + _visibleTrees = treesInBounds; + _clusters = clusters; + _currentZoom = zoom; + }); + } + + List _applyFilters(List trees) { + return trees.where((tree) { + // Status filter + if (_filterOptions.showAliveOnly && !tree.isAlive) return false; + if (_filterOptions.showDeceasedOnly && tree.isAlive) return false; + + // Species filter + if (_filterOptions.speciesFilter != null && + tree.species != _filterOptions.speciesFilter) { + return false; + } + + // Care count filter + if (_filterOptions.minCareCount != null && + tree.careCount < _filterOptions.minCareCount!) { + return false; + } + + // Date filters + if (_filterOptions.plantedAfter != null) { + final plantedDate = DateTime.fromMillisecondsSinceEpoch(tree.plantingDate * 1000); + if (plantedDate.isBefore(_filterOptions.plantedAfter!)) return false; + } + + if (_filterOptions.plantedBefore != null) { + final plantedDate = DateTime.fromMillisecondsSinceEpoch(tree.plantingDate * 1000); + if (plantedDate.isAfter(_filterOptions.plantedBefore!)) return false; + } + + return true; + }).toList(); + } + + void _updateAvailableSpecies() { + final species = _treeMapService.allTrees + .map((t) => t.species) + .where((s) => s.isNotEmpty && s != 'Unknown') + .toSet() + .toList() + ..sort(); + + if (_availableSpecies.length != species.length) { + _availableSpecies = species; + } + } + + void _onFilterChanged(MapFilterOptions newOptions) { + setState(() { + _filterOptions = newOptions; + }); + _updateVisibleTrees(); + } + + void _onSearchResultSelected(MapSearchResult result) { + // Move map to the result location + _mapController.move(result.location, result.type == SearchResultType.tree ? 16.0 : 14.0); + + // If it's a tree, show its details + if (result.tree != null) { + setState(() { + _selectedTree = result.tree; + _showTreeDetails = true; + }); + } + } + + Future _onMapMove() async { + _updateVisibleTrees(); + + // Load more trees if needed + if (!_isLoadingMore && _treeMapService.allTrees.length < _treeMapService.totalTreeCount) { + setState(() => _isLoadingMore = true); + + final walletProvider = Provider.of(context, listen: false); + await _treeMapService.fetchAllTrees( + walletProvider: walletProvider, + offset: _treeMapService.allTrees.length, + limit: 50, + ); + + _updateVisibleTrees(); + + if (mounted) { + setState(() => _isLoadingMore = false); + } + } + } + + void _onClusterTap(TreeCluster cluster) { + if (cluster.isSingleTree) { + // Show tree details + setState(() { + _selectedTree = cluster.singleTree; + _showTreeDetails = true; + }); + } else { + // Zoom in to cluster + _mapController.move(cluster.center, _currentZoom + 2); + } + } + + void _centerOnUserLocation() async { + if (_userLocation != null) { + _mapController.move(_userLocation!, 14.0); + } else { + await _getUserLocation(); + } + } + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, walletProvider, child) { + return BaseScaffold( + title: 'Explore Trees', + body: walletProvider.isConnected + ? _buildMapContent(context) + : _buildConnectWalletPrompt(context), + ); + }, + ); + } + + Widget _buildMapContent(BuildContext context) { + return Stack( + children: [ + // Map + FlutterMap( + mapController: _mapController, + options: MapOptions( + initialCenter: _userLocation ?? _defaultCenter, + initialZoom: 10.0, + minZoom: 3.0, + maxZoom: 18.0, + onPositionChanged: (position, hasGesture) { + if (hasGesture) { + _onMapMove(); + } + }, + onMapReady: () { + _updateVisibleTrees(); + }, + ), + children: [ + // OpenStreetMap tiles + TileLayer( + urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: 'tree_planting_protocol', + ), + + // User location marker + if (_userLocation != null) + MarkerLayer( + markers: [ + Marker( + point: _userLocation!, + 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: 2), + ), + child: const Center( + child: Icon(Icons.my_location, color: Colors.blue, size: 20), + ), + ), + ), + ], + ), + + // Tree clusters/markers + MarkerLayer( + markers: _clusters.map((cluster) => _buildClusterMarker(cluster)).toList(), + ), + ], + ), + + // Loading overlay + if (_isLoading) + Container( + color: Colors.black54, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation( + getThemeColors(context)['primary']!, + ), + ), + const SizedBox(height: 16), + Text( + 'Loading trees...', + style: TextStyle( + color: Colors.white, + fontSize: 16, + ), + ), + ], + ), + ), + ), + + // Error message + if (_errorMessage != null) + Positioned( + top: 16, + left: 16, + right: 16, + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: getThemeColors(context)['error'], + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const Icon(Icons.error, color: Colors.white), + const SizedBox(width: 8), + Expanded( + child: Text( + _errorMessage!, + style: const TextStyle(color: Colors.white), + ), + ), + IconButton( + icon: const Icon(Icons.close, color: Colors.white), + onPressed: () => setState(() => _errorMessage = null), + ), + ], + ), + ), + ), + + // Stats overlay + Positioned( + top: 16, + left: 16, + child: _buildStatsCard(context), + ), + + // Filter widget + Positioned( + top: 16, + right: 70, + child: MapFilterWidget( + initialOptions: _filterOptions, + availableSpecies: _availableSpecies, + onFilterChanged: _onFilterChanged, + ), + ), + + // Quick filters bar + if (_filterOptions.hasActiveFilters) + Positioned( + top: 80, + left: 16, + right: 16, + child: QuickFilterBar( + options: _filterOptions, + onFilterChanged: _onFilterChanged, + ), + ), + + // Search widget + Positioned( + bottom: _showTreeDetails ? 300 : 120, + left: 16, + right: 70, + child: MapSearchWidget( + trees: _treeMapService.allTrees, + onResultSelected: _onSearchResultSelected, + ), + ), + + // Control buttons + Positioned( + right: 16, + bottom: _showTreeDetails ? 280 : 100, + child: _buildControlButtons(context), + ), + + // Loading more indicator + if (_isLoadingMore) + Positioned( + bottom: 16, + left: 0, + right: 0, + child: Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: getThemeColors(context)['background'], + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black26, + blurRadius: 4, + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + getThemeColors(context)['primary']!, + ), + ), + ), + const SizedBox(width: 8), + Text( + 'Loading more trees...', + style: TextStyle( + color: getThemeColors(context)['textPrimary'], + fontSize: 12, + ), + ), + ], + ), + ), + ), + ), + + // Tree details panel + if (_showTreeDetails && _selectedTree != null) + Positioned( + bottom: 0, + left: 0, + right: 0, + child: _buildTreeDetailsPanel(context), + ), + ], + ); + } + + Marker _buildClusterMarker(TreeCluster cluster) { + final isSingle = cluster.isSingleTree; + final tree = cluster.singleTree; + final isSelected = _selectedTree?.id == tree?.id; + + return Marker( + point: cluster.center, + width: isSingle ? 50 : 60, + height: isSingle ? 50 : 60, + child: GestureDetector( + onTap: () => _onClusterTap(cluster), + child: isSingle + ? _buildSingleTreeMarker(tree!, isSelected) + : _buildClusterBubble(cluster), + ), + ); + } + + Widget _buildSingleTreeMarker(MapTreeData tree, bool isSelected) { + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + decoration: BoxDecoration( + color: tree.isAlive + ? (isSelected ? Colors.green.shade700 : Colors.green) + : Colors.grey, + shape: BoxShape.circle, + border: Border.all( + color: isSelected ? Colors.white : Colors.green.shade900, + width: isSelected ? 3 : 2, + ), + boxShadow: [ + BoxShadow( + color: Colors.black38, + blurRadius: isSelected ? 8 : 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Center( + child: Icon( + Icons.park, + color: Colors.white, + size: isSelected ? 28 : 24, + ), + ), + ); + } + + Widget _buildClusterBubble(TreeCluster cluster) { + final count = cluster.totalTreeCount; + final color = count > 50 + ? Colors.red + : count > 20 + ? Colors.orange + : Colors.green; + + return Container( + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all(color: color.shade900, width: 2), + boxShadow: [ + BoxShadow( + color: Colors.black38, + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + count.toString(), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + const Icon(Icons.park, color: Colors.white, size: 16), + ], + ), + ), + ); + } + + Widget _buildStatsCard(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: getThemeColors(context)['background']!.withValues(alpha: 0.95), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: getThemeColors(context)['border']!), + boxShadow: [ + BoxShadow( + color: Colors.black26, + blurRadius: 4, + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.eco, + color: getThemeColors(context)['primary'], + size: 20, + ), + const SizedBox(width: 8), + Text( + '${_treeMapService.totalTreeCount} Trees', + style: TextStyle( + fontWeight: FontWeight.bold, + color: getThemeColors(context)['textPrimary'], + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + '${_visibleTrees.length} visible', + style: TextStyle( + fontSize: 12, + color: getThemeColors(context)['textPrimary']!.withValues(alpha: 0.7), + ), + ), + ], + ), + ); + } + + Widget _buildControlButtons(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Zoom in + _buildControlButton( + context, + icon: Icons.add, + onTap: () { + final newZoom = (_currentZoom + 1).clamp(3.0, 18.0); + _mapController.move(_mapController.camera.center, newZoom); + }, + ), + const SizedBox(height: 8), + // Zoom out + _buildControlButton( + context, + icon: Icons.remove, + onTap: () { + final newZoom = (_currentZoom - 1).clamp(3.0, 18.0); + _mapController.move(_mapController.camera.center, newZoom); + }, + ), + const SizedBox(height: 16), + // Center on user location + _buildControlButton( + context, + icon: Icons.my_location, + onTap: _centerOnUserLocation, + color: Colors.blue, + ), + const SizedBox(height: 8), + // Refresh + _buildControlButton( + context, + icon: Icons.refresh, + onTap: () { + _treeMapService.clearCache(); + _initializeMap(); + }, + ), + ], + ); + } + + Widget _buildControlButton( + BuildContext context, { + required IconData icon, + required VoidCallback onTap, + Color? color, + }) { + return Material( + color: getThemeColors(context)['background'], + borderRadius: BorderRadius.circular(8), + elevation: 4, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Container( + width: 44, + height: 44, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: getThemeColors(context)['border']!), + ), + child: Icon( + icon, + color: color ?? getThemeColors(context)['icon'], + ), + ), + ), + ); + } + + Widget _buildTreeDetailsPanel(BuildContext context) { + final tree = _selectedTree!; + + return Container( + decoration: BoxDecoration( + color: getThemeColors(context)['background'], + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + boxShadow: [ + BoxShadow( + color: Colors.black26, + blurRadius: 10, + offset: const Offset(0, -2), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Handle bar + Container( + margin: const EdgeInsets.only(top: 12), + width: 40, + height: 4, + decoration: BoxDecoration( + color: getThemeColors(context)['border'], + borderRadius: BorderRadius.circular(2), + ), + ), + + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + // Tree image + Container( + width: 60, + height: 60, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: getThemeColors(context)['border']!), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: tree.imageUri.isNotEmpty + ? Image.network( + tree.imageUri, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Icon( + Icons.park, + color: getThemeColors(context)['primary'], + size: 30, + ), + ) + : Icon( + Icons.park, + color: getThemeColors(context)['primary'], + size: 30, + ), + ), + ), + const SizedBox(width: 12), + + // Tree info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: getThemeColors(context)['primary'], + borderRadius: BorderRadius.circular(12), + ), + child: Text( + 'ID: ${tree.id}', + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: tree.isAlive ? Colors.green : Colors.grey, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + tree.isAlive ? 'Alive' : 'Deceased', + style: const TextStyle( + color: Colors.white, + fontSize: 12, + ), + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + tree.species, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: getThemeColors(context)['textPrimary'], + ), + ), + ], + ), + ), + + // Close button + IconButton( + icon: Icon( + Icons.close, + color: getThemeColors(context)['icon'], + ), + onPressed: () { + setState(() { + _showTreeDetails = false; + _selectedTree = null; + }); + }, + ), + ], + ), + + const SizedBox(height: 16), + + // Details row + Row( + children: [ + _buildDetailChip( + context, + icon: Icons.location_on, + label: '${tree.latitude.toStringAsFixed(4)}, ${tree.longitude.toStringAsFixed(4)}', + ), + const SizedBox(width: 8), + _buildDetailChip( + context, + icon: Icons.favorite, + label: '${tree.careCount} care', + ), + ], + ), + + const SizedBox(height: 8), + + Row( + children: [ + _buildDetailChip( + context, + icon: Icons.nature, + label: '${tree.numberOfTrees} trees', + ), + const SizedBox(width: 8), + if (tree.geoHash.isNotEmpty) + _buildDetailChip( + context, + icon: Icons.grid_on, + label: tree.geoHash.substring(0, 6), + ), + ], + ), + + const SizedBox(height: 16), + + // Action button + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () { + context.push('/trees/${tree.id}'); + }, + style: ElevatedButton.styleFrom( + backgroundColor: getThemeColors(context)['primary'], + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(buttonCircularRadius), + ), + side: const BorderSide(color: Colors.black, width: 2), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: const Text( + 'View Full Details', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildDetailChip(BuildContext context, {required IconData icon, required String label}) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: getThemeColors(context)['secondary']!.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: getThemeColors(context)['border']!.withValues(alpha: 0.3)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: getThemeColors(context)['textPrimary']), + const SizedBox(width: 4), + Text( + label, + style: TextStyle( + fontSize: 12, + color: getThemeColors(context)['textPrimary'], + ), + ), + ], + ), + ); + } + + Widget _buildConnectWalletPrompt(BuildContext context) { + return Center( + child: Container( + margin: const EdgeInsets.all(24), + padding: const EdgeInsets.all(32), + decoration: BoxDecoration( + color: getThemeColors(context)['background'], + borderRadius: BorderRadius.circular(buttonCircularRadius), + border: Border.all( + color: getThemeColors(context)['border']!, + width: buttonborderWidth, + ), + boxShadow: [ + BoxShadow( + color: Colors.black, + blurRadius: buttonBlurRadius, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.map_outlined, + size: 64, + color: getThemeColors(context)['primary'], + ), + const SizedBox(height: 24), + Text( + 'Connect to Explore', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: getThemeColors(context)['textPrimary'], + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + Text( + 'Connect your wallet to explore trees on the map and discover trees planted around you.', + style: TextStyle( + fontSize: 16, + color: getThemeColors(context)['textPrimary'], + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + ElevatedButton.icon( + onPressed: () async { + final walletProvider = Provider.of( + context, + listen: false, + ); + try { + await walletProvider.connectWallet(); + if (mounted && walletProvider.isConnected) { + _initializeMap(); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to connect: $e'), + backgroundColor: getThemeColors(context)['error'], + ), + ); + } + } + }, + icon: const Icon(Icons.account_balance_wallet), + label: const Text( + 'Connect Wallet', + style: TextStyle(fontWeight: FontWeight.bold), + ), + style: ElevatedButton.styleFrom( + backgroundColor: getThemeColors(context)['primary'], + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(buttonCircularRadius), + ), + side: const BorderSide(color: Colors.black, width: 2), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + ), + ), + ], + ), + ), + ); + } + + @override + void dispose() { + _mapController.dispose(); + super.dispose(); + } +} diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 64438a8..3b8eca7 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -1,13 +1,17 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; import 'package:tree_planting_protocol/providers/wallet_provider.dart'; import 'package:tree_planting_protocol/utils/constants/navbar_constants.dart'; +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/basic_scaffold.dart'; import 'package:tree_planting_protocol/widgets/profile_widgets/profile_section_widget.dart'; import 'package:tree_planting_protocol/widgets/nft_display_utils/user_nfts_widget.dart'; +import 'package:tree_planting_protocol/widgets/map_widgets/nearby_trees_widget.dart'; class HomePage extends StatelessWidget { const HomePage({super.key}); @@ -26,6 +30,33 @@ class HomePage extends StatelessWidget { child: ProfileSectionWidget( userAddress: walletProvider.currentAddress ?? '', )), + + // Quick Actions Section + if (walletProvider.isConnected) ...[ + const SizedBox(height: 16), + _buildQuickActions(context), + const SizedBox(height: 16), + // Nearby Trees Section + Container( + width: double.infinity, + constraints: const BoxConstraints(maxWidth: 500), + margin: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: getThemeColors(context)['background'], + borderRadius: BorderRadius.circular(buttonCircularRadius), + border: Border.all( + color: getThemeColors(context)['border']!, + width: 1, + ), + ), + child: const NearbyTreesWidget( + radiusMeters: 10000, + maxTrees: 8, + ), + ), + ], + + const SizedBox(height: 16), SizedBox( width: 400, height: 600, @@ -40,4 +71,86 @@ class HomePage extends StatelessWidget { ), ); } + + Widget _buildQuickActions(BuildContext context) { + return Container( + width: double.infinity, + constraints: const BoxConstraints(maxWidth: 500), + margin: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Expanded( + child: _buildActionButton( + context, + icon: Icons.map, + label: 'Explore Map', + onTap: () => context.push('/explore-map'), + isPrimary: true, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildActionButton( + context, + icon: Icons.forest, + label: 'All Trees', + onTap: () => context.push('/trees'), + isPrimary: false, + ), + ), + ], + ), + ); + } + + Widget _buildActionButton( + BuildContext context, { + required IconData icon, + required String label, + required VoidCallback onTap, + required bool isPrimary, + }) { + return Material( + color: isPrimary + ? getThemeColors(context)['primary'] + : getThemeColors(context)['secondary'], + borderRadius: BorderRadius.circular(buttonCircularRadius), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(buttonCircularRadius), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(buttonCircularRadius), + border: Border.all( + color: getThemeColors(context)['border']!, + width: buttonborderWidth, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + color: isPrimary + ? Colors.white + : getThemeColors(context)['textPrimary'], + size: 20, + ), + const SizedBox(width: 8), + Text( + label, + style: TextStyle( + fontWeight: FontWeight.bold, + color: isPrimary + ? Colors.white + : getThemeColors(context)['textPrimary'], + ), + ), + ], + ), + ), + ), + ); + } } diff --git a/lib/pages/trees_page.dart b/lib/pages/trees_page.dart index 61a1ae8..ffaeade 100644 --- a/lib/pages/trees_page.dart +++ b/lib/pages/trees_page.dart @@ -35,43 +35,78 @@ class _AllTreesPageState extends State { // Header with Mint NFT Button Container( padding: const EdgeInsets.all(16), - child: Row( + child: Column( children: [ - Icon( - Icons.eco, - size: 28, - color: getThemeColors(context)['primary'], - ), - const SizedBox(width: 8), - Text( - 'Discover Trees', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: getThemeColors(context)['textPrimary'], - ), + Row( + children: [ + Icon( + Icons.eco, + size: 28, + color: getThemeColors(context)['primary'], + ), + const SizedBox(width: 8), + Text( + 'Discover Trees', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: getThemeColors(context)['textPrimary'], + ), + ), + const Spacer(), + ElevatedButton.icon( + onPressed: () { + context.push('/mint-nft'); + }, + icon: const Icon(Icons.add, size: 20), + label: const Text( + 'Mint NFT', + style: TextStyle(fontWeight: FontWeight.bold), + ), + style: ElevatedButton.styleFrom( + backgroundColor: getThemeColors(context)['primary'], + 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 Spacer(), - ElevatedButton.icon( - onPressed: () { - context.push('/mint-nft'); - }, - icon: const Icon(Icons.add, size: 20), - label: const Text( - 'Mint NFT', - style: TextStyle(fontWeight: FontWeight.bold), - ), - style: ElevatedButton.styleFrom( - backgroundColor: getThemeColors(context)['primary'], - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(buttonCircularRadius), + const SizedBox(height: 12), + // Explore Map Button + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () { + context.push('/explore-map'); + }, + icon: const Icon(Icons.map, size: 20), + label: const Text( + 'Explore Trees on Map', + style: TextStyle(fontWeight: FontWeight.bold), ), - side: const BorderSide(color: Colors.black, width: 2), - elevation: buttonBlurRadius, - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, + style: ElevatedButton.styleFrom( + backgroundColor: getThemeColors(context)['secondary'], + foregroundColor: getThemeColors(context)['textPrimary'], + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(buttonCircularRadius), + ), + side: BorderSide( + color: getThemeColors(context)['border']!, + width: buttonborderWidth, + ), + elevation: buttonBlurRadius, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), ), ), ), diff --git a/lib/services/geohash_service.dart b/lib/services/geohash_service.dart new file mode 100644 index 0000000..edae425 --- /dev/null +++ b/lib/services/geohash_service.dart @@ -0,0 +1,158 @@ +import 'package:dart_geohash/dart_geohash.dart'; +import 'package:latlong2/latlong.dart'; + +/// Service for efficient geospatial queries using geohash +class GeohashService { + static final GeohashService _instance = GeohashService._internal(); + factory GeohashService() => _instance; + GeohashService._internal(); + + final GeoHasher _geoHasher = GeoHasher(); + + /// Default precision for geohash (6 = ~1.2km x 0.6km area) + static const int defaultPrecision = 6; + + /// Encode coordinates to geohash + String encode(double latitude, double longitude, {int precision = defaultPrecision}) { + return _geoHasher.encode(longitude, latitude, precision: precision); + } + + /// Decode geohash to coordinates + LatLng decode(String geohash) { + final decoded = _geoHasher.decode(geohash); + return LatLng(decoded[1], decoded[0]); + } + + /// Get bounding box for a geohash + GeohashBounds getBounds(String geohash) { + // Calculate approximate bounds based on geohash precision + final center = decode(geohash); + final precision = geohash.length; + + // Approximate dimensions based on precision + final latDelta = _getLatDelta(precision); + final lngDelta = _getLngDelta(precision); + + return GeohashBounds( + southwest: LatLng(center.latitude - latDelta / 2, center.longitude - lngDelta / 2), + northeast: LatLng(center.latitude + latDelta / 2, center.longitude + lngDelta / 2), + ); + } + + double _getLatDelta(int precision) { + // Approximate latitude span for each precision level + const latDeltas = [180.0, 45.0, 5.6, 1.4, 0.18, 0.022, 0.0027, 0.00068, 0.000085]; + return precision < latDeltas.length ? latDeltas[precision] : 0.00001; + } + + double _getLngDelta(int precision) { + // Approximate longitude span for each precision level + const lngDeltas = [360.0, 45.0, 11.25, 1.4, 0.35, 0.044, 0.0055, 0.00069, 0.000172]; + return precision < lngDeltas.length ? lngDeltas[precision] : 0.00001; + } + + /// Get neighboring geohashes (8 surrounding + center) + List getNeighbors(String geohash) { + final neighbors = [geohash]; + + // Get all 8 neighbors + final directions = ['n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw']; + for (final direction in directions) { + try { + final neighbor = _getNeighbor(geohash, direction); + if (neighbor.isNotEmpty) { + neighbors.add(neighbor); + } + } catch (_) { + // Skip invalid neighbors at edges + } + } + + return neighbors; + } + + String _getNeighbor(String geohash, String direction) { + if (geohash.isEmpty) return ''; + + final center = decode(geohash); + final precision = geohash.length; + final latDelta = _getLatDelta(precision); + final lngDelta = _getLngDelta(precision); + + double newLat = center.latitude; + double newLng = center.longitude; + + if (direction.contains('n')) newLat += latDelta; + if (direction.contains('s')) newLat -= latDelta; + if (direction.contains('e')) newLng += lngDelta; + if (direction.contains('w')) newLng -= lngDelta; + + // Clamp to valid ranges + newLat = newLat.clamp(-90.0, 90.0); + newLng = newLng.clamp(-180.0, 180.0); + + return encode(newLat, newLng, precision: precision); + } + + /// Get geohashes covering a bounding box + List getGeohashesInBounds(LatLng southwest, LatLng northeast, {int precision = defaultPrecision}) { + final geohashes = {}; + + final latStep = _getLatDelta(precision) * 0.8; + final lngStep = _getLngDelta(precision) * 0.8; + + for (double lat = southwest.latitude; lat <= northeast.latitude; lat += latStep) { + for (double lng = southwest.longitude; lng <= northeast.longitude; lng += lngStep) { + geohashes.add(encode(lat, lng, precision: precision)); + } + } + + return geohashes.toList(); + } + + /// Calculate optimal precision based on zoom level + int getPrecisionForZoom(double zoom) { + if (zoom >= 18) return 8; + if (zoom >= 16) return 7; + if (zoom >= 14) return 6; + if (zoom >= 12) return 5; + if (zoom >= 10) return 4; + if (zoom >= 8) return 3; + if (zoom >= 6) return 2; + return 1; + } + + /// Check if a geohash starts with any of the given prefixes + bool matchesAnyPrefix(String geohash, List prefixes) { + for (final prefix in prefixes) { + if (geohash.startsWith(prefix)) return true; + } + return false; + } + + /// Calculate distance between two points in meters + double calculateDistance(LatLng point1, LatLng point2) { + const Distance distance = Distance(); + return distance.as(LengthUnit.Meter, point1, point2); + } +} + +/// Represents bounds of a geohash area +class GeohashBounds { + final LatLng southwest; + final LatLng northeast; + + GeohashBounds({required this.southwest, required this.northeast}); + + LatLng get center => LatLng( + (southwest.latitude + northeast.latitude) / 2, + (southwest.longitude + northeast.longitude) / 2, + ); + + bool contains(LatLng point) { + return point.latitude >= southwest.latitude && + point.latitude <= northeast.latitude && + point.longitude >= southwest.longitude && + point.longitude <= northeast.longitude; + } +} diff --git a/lib/services/tree_map_service.dart b/lib/services/tree_map_service.dart new file mode 100644 index 0000000..610e915 --- /dev/null +++ b/lib/services/tree_map_service.dart @@ -0,0 +1,316 @@ +import 'package:latlong2/latlong.dart'; +import 'package:tree_planting_protocol/providers/wallet_provider.dart'; +import 'package:tree_planting_protocol/services/geohash_service.dart'; +import 'package:tree_planting_protocol/utils/logger.dart'; +import 'package:tree_planting_protocol/utils/services/contract_functions/tree_nft_contract/tree_nft_contract_read_services.dart'; + +/// Model for tree data displayed on map +class MapTreeData { + final int id; + final double latitude; + final double longitude; + final String species; + final String imageUri; + final String geoHash; + final bool isAlive; + final int careCount; + final int plantingDate; + final int numberOfTrees; + + MapTreeData({ + required this.id, + required this.latitude, + required this.longitude, + required this.species, + required this.imageUri, + required this.geoHash, + required this.isAlive, + required this.careCount, + required this.plantingDate, + required this.numberOfTrees, + }); + + LatLng get position => LatLng(latitude, longitude); + + factory MapTreeData.fromContractData(Map data) { + final lat = _convertCoordinate(data['latitude'] ?? 0); + final lng = _convertCoordinate(data['longitude'] ?? 0); + final death = data['death'] ?? 0; + final isAlive = death == 0 || death > DateTime.now().millisecondsSinceEpoch ~/ 1000; + + return MapTreeData( + id: data['id'] ?? 0, + latitude: lat, + longitude: lng, + species: data['species'] ?? 'Unknown', + imageUri: data['imageUri'] ?? '', + geoHash: data['geoHash'] ?? '', + isAlive: isAlive, + careCount: data['careCount'] ?? 0, + plantingDate: data['planting'] ?? 0, + numberOfTrees: data['numberOfTrees'] ?? 1, + ); + } + + static double _convertCoordinate(int coordinate) { + return (coordinate / 1000000.0) - 90.0; + } +} + +/// Cluster of trees for efficient rendering +class TreeCluster { + final LatLng center; + final List trees; + final String geohash; + + TreeCluster({ + required this.center, + required this.trees, + required this.geohash, + }); + + int get count => trees.length; + int get totalTreeCount => trees.fold(0, (sum, tree) => sum + tree.numberOfTrees); + + bool get isSingleTree => trees.length == 1; + MapTreeData? get singleTree => isSingleTree ? trees.first : null; +} + +/// Service for fetching and managing tree data for map display +class TreeMapService { + static final TreeMapService _instance = TreeMapService._internal(); + factory TreeMapService() => _instance; + TreeMapService._internal(); + + final GeohashService _geohashService = GeohashService(); + + // Cache for loaded trees by geohash + final Map> _treeCache = {}; + final Set _loadingGeohashes = {}; + + // All loaded trees + List _allTrees = []; + int _totalTreeCount = 0; + bool _hasMore = true; + + List get allTrees => _allTrees; + int get totalTreeCount => _totalTreeCount; + + /// Clear all cached data + void clearCache() { + _treeCache.clear(); + _loadingGeohashes.clear(); + _allTrees.clear(); + _totalTreeCount = 0; + _hasMore = true; + } + + /// Fetch trees for visible map area using geohash-based queries + Future> fetchTreesInBounds({ + required WalletProvider walletProvider, + required LatLng southwest, + required LatLng northeast, + required double zoom, + }) async { + try { + // Calculate optimal precision based on zoom + final precision = _geohashService.getPrecisionForZoom(zoom); + + // Get geohashes covering the visible area + final geohashes = _geohashService.getGeohashesInBounds( + southwest, + northeast, + precision: precision, + ); + + logger.d('Fetching trees for ${geohashes.length} geohashes at precision $precision'); + + // Filter trees from cache that match visible geohashes + final visibleTrees = []; + final geohashesToFetch = []; + + for (final geohash in geohashes) { + if (_treeCache.containsKey(geohash)) { + visibleTrees.addAll(_treeCache[geohash]!); + } else if (!_loadingGeohashes.contains(geohash)) { + geohashesToFetch.add(geohash); + } + } + + // Fetch new geohashes if needed + if (geohashesToFetch.isNotEmpty && _hasMore) { + await _fetchTreesFromBlockchain( + walletProvider: walletProvider, + geohashes: geohashesToFetch, + ); + + // Add newly fetched trees + for (final geohash in geohashesToFetch) { + if (_treeCache.containsKey(geohash)) { + visibleTrees.addAll(_treeCache[geohash]!); + } + } + } + + // Filter to only trees within bounds + return visibleTrees.where((tree) { + return tree.latitude >= southwest.latitude && + tree.latitude <= northeast.latitude && + tree.longitude >= southwest.longitude && + tree.longitude <= northeast.longitude; + }).toList(); + } catch (e) { + logger.e('Error fetching trees in bounds: $e'); + return []; + } + } + + /// Fetch all trees with pagination (for initial load) + Future> fetchAllTrees({ + required WalletProvider walletProvider, + int offset = 0, + int limit = 50, + }) async { + try { + final result = await ContractReadFunctions.getRecentTreesPaginated( + walletProvider: walletProvider, + offset: offset, + limit: limit, + ); + + if (result.success && result.data != null) { + final treesData = result.data['trees'] as List? ?? []; + _totalTreeCount = result.data['totalCount'] ?? 0; + _hasMore = result.data['hasMore'] ?? false; + + final newTrees = treesData + .map((data) => MapTreeData.fromContractData(data as Map)) + .toList(); + + // Add to cache by geohash + for (final tree in newTrees) { + final geohash = tree.geoHash.isNotEmpty + ? tree.geoHash.substring(0, GeohashService.defaultPrecision.clamp(1, tree.geoHash.length)) + : _geohashService.encode(tree.latitude, tree.longitude); + + _treeCache.putIfAbsent(geohash, () => []); + if (!_treeCache[geohash]!.any((t) => t.id == tree.id)) { + _treeCache[geohash]!.add(tree); + } + } + + if (offset == 0) { + _allTrees = newTrees; + } else { + _allTrees.addAll(newTrees); + } + + logger.d('Fetched ${newTrees.length} trees, total: ${_allTrees.length}'); + return newTrees; + } + + return []; + } catch (e) { + logger.e('Error fetching all trees: $e'); + return []; + } + } + + Future _fetchTreesFromBlockchain({ + required WalletProvider walletProvider, + required List geohashes, + }) async { + // Mark geohashes as loading + _loadingGeohashes.addAll(geohashes); + + try { + // For now, we fetch all trees and filter by geohash + // In a production app, you'd have a backend that indexes by geohash + if (_allTrees.isEmpty) { + await fetchAllTrees(walletProvider: walletProvider, limit: 100); + } + + // Organize trees by geohash + for (final tree in _allTrees) { + for (final geohash in geohashes) { + if (tree.geoHash.startsWith(geohash) || + _geohashService.encode(tree.latitude, tree.longitude).startsWith(geohash)) { + _treeCache.putIfAbsent(geohash, () => []); + if (!_treeCache[geohash]!.any((t) => t.id == tree.id)) { + _treeCache[geohash]!.add(tree); + } + } + } + } + } finally { + _loadingGeohashes.removeAll(geohashes); + } + } + + /// Cluster trees for efficient rendering at lower zoom levels + List clusterTrees(List trees, double zoom) { + if (trees.isEmpty) return []; + + // At high zoom, show individual trees + if (zoom >= 15) { + return trees.map((tree) => TreeCluster( + center: tree.position, + trees: [tree], + geohash: tree.geoHash, + )).toList(); + } + + // Cluster by geohash at lower zoom levels + final precision = _geohashService.getPrecisionForZoom(zoom); + final clusters = >{}; + + for (final tree in trees) { + final clusterHash = tree.geoHash.isNotEmpty && tree.geoHash.length >= precision + ? tree.geoHash.substring(0, precision) + : _geohashService.encode(tree.latitude, tree.longitude, precision: precision); + + clusters.putIfAbsent(clusterHash, () => []); + clusters[clusterHash]!.add(tree); + } + + return clusters.entries.map((entry) { + final clusterTrees = entry.value; + final centerLat = clusterTrees.map((t) => t.latitude).reduce((a, b) => a + b) / clusterTrees.length; + final centerLng = clusterTrees.map((t) => t.longitude).reduce((a, b) => a + b) / clusterTrees.length; + + return TreeCluster( + center: LatLng(centerLat, centerLng), + trees: clusterTrees, + geohash: entry.key, + ); + }).toList(); + } + + /// Get trees near a specific location + Future> getTreesNearLocation({ + required WalletProvider walletProvider, + required double latitude, + required double longitude, + double radiusMeters = 5000, + }) async { + final centerGeohash = _geohashService.encode(latitude, longitude); + final neighborGeohashes = _geohashService.getNeighbors(centerGeohash); + + // Ensure we have trees loaded + if (_allTrees.isEmpty) { + await fetchAllTrees(walletProvider: walletProvider, limit: 100); + } + + // Filter trees within radius + final center = LatLng(latitude, longitude); + return _allTrees.where((tree) { + final distance = _geohashService.calculateDistance(center, tree.position); + return distance <= radiusMeters; + }).toList() + ..sort((a, b) { + final distA = _geohashService.calculateDistance(center, a.position); + final distB = _geohashService.calculateDistance(center, b.position); + return distA.compareTo(distB); + }); + } +} diff --git a/lib/utils/constants/route_constants.dart b/lib/utils/constants/route_constants.dart index 0e3877a..309d64f 100644 --- a/lib/utils/constants/route_constants.dart +++ b/lib/utils/constants/route_constants.dart @@ -1,6 +1,7 @@ class RouteConstants { static const String home = '/'; static const String allTrees = '/trees'; + static const String exploreMap = '/explore-map'; static const String mintNft = '/mint-nft'; static const String mintNftOrganisation = '/mint-nft-organisation'; static const String mintNftDetails = '/mint-nft/details'; @@ -8,6 +9,7 @@ class RouteConstants { static const String homePath = '/'; static const String allTreesPath = '/trees'; + static const String exploreMapPath = '/explore-map'; static const String mintNftPath = '/mint-nft'; static const String mintNftOrganisationPath = '/mint-nft-organisation'; static const String mintNftDetailsPath = '/mint-nft/details'; diff --git a/lib/widgets/map_widgets/map_filter_widget.dart b/lib/widgets/map_widgets/map_filter_widget.dart new file mode 100644 index 0000000..009aa73 --- /dev/null +++ b/lib/widgets/map_widgets/map_filter_widget.dart @@ -0,0 +1,426 @@ +import 'package:flutter/material.dart'; +import 'package:tree_planting_protocol/utils/constants/ui/color_constants.dart'; +import 'package:tree_planting_protocol/utils/constants/ui/dimensions.dart'; + +/// Filter options for the tree map +class MapFilterOptions { + final bool showAliveOnly; + final bool showDeceasedOnly; + final String? speciesFilter; + final int? minCareCount; + final DateTime? plantedAfter; + final DateTime? plantedBefore; + + const MapFilterOptions({ + this.showAliveOnly = false, + this.showDeceasedOnly = false, + this.speciesFilter, + this.minCareCount, + this.plantedAfter, + this.plantedBefore, + }); + + MapFilterOptions copyWith({ + bool? showAliveOnly, + bool? showDeceasedOnly, + String? speciesFilter, + int? minCareCount, + DateTime? plantedAfter, + DateTime? plantedBefore, + }) { + return MapFilterOptions( + showAliveOnly: showAliveOnly ?? this.showAliveOnly, + showDeceasedOnly: showDeceasedOnly ?? this.showDeceasedOnly, + speciesFilter: speciesFilter ?? this.speciesFilter, + minCareCount: minCareCount ?? this.minCareCount, + plantedAfter: plantedAfter ?? this.plantedAfter, + plantedBefore: plantedBefore ?? this.plantedBefore, + ); + } + + bool get hasActiveFilters => + showAliveOnly || + showDeceasedOnly || + speciesFilter != null || + minCareCount != null || + plantedAfter != null || + plantedBefore != null; +} + +/// Widget for filtering trees on the map +class MapFilterWidget extends StatefulWidget { + final MapFilterOptions initialOptions; + final List availableSpecies; + final Function(MapFilterOptions) onFilterChanged; + + const MapFilterWidget({ + super.key, + required this.initialOptions, + required this.availableSpecies, + required this.onFilterChanged, + }); + + @override + State createState() => _MapFilterWidgetState(); +} + +class _MapFilterWidgetState extends State { + late MapFilterOptions _options; + bool _isExpanded = false; + + @override + void initState() { + super.initState(); + _options = widget.initialOptions; + } + + void _updateOptions(MapFilterOptions newOptions) { + setState(() { + _options = newOptions; + }); + widget.onFilterChanged(newOptions); + } + + void _clearFilters() { + _updateOptions(const MapFilterOptions()); + } + + @override + Widget build(BuildContext context) { + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + decoration: BoxDecoration( + color: getThemeColors(context)['background'], + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black26, + blurRadius: 4, + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Header + InkWell( + onTap: () => setState(() => _isExpanded = !_isExpanded), + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.filter_list, + color: _options.hasActiveFilters + ? getThemeColors(context)['primary'] + : getThemeColors(context)['icon'], + size: 20, + ), + const SizedBox(width: 8), + Text( + 'Filters', + style: TextStyle( + fontWeight: FontWeight.bold, + color: getThemeColors(context)['textPrimary'], + ), + ), + if (_options.hasActiveFilters) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: getThemeColors(context)['primary'], + borderRadius: BorderRadius.circular(10), + ), + child: const Text( + 'Active', + style: TextStyle( + color: Colors.white, + fontSize: 10, + ), + ), + ), + ], + const SizedBox(width: 8), + Icon( + _isExpanded ? Icons.expand_less : Icons.expand_more, + color: getThemeColors(context)['icon'], + size: 20, + ), + ], + ), + ), + ), + + // Expanded filters + if (_isExpanded) ...[ + const Divider(height: 1), + Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Status filter + Text( + 'Status', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 12, + color: getThemeColors(context)['textPrimary'], + ), + ), + const SizedBox(height: 8), + Row( + children: [ + _buildFilterChip( + context, + label: 'Alive', + isSelected: _options.showAliveOnly, + onTap: () { + _updateOptions(_options.copyWith( + showAliveOnly: !_options.showAliveOnly, + showDeceasedOnly: false, + )); + }, + ), + const SizedBox(width: 8), + _buildFilterChip( + context, + label: 'Deceased', + isSelected: _options.showDeceasedOnly, + onTap: () { + _updateOptions(_options.copyWith( + showDeceasedOnly: !_options.showDeceasedOnly, + showAliveOnly: false, + )); + }, + ), + ], + ), + + const SizedBox(height: 16), + + // Species filter + if (widget.availableSpecies.isNotEmpty) ...[ + Text( + 'Species', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 12, + color: getThemeColors(context)['textPrimary'], + ), + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + border: Border.all( + color: getThemeColors(context)['border']!, + ), + borderRadius: BorderRadius.circular(8), + ), + child: DropdownButton( + value: _options.speciesFilter, + hint: Text( + 'All species', + style: TextStyle( + color: getThemeColors(context)['textPrimary'], + ), + ), + isExpanded: true, + underline: const SizedBox(), + items: [ + const DropdownMenuItem( + value: null, + child: Text('All species'), + ), + ...widget.availableSpecies.map((species) { + return DropdownMenuItem( + value: species, + child: Text(species), + ); + }), + ], + onChanged: (value) { + _updateOptions(_options.copyWith(speciesFilter: value)); + }, + ), + ), + ], + + const SizedBox(height: 16), + + // Clear filters button + if (_options.hasActiveFilters) + SizedBox( + width: double.infinity, + child: TextButton.icon( + onPressed: _clearFilters, + icon: const Icon(Icons.clear_all, size: 18), + label: const Text('Clear all filters'), + style: TextButton.styleFrom( + foregroundColor: getThemeColors(context)['error'], + ), + ), + ), + ], + ), + ), + ], + ], + ), + ); + } + + Widget _buildFilterChip( + BuildContext context, { + required String label, + required bool isSelected, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: isSelected + ? getThemeColors(context)['primary'] + : getThemeColors(context)['background'], + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isSelected + ? getThemeColors(context)['primary']! + : getThemeColors(context)['border']!, + ), + ), + child: Text( + label, + style: TextStyle( + fontSize: 12, + color: isSelected + ? Colors.white + : getThemeColors(context)['textPrimary'], + ), + ), + ), + ); + } +} + +/// Quick filter bar for common filters +class QuickFilterBar extends StatelessWidget { + final MapFilterOptions options; + final Function(MapFilterOptions) onFilterChanged; + + const QuickFilterBar({ + super.key, + required this.options, + required this.onFilterChanged, + }); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + children: [ + _buildQuickFilter( + context, + icon: Icons.eco, + label: 'Alive', + isActive: options.showAliveOnly, + onTap: () { + onFilterChanged(options.copyWith( + showAliveOnly: !options.showAliveOnly, + showDeceasedOnly: false, + )); + }, + ), + const SizedBox(width: 8), + _buildQuickFilter( + context, + icon: Icons.favorite, + label: 'Well-cared', + isActive: options.minCareCount != null && options.minCareCount! > 0, + onTap: () { + onFilterChanged(options.copyWith( + minCareCount: options.minCareCount == null ? 5 : null, + )); + }, + ), + const SizedBox(width: 8), + _buildQuickFilter( + context, + icon: Icons.new_releases, + label: 'Recent', + isActive: options.plantedAfter != null, + onTap: () { + final thirtyDaysAgo = DateTime.now().subtract(const Duration(days: 30)); + onFilterChanged(options.copyWith( + plantedAfter: options.plantedAfter == null ? thirtyDaysAgo : null, + )); + }, + ), + ], + ), + ); + } + + Widget _buildQuickFilter( + BuildContext context, { + required IconData icon, + required String label, + required bool isActive, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: isActive + ? getThemeColors(context)['primary'] + : getThemeColors(context)['background'], + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: isActive + ? getThemeColors(context)['primary']! + : getThemeColors(context)['border']!, + ), + boxShadow: [ + BoxShadow( + color: Colors.black12, + blurRadius: 2, + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 16, + color: isActive + ? Colors.white + : getThemeColors(context)['icon'], + ), + const SizedBox(width: 6), + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: isActive + ? Colors.white + : getThemeColors(context)['textPrimary'], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/map_widgets/map_search_widget.dart b/lib/widgets/map_widgets/map_search_widget.dart new file mode 100644 index 0000000..52ea28d --- /dev/null +++ b/lib/widgets/map_widgets/map_search_widget.dart @@ -0,0 +1,345 @@ +import 'package:flutter/material.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:tree_planting_protocol/services/tree_map_service.dart'; +import 'package:tree_planting_protocol/utils/constants/ui/color_constants.dart'; + +/// Search result types +enum SearchResultType { tree, location, geohash } + +/// Search result model +class MapSearchResult { + final SearchResultType type; + final String title; + final String subtitle; + final LatLng location; + final MapTreeData? tree; + + MapSearchResult({ + required this.type, + required this.title, + required this.subtitle, + required this.location, + this.tree, + }); +} + +/// Search widget for the map +class MapSearchWidget extends StatefulWidget { + final List trees; + final Function(MapSearchResult) onResultSelected; + final Function(LatLng, double)? onLocationSearch; + + const MapSearchWidget({ + super.key, + required this.trees, + required this.onResultSelected, + this.onLocationSearch, + }); + + @override + State createState() => _MapSearchWidgetState(); +} + +class _MapSearchWidgetState extends State { + final TextEditingController _searchController = TextEditingController(); + final FocusNode _focusNode = FocusNode(); + List _results = []; + bool _isSearching = false; + bool _showResults = false; + + @override + void initState() { + super.initState(); + _focusNode.addListener(() { + if (!_focusNode.hasFocus) { + Future.delayed(const Duration(milliseconds: 200), () { + if (mounted) { + setState(() => _showResults = false); + } + }); + } + }); + } + + void _performSearch(String query) { + if (query.isEmpty) { + setState(() { + _results = []; + _showResults = false; + }); + return; + } + + setState(() => _isSearching = true); + + final results = []; + final queryLower = query.toLowerCase(); + + // Search by tree ID + if (RegExp(r'^\d+$').hasMatch(query)) { + final id = int.tryParse(query); + if (id != null) { + final matchingTrees = widget.trees.where((t) => t.id == id); + for (final tree in matchingTrees) { + results.add(MapSearchResult( + type: SearchResultType.tree, + title: 'Tree #${tree.id}', + subtitle: tree.species, + location: tree.position, + tree: tree, + )); + } + } + } + + // Search by species + final speciesMatches = widget.trees.where( + (t) => t.species.toLowerCase().contains(queryLower), + ); + for (final tree in speciesMatches.take(5)) { + if (!results.any((r) => r.tree?.id == tree.id)) { + results.add(MapSearchResult( + type: SearchResultType.tree, + title: tree.species, + subtitle: 'Tree #${tree.id}', + location: tree.position, + tree: tree, + )); + } + } + + // Search by geohash + if (query.length >= 4 && RegExp(r'^[0-9a-z]+$').hasMatch(queryLower)) { + final geohashMatches = widget.trees.where( + (t) => t.geoHash.toLowerCase().startsWith(queryLower), + ); + if (geohashMatches.isNotEmpty) { + final firstMatch = geohashMatches.first; + results.add(MapSearchResult( + type: SearchResultType.geohash, + title: 'Geohash: $query', + subtitle: '${geohashMatches.length} trees in this area', + location: firstMatch.position, + )); + } + } + + // Search by coordinates (lat,lng format) + final coordMatch = RegExp(r'^(-?\d+\.?\d*),\s*(-?\d+\.?\d*)$').firstMatch(query); + if (coordMatch != null) { + final lat = double.tryParse(coordMatch.group(1)!); + final lng = double.tryParse(coordMatch.group(2)!); + if (lat != null && lng != null && lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180) { + results.add(MapSearchResult( + type: SearchResultType.location, + title: 'Location', + subtitle: '${lat.toStringAsFixed(4)}, ${lng.toStringAsFixed(4)}', + location: LatLng(lat, lng), + )); + } + } + + setState(() { + _results = results; + _isSearching = false; + _showResults = results.isNotEmpty; + }); + } + + void _selectResult(MapSearchResult result) { + _searchController.clear(); + setState(() { + _results = []; + _showResults = false; + }); + _focusNode.unfocus(); + widget.onResultSelected(result); + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Search input + Container( + decoration: BoxDecoration( + color: getThemeColors(context)['background'], + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black26, + blurRadius: 4, + ), + ], + ), + child: Row( + children: [ + const SizedBox(width: 12), + Icon( + Icons.search, + color: getThemeColors(context)['icon'], + size: 20, + ), + Expanded( + child: TextField( + controller: _searchController, + focusNode: _focusNode, + decoration: InputDecoration( + hintText: 'Search trees, species, or location...', + hintStyle: TextStyle( + color: getThemeColors(context)['textPrimary']!.withValues(alpha: 0.5), + fontSize: 14, + ), + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + ), + style: TextStyle( + color: getThemeColors(context)['textPrimary'], + fontSize: 14, + ), + onChanged: _performSearch, + onTap: () { + if (_results.isNotEmpty) { + setState(() => _showResults = true); + } + }, + ), + ), + if (_searchController.text.isNotEmpty) + IconButton( + icon: Icon( + Icons.clear, + color: getThemeColors(context)['icon'], + size: 18, + ), + onPressed: () { + _searchController.clear(); + _performSearch(''); + }, + ), + if (_isSearching) + Padding( + padding: const EdgeInsets.only(right: 12), + child: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + getThemeColors(context)['primary']!, + ), + ), + ), + ), + ], + ), + ), + + // Search results + if (_showResults && _results.isNotEmpty) + Container( + margin: const EdgeInsets.only(top: 4), + constraints: const BoxConstraints(maxHeight: 200), + decoration: BoxDecoration( + color: getThemeColors(context)['background'], + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black26, + blurRadius: 4, + ), + ], + ), + child: ListView.builder( + shrinkWrap: true, + padding: EdgeInsets.zero, + itemCount: _results.length, + itemBuilder: (context, index) { + final result = _results[index]; + return _buildResultItem(context, result); + }, + ), + ), + ], + ); + } + + Widget _buildResultItem(BuildContext context, MapSearchResult result) { + IconData icon; + Color iconColor; + + switch (result.type) { + case SearchResultType.tree: + icon = Icons.park; + iconColor = Colors.green; + break; + case SearchResultType.location: + icon = Icons.location_on; + iconColor = Colors.red; + break; + case SearchResultType.geohash: + icon = Icons.grid_on; + iconColor = Colors.blue; + break; + } + + return InkWell( + onTap: () => _selectResult(result), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: iconColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, color: iconColor, size: 18), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + result.title, + style: TextStyle( + fontWeight: FontWeight.w500, + color: getThemeColors(context)['textPrimary'], + fontSize: 14, + ), + ), + Text( + result.subtitle, + style: TextStyle( + color: getThemeColors(context)['textPrimary']!.withValues(alpha: 0.6), + fontSize: 12, + ), + ), + ], + ), + ), + Icon( + Icons.arrow_forward_ios, + color: getThemeColors(context)['icon']!.withValues(alpha: 0.5), + size: 14, + ), + ], + ), + ), + ); + } + + @override + void dispose() { + _searchController.dispose(); + _focusNode.dispose(); + super.dispose(); + } +} diff --git a/lib/widgets/map_widgets/nearby_trees_widget.dart b/lib/widgets/map_widgets/nearby_trees_widget.dart new file mode 100644 index 0000000..daf4981 --- /dev/null +++ b/lib/widgets/map_widgets/nearby_trees_widget.dart @@ -0,0 +1,415 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; +import 'package:tree_planting_protocol/providers/wallet_provider.dart'; +import 'package:tree_planting_protocol/services/geohash_service.dart'; +import 'package:tree_planting_protocol/services/tree_map_service.dart'; +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/utils/logger.dart'; +import 'package:tree_planting_protocol/utils/services/get_current_location.dart'; +import 'package:latlong2/latlong.dart'; + +/// Widget that displays trees near the user's current location +class NearbyTreesWidget extends StatefulWidget { + final double radiusMeters; + final int maxTrees; + + const NearbyTreesWidget({ + super.key, + this.radiusMeters = 5000, + this.maxTrees = 10, + }); + + @override + State createState() => _NearbyTreesWidgetState(); +} + +class _NearbyTreesWidgetState extends State { + final TreeMapService _treeMapService = TreeMapService(); + final LocationService _locationService = LocationService(); + final GeohashService _geohashService = GeohashService(); + + List _nearbyTrees = []; + bool _isLoading = true; + String? _errorMessage; + LatLng? _userLocation; + + @override + void initState() { + super.initState(); + _loadNearbyTrees(); + } + + Future _loadNearbyTrees() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + // Get user location + final locationInfo = await _locationService.getCurrentLocationWithTimeout( + timeout: const Duration(seconds: 15), + ); + + if (!locationInfo.isValid) { + setState(() { + _errorMessage = 'Could not determine your location'; + _isLoading = false; + }); + return; + } + + _userLocation = LatLng(locationInfo.latitude!, locationInfo.longitude!); + + // Get wallet provider + final walletProvider = Provider.of(context, listen: false); + + if (!walletProvider.isConnected) { + setState(() { + _errorMessage = 'Please connect your wallet'; + _isLoading = false; + }); + return; + } + + // Fetch nearby trees + final trees = await _treeMapService.getTreesNearLocation( + walletProvider: walletProvider, + latitude: _userLocation!.latitude, + longitude: _userLocation!.longitude, + radiusMeters: widget.radiusMeters, + ); + + setState(() { + _nearbyTrees = trees.take(widget.maxTrees).toList(); + _isLoading = false; + }); + } catch (e) { + logger.e('Error loading nearby trees: $e'); + setState(() { + _errorMessage = 'Failed to load nearby trees'; + _isLoading = false; + }); + } + } + + String _formatDistance(double meters) { + if (meters < 1000) { + return '${meters.toInt()}m'; + } + return '${(meters / 1000).toStringAsFixed(1)}km'; + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return _buildLoadingState(context); + } + + if (_errorMessage != null) { + return _buildErrorState(context); + } + + if (_nearbyTrees.isEmpty) { + return _buildEmptyState(context); + } + + return _buildTreesList(context); + } + + Widget _buildLoadingState(BuildContext context) { + return Container( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation( + getThemeColors(context)['primary']!, + ), + ), + const SizedBox(height: 16), + Text( + 'Finding trees near you...', + style: TextStyle( + color: getThemeColors(context)['textPrimary'], + ), + ), + ], + ), + ); + } + + Widget _buildErrorState(BuildContext context) { + return Container( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.location_off, + size: 48, + color: getThemeColors(context)['error'], + ), + const SizedBox(height: 16), + Text( + _errorMessage!, + style: TextStyle( + color: getThemeColors(context)['textPrimary'], + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadNearbyTrees, + style: ElevatedButton.styleFrom( + backgroundColor: getThemeColors(context)['primary'], + foregroundColor: Colors.white, + ), + child: const Text('Retry'), + ), + ], + ), + ); + } + + Widget _buildEmptyState(BuildContext context) { + return Container( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.park_outlined, + size: 48, + color: getThemeColors(context)['secondary'], + ), + const SizedBox(height: 16), + Text( + 'No trees found nearby', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: getThemeColors(context)['textPrimary'], + ), + ), + const SizedBox(height: 8), + Text( + 'Be the first to plant a tree in your area!', + style: TextStyle( + color: getThemeColors(context)['textPrimary'], + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton.icon( + onPressed: () => context.push('/mint-nft'), + icon: const Icon(Icons.add), + label: const Text('Plant Tree'), + style: ElevatedButton.styleFrom( + backgroundColor: getThemeColors(context)['primary'], + foregroundColor: Colors.white, + ), + ), + const SizedBox(width: 12), + OutlinedButton.icon( + onPressed: () => context.push('/explore-map'), + icon: const Icon(Icons.map), + label: const Text('Explore Map'), + ), + ], + ), + ], + ), + ); + } + + Widget _buildTreesList(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Icon( + Icons.near_me, + color: getThemeColors(context)['primary'], + size: 20, + ), + const SizedBox(width: 8), + Text( + 'Trees Near You', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: getThemeColors(context)['textPrimary'], + ), + ), + const Spacer(), + TextButton( + onPressed: () => context.push('/explore-map'), + child: Text( + 'View All', + style: TextStyle( + color: getThemeColors(context)['primary'], + ), + ), + ), + ], + ), + ), + + // Horizontal list of nearby trees + SizedBox( + height: 180, + child: ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12), + itemCount: _nearbyTrees.length, + itemBuilder: (context, index) { + final tree = _nearbyTrees[index]; + final distance = _userLocation != null + ? _geohashService.calculateDistance(_userLocation!, tree.position) + : 0.0; + + return _buildTreeCard(context, tree, distance); + }, + ), + ), + ], + ); + } + + Widget _buildTreeCard(BuildContext context, MapTreeData tree, double distance) { + return GestureDetector( + onTap: () => context.push('/trees/${tree.id}'), + child: Container( + width: 140, + margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), + decoration: BoxDecoration( + color: getThemeColors(context)['background'], + borderRadius: BorderRadius.circular(buttonCircularRadius), + border: Border.all( + color: getThemeColors(context)['border']!, + width: 1, + ), + boxShadow: [ + BoxShadow( + color: Colors.black12, + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Image + Container( + height: 80, + decoration: BoxDecoration( + borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), + color: getThemeColors(context)['secondary']!.withValues(alpha: 0.3), + ), + child: ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), + child: tree.imageUri.isNotEmpty + ? Image.network( + tree.imageUri, + fit: BoxFit.cover, + width: double.infinity, + errorBuilder: (_, __, ___) => Center( + child: Icon( + Icons.park, + color: getThemeColors(context)['primary'], + size: 32, + ), + ), + ) + : Center( + child: Icon( + Icons.park, + color: getThemeColors(context)['primary'], + size: 32, + ), + ), + ), + ), + + // Info + Padding( + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + tree.species, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 12, + color: getThemeColors(context)['textPrimary'], + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Row( + children: [ + Icon( + Icons.location_on, + size: 12, + color: getThemeColors(context)['primary'], + ), + const SizedBox(width: 2), + Text( + _formatDistance(distance), + style: TextStyle( + fontSize: 11, + color: getThemeColors(context)['textPrimary']!.withValues(alpha: 0.7), + ), + ), + ], + ), + const SizedBox(height: 4), + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: tree.isAlive ? Colors.green : Colors.grey, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + tree.isAlive ? 'Alive' : 'Deceased', + style: const TextStyle( + color: Colors.white, + fontSize: 9, + ), + ), + ), + const Spacer(), + Text( + '#${tree.id}', + style: TextStyle( + fontSize: 10, + color: getThemeColors(context)['textPrimary']!.withValues(alpha: 0.5), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/map_widgets/tree_heatmap_layer.dart b/lib/widgets/map_widgets/tree_heatmap_layer.dart new file mode 100644 index 0000000..09fda37 --- /dev/null +++ b/lib/widgets/map_widgets/tree_heatmap_layer.dart @@ -0,0 +1,186 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:tree_planting_protocol/services/tree_map_service.dart'; + +/// Custom heatmap layer for visualizing tree density +class TreeHeatmapLayer extends StatelessWidget { + final List trees; + final double zoom; + final double opacity; + final double radius; + + const TreeHeatmapLayer({ + super.key, + required this.trees, + required this.zoom, + this.opacity = 0.6, + this.radius = 30, + }); + + @override + Widget build(BuildContext context) { + if (trees.isEmpty || zoom > 14) { + return const SizedBox.shrink(); + } + + return CustomPaint( + painter: _HeatmapPainter( + trees: trees, + opacity: opacity, + radius: _getRadiusForZoom(zoom), + ), + size: Size.infinite, + ); + } + + double _getRadiusForZoom(double zoom) { + // Adjust radius based on zoom level + if (zoom < 6) return 15; + if (zoom < 8) return 20; + if (zoom < 10) return 25; + if (zoom < 12) return 30; + return 40; + } +} + +class _HeatmapPainter extends CustomPainter { + final List trees; + final double opacity; + final double radius; + + _HeatmapPainter({ + required this.trees, + required this.opacity, + required this.radius, + }); + + @override + void paint(Canvas canvas, Size size) { + // This is a simplified heatmap - in production you'd use proper heatmap algorithms + // For now, we'll draw gradient circles at tree locations + } + + @override + bool shouldRepaint(covariant _HeatmapPainter oldDelegate) { + return trees != oldDelegate.trees || + opacity != oldDelegate.opacity || + radius != oldDelegate.radius; + } +} + +/// Widget that shows tree density as colored regions on the map +class TreeDensityOverlay extends StatelessWidget { + final List clusters; + final MapCamera camera; + + const TreeDensityOverlay({ + super.key, + required this.clusters, + required this.camera, + }); + + @override + Widget build(BuildContext context) { + return Stack( + children: clusters.map((cluster) { + if (cluster.count < 3) return const SizedBox.shrink(); + + final point = camera.latLngToScreenPoint(cluster.center); + final intensity = (cluster.totalTreeCount / 100).clamp(0.2, 1.0); + final size = 40 + (cluster.totalTreeCount * 2).clamp(0, 60).toDouble(); + + return Positioned( + left: point.x - size / 2, + top: point.y - size / 2, + child: IgnorePointer( + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + _getColorForIntensity(intensity).withValues(alpha: 0.4), + _getColorForIntensity(intensity).withValues(alpha: 0.1), + Colors.transparent, + ], + stops: const [0.0, 0.5, 1.0], + ), + ), + ), + ), + ); + }).toList(), + ); + } + + Color _getColorForIntensity(double intensity) { + if (intensity > 0.7) return Colors.red; + if (intensity > 0.4) return Colors.orange; + return Colors.green; + } +} + +/// Legend widget for the heatmap +class TreeDensityLegend extends StatelessWidget { + const TreeDensityLegend({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: Colors.black26, + blurRadius: 4, + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Tree Density', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + const SizedBox(height: 8), + _buildLegendItem(Colors.green, 'Low'), + _buildLegendItem(Colors.orange, 'Medium'), + _buildLegendItem(Colors.red, 'High'), + ], + ), + ); + } + + Widget _buildLegendItem(Color color, String label) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.6), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + Text( + label, + style: const TextStyle(fontSize: 11), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/nft_display_utils/recent_trees_widget.dart b/lib/widgets/nft_display_utils/recent_trees_widget.dart index 426c65e..bde7c22 100644 --- a/lib/widgets/nft_display_utils/recent_trees_widget.dart +++ b/lib/widgets/nft_display_utils/recent_trees_widget.dart @@ -372,12 +372,8 @@ class _RecentTreesWidgetState extends State { Expanded( child: ElevatedButton( onPressed: () { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: const Text('Map view coming soon!'), - backgroundColor: getThemeColors(context)['secondary'], - ), - ); + // Navigate to explore map page + context.push('/explore-map'); }, style: ElevatedButton.styleFrom( backgroundColor: getThemeColors(context)['secondary'], From dd8a2fdc187aedfd7c63e19de8ce4a42f2499b7b Mon Sep 17 00:00:00 2001 From: Kartikey Gupta Date: Sat, 13 Dec 2025 22:02:51 +0530 Subject: [PATCH 2/8] fix: Implement heatmap painting logic in TreeHeatmapLayer - Add proper paint() implementation with radial gradient circles - Use MapCamera for coordinate-to-screen conversion - Add additive blending for overlapping heat points - Filter points to only render visible area for performance - Update shouldRepaint to check camera position changes --- .../map_widgets/tree_heatmap_layer.dart | 92 ++++++++++++++----- 1 file changed, 70 insertions(+), 22 deletions(-) diff --git a/lib/widgets/map_widgets/tree_heatmap_layer.dart b/lib/widgets/map_widgets/tree_heatmap_layer.dart index 09fda37..cdc2920 100644 --- a/lib/widgets/map_widgets/tree_heatmap_layer.dart +++ b/lib/widgets/map_widgets/tree_heatmap_layer.dart @@ -1,75 +1,123 @@ +import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; -import 'package:latlong2/latlong.dart'; import 'package:tree_planting_protocol/services/tree_map_service.dart'; -/// Custom heatmap layer for visualizing tree density +/// Custom heatmap layer for visualizing tree density on flutter_map +/// Uses gradient circles to show concentration of trees class TreeHeatmapLayer extends StatelessWidget { final List trees; - final double zoom; + final MapCamera camera; final double opacity; - final double radius; + final double baseRadius; const TreeHeatmapLayer({ super.key, required this.trees, - required this.zoom, - this.opacity = 0.6, - this.radius = 30, + required this.camera, + this.opacity = 0.5, + this.baseRadius = 30, }); @override Widget build(BuildContext context) { - if (trees.isEmpty || zoom > 14) { + // Don't show heatmap at high zoom levels (show individual markers instead) + if (trees.isEmpty || camera.zoom > 14) { return const SizedBox.shrink(); } return CustomPaint( painter: _HeatmapPainter( trees: trees, + camera: camera, opacity: opacity, - radius: _getRadiusForZoom(zoom), + radius: _getRadiusForZoom(camera.zoom), ), size: Size.infinite, ); } double _getRadiusForZoom(double zoom) { - // Adjust radius based on zoom level - if (zoom < 6) return 15; - if (zoom < 8) return 20; - if (zoom < 10) return 25; - if (zoom < 12) return 30; - return 40; + // Adjust radius based on zoom level for better visualization + if (zoom < 6) return baseRadius * 0.5; + if (zoom < 8) return baseRadius * 0.7; + if (zoom < 10) return baseRadius * 0.85; + if (zoom < 12) return baseRadius; + return baseRadius * 1.3; } } class _HeatmapPainter extends CustomPainter { final List trees; + final MapCamera camera; final double opacity; final double radius; _HeatmapPainter({ required this.trees, + required this.camera, required this.opacity, required this.radius, }); @override void paint(Canvas canvas, Size size) { - // This is a simplified heatmap - in production you'd use proper heatmap algorithms - // For now, we'll draw gradient circles at tree locations + if (trees.isEmpty) return; + + // Create a list of screen points for all trees + final points = []; + for (final tree in trees) { + final screenPoint = camera.latLngToScreenPoint(tree.position); + // Only include points that are within or near the visible area + if (screenPoint.x >= -radius && + screenPoint.x <= size.width + radius && + screenPoint.y >= -radius && + screenPoint.y <= size.height + radius) { + points.add(Offset(screenPoint.x, screenPoint.y)); + } + } + + if (points.isEmpty) return; + + // Draw gradient circles at each tree location + for (final point in points) { + _drawHeatPoint(canvas, point); + } + } + + void _drawHeatPoint(Canvas canvas, Offset center) { + // Create a radial gradient for the heat point + final gradient = ui.Gradient.radial( + center, + radius, + [ + Colors.green.withValues(alpha: opacity * 0.8), + Colors.green.withValues(alpha: opacity * 0.4), + Colors.green.withValues(alpha: opacity * 0.1), + Colors.transparent, + ], + [0.0, 0.3, 0.6, 1.0], + ); + + final paint = Paint() + ..shader = gradient + ..blendMode = BlendMode.plus; // Additive blending for overlapping areas + + canvas.drawCircle(center, radius, paint); } @override bool shouldRepaint(covariant _HeatmapPainter oldDelegate) { - return trees != oldDelegate.trees || - opacity != oldDelegate.opacity || - radius != oldDelegate.radius; + return trees.length != oldDelegate.trees.length || + camera.zoom != oldDelegate.camera.zoom || + camera.center != oldDelegate.camera.center || + opacity != oldDelegate.opacity || + radius != oldDelegate.radius; } } /// Widget that shows tree density as colored regions on the map +/// This is an alternative to the heatmap that uses discrete clusters class TreeDensityOverlay extends StatelessWidget { final List clusters; final MapCamera camera; @@ -85,7 +133,7 @@ class TreeDensityOverlay extends StatelessWidget { return Stack( children: clusters.map((cluster) { if (cluster.count < 3) return const SizedBox.shrink(); - + final point = camera.latLngToScreenPoint(cluster.center); final intensity = (cluster.totalTreeCount / 100).clamp(0.2, 1.0); final size = 40 + (cluster.totalTreeCount * 2).clamp(0, 60).toDouble(); @@ -133,7 +181,7 @@ class TreeDensityLegend extends StatelessWidget { decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.9), borderRadius: BorderRadius.circular(8), - boxShadow: [ + boxShadow: const [ BoxShadow( color: Colors.black26, blurRadius: 4, From 22eba343ecb8e965ea5e520fb78ef4ba3cb4d0bf Mon Sep 17 00:00:00 2001 From: Kartikey Gupta Date: Sat, 13 Dec 2025 22:04:25 +0530 Subject: [PATCH 3/8] fix: Add mounted checks in NearbyTreesWidget async method - Add mounted check at start of _loadNearbyTrees - Add mounted check after getCurrentLocationWithTimeout await - Add mounted check after getTreesNearLocation await - Add mounted check in catch block before setState - Prevents setState on unmounted widget errors --- lib/widgets/map_widgets/nearby_trees_widget.dart | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/lib/widgets/map_widgets/nearby_trees_widget.dart b/lib/widgets/map_widgets/nearby_trees_widget.dart index daf4981..4eaa9a7 100644 --- a/lib/widgets/map_widgets/nearby_trees_widget.dart +++ b/lib/widgets/map_widgets/nearby_trees_widget.dart @@ -42,6 +42,8 @@ class _NearbyTreesWidgetState extends State { } Future _loadNearbyTrees() async { + if (!mounted) return; + setState(() { _isLoading = true; _errorMessage = null; @@ -53,6 +55,9 @@ class _NearbyTreesWidgetState extends State { timeout: const Duration(seconds: 15), ); + // Check mounted after await + if (!mounted) return; + if (!locationInfo.isValid) { setState(() { _errorMessage = 'Could not determine your location'; @@ -63,10 +68,11 @@ class _NearbyTreesWidgetState extends State { _userLocation = LatLng(locationInfo.latitude!, locationInfo.longitude!); - // Get wallet provider + // Get wallet provider (safe to use context now since we checked mounted) final walletProvider = Provider.of(context, listen: false); if (!walletProvider.isConnected) { + if (!mounted) return; setState(() { _errorMessage = 'Please connect your wallet'; _isLoading = false; @@ -82,12 +88,16 @@ class _NearbyTreesWidgetState extends State { radiusMeters: widget.radiusMeters, ); + // Check mounted after await + if (!mounted) return; + setState(() { _nearbyTrees = trees.take(widget.maxTrees).toList(); _isLoading = false; }); } catch (e) { logger.e('Error loading nearby trees: $e'); + if (!mounted) return; setState(() { _errorMessage = 'Failed to load nearby trees'; _isLoading = false; From afbd5746aa11866af6fa3a0c7271f492ce5d8ae8 Mon Sep 17 00:00:00 2001 From: Kartikey Gupta Date: Sat, 13 Dec 2025 22:13:40 +0530 Subject: [PATCH 4/8] fix: Address CodeRabbit review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use dart_geohash native neighbor() function instead of custom delta-based calculation - Add O(1) duplicate check using Set for tree cache (fix O(n×m) performance issue) - Add sentinel pattern in MapFilterOptions.copyWith to allow clearing nullable fields - Fix unsafe substring call for short geohashes in tree details panel - Fix map controller race condition with _mapReady flag and pending center - Remove unused _geohashService field from ExploreTreesMapPage --- lib/pages/explore_trees_map_page.dart | 66 ++++++++++++++----- lib/services/geohash_service.dart | 48 ++++++-------- lib/services/tree_map_service.dart | 55 +++++++++++----- .../map_widgets/map_filter_widget.dart | 28 +++++--- 4 files changed, 127 insertions(+), 70 deletions(-) diff --git a/lib/pages/explore_trees_map_page.dart b/lib/pages/explore_trees_map_page.dart index eda3ae3..cd09316 100644 --- a/lib/pages/explore_trees_map_page.dart +++ b/lib/pages/explore_trees_map_page.dart @@ -4,7 +4,6 @@ import 'package:go_router/go_router.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/services/geohash_service.dart'; import 'package:tree_planting_protocol/services/tree_map_service.dart'; import 'package:tree_planting_protocol/utils/constants/ui/color_constants.dart'; import 'package:tree_planting_protocol/utils/constants/ui/dimensions.dart'; @@ -24,7 +23,6 @@ class ExploreTreesMapPage extends StatefulWidget { class _ExploreTreesMapPageState extends State { late MapController _mapController; final TreeMapService _treeMapService = TreeMapService(); - final GeohashService _geohashService = GeohashService(); final LocationService _locationService = LocationService(); List _clusters = []; @@ -39,6 +37,11 @@ class _ExploreTreesMapPageState extends State { MapFilterOptions _filterOptions = const MapFilterOptions(); List _availableSpecies = []; + // Map ready state to prevent race conditions + bool _mapReady = false; + LatLng? _pendingCenter; + double? _pendingZoom; + // Default center (can be changed based on user location) static const LatLng _defaultCenter = LatLng(28.6139, 77.2090); // Delhi, India @@ -72,14 +75,17 @@ class _ExploreTreesMapPageState extends State { final locationInfo = await _locationService.getCurrentLocationWithTimeout( timeout: const Duration(seconds: 10), ); - - if (locationInfo.isValid && mounted) { + + if (!mounted) return; + + if (locationInfo.isValid) { + final location = LatLng(locationInfo.latitude!, locationInfo.longitude!); setState(() { - _userLocation = LatLng(locationInfo.latitude!, locationInfo.longitude!); + _userLocation = location; }); - - // Move map to user location - _mapController.move(_userLocation!, 12.0); + + // Move map to user location if ready, otherwise defer + _moveMapTo(location, 12.0); } } catch (e) { logger.w('Could not get user location: $e'); @@ -87,6 +93,35 @@ class _ExploreTreesMapPageState extends State { } } + /// Safely move the map, deferring if not ready + void _moveMapTo(LatLng center, double zoom) { + if (_mapReady) { + _mapController.move(center, zoom); + } else { + // Defer until map is ready + _pendingCenter = center; + _pendingZoom = zoom; + } + } + + /// Called when the map is ready + void _onMapReady() { + if (!mounted) return; + + setState(() { + _mapReady = true; + }); + + // Apply any pending move + if (_pendingCenter != null) { + _mapController.move(_pendingCenter!, _pendingZoom ?? 12.0); + _pendingCenter = null; + _pendingZoom = null; + } + + _updateVisibleTrees(); + } + Future _loadTrees() async { final walletProvider = Provider.of(context, listen: false); @@ -201,7 +236,8 @@ class _ExploreTreesMapPageState extends State { void _onSearchResultSelected(MapSearchResult result) { // Move map to the result location - _mapController.move(result.location, result.type == SearchResultType.tree ? 16.0 : 14.0); + _moveMapTo( + result.location, result.type == SearchResultType.tree ? 16.0 : 14.0); // If it's a tree, show its details if (result.tree != null) { @@ -243,13 +279,13 @@ class _ExploreTreesMapPageState extends State { }); } else { // Zoom in to cluster - _mapController.move(cluster.center, _currentZoom + 2); + _moveMapTo(cluster.center, _currentZoom + 2); } } void _centerOnUserLocation() async { if (_userLocation != null) { - _mapController.move(_userLocation!, 14.0); + _moveMapTo(_userLocation!, 14.0); } else { await _getUserLocation(); } @@ -285,9 +321,7 @@ class _ExploreTreesMapPageState extends State { _onMapMove(); } }, - onMapReady: () { - _updateVisibleTrees(); - }, + onMapReady: _onMapReady, ), children: [ // OpenStreetMap tiles @@ -866,7 +900,9 @@ class _ExploreTreesMapPageState extends State { _buildDetailChip( context, icon: Icons.grid_on, - label: tree.geoHash.substring(0, 6), + label: tree.geoHash.length >= 6 + ? tree.geoHash.substring(0, 6) + : tree.geoHash, ), ], ), diff --git a/lib/services/geohash_service.dart b/lib/services/geohash_service.dart index edae425..d9daedd 100644 --- a/lib/services/geohash_service.dart +++ b/lib/services/geohash_service.dart @@ -52,46 +52,36 @@ class GeohashService { } /// Get neighboring geohashes (8 surrounding + center) + /// Uses dart_geohash's native neighbor() function for correct traversal List getNeighbors(String geohash) { + if (geohash.isEmpty) return []; + final neighbors = [geohash]; - - // Get all 8 neighbors - final directions = ['n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw']; + + // Use dart_geohash's native Direction enum for all 8 neighbors + final directions = [ + Direction.NORTH, + Direction.NORTHEAST, + Direction.EAST, + Direction.SOUTHEAST, + Direction.SOUTH, + Direction.SOUTHWEST, + Direction.WEST, + Direction.NORTHWEST, + ]; + for (final direction in directions) { try { - final neighbor = _getNeighbor(geohash, direction); + final neighbor = _geoHasher.neighbor(geohash, direction); if (neighbor.isNotEmpty) { neighbors.add(neighbor); } } catch (_) { - // Skip invalid neighbors at edges + // Skip invalid neighbors at edges (e.g., at poles or date line) } } - - return neighbors; - } - String _getNeighbor(String geohash, String direction) { - if (geohash.isEmpty) return ''; - - final center = decode(geohash); - final precision = geohash.length; - final latDelta = _getLatDelta(precision); - final lngDelta = _getLngDelta(precision); - - double newLat = center.latitude; - double newLng = center.longitude; - - if (direction.contains('n')) newLat += latDelta; - if (direction.contains('s')) newLat -= latDelta; - if (direction.contains('e')) newLng += lngDelta; - if (direction.contains('w')) newLng -= lngDelta; - - // Clamp to valid ranges - newLat = newLat.clamp(-90.0, 90.0); - newLng = newLng.clamp(-180.0, 180.0); - - return encode(newLat, newLng, precision: precision); + return neighbors; } /// Get geohashes covering a bounding box diff --git a/lib/services/tree_map_service.dart b/lib/services/tree_map_service.dart index 610e915..266c363 100644 --- a/lib/services/tree_map_service.dart +++ b/lib/services/tree_map_service.dart @@ -83,11 +83,13 @@ class TreeMapService { TreeMapService._internal(); final GeohashService _geohashService = GeohashService(); - + // Cache for loaded trees by geohash final Map> _treeCache = {}; + // O(1) lookup for duplicate prevention per geohash + final Map> _treeCacheIds = {}; final Set _loadingGeohashes = {}; - + // All loaded trees List _allTrees = []; int _totalTreeCount = 0; @@ -99,12 +101,24 @@ class TreeMapService { /// Clear all cached data void clearCache() { _treeCache.clear(); + _treeCacheIds.clear(); _loadingGeohashes.clear(); _allTrees.clear(); _totalTreeCount = 0; _hasMore = true; } + /// Add tree to cache with O(1) duplicate check + void _addTreeToCache(String geohash, MapTreeData tree) { + _treeCache.putIfAbsent(geohash, () => []); + _treeCacheIds.putIfAbsent(geohash, () => {}); + + // O(1) duplicate check using Set + if (_treeCacheIds[geohash]!.add(tree.id)) { + _treeCache[geohash]!.add(tree); + } + } + /// Fetch trees for visible map area using geohash-based queries Future> fetchTreesInBounds({ required WalletProvider walletProvider, @@ -187,16 +201,15 @@ class TreeMapService { .map((data) => MapTreeData.fromContractData(data as Map)) .toList(); - // Add to cache by geohash + // Add to cache by geohash with O(1) duplicate check for (final tree in newTrees) { - final geohash = tree.geoHash.isNotEmpty - ? tree.geoHash.substring(0, GeohashService.defaultPrecision.clamp(1, tree.geoHash.length)) + final geohash = tree.geoHash.isNotEmpty + ? tree.geoHash.substring( + 0, + GeohashService.defaultPrecision.clamp(1, tree.geoHash.length)) : _geohashService.encode(tree.latitude, tree.longitude); - - _treeCache.putIfAbsent(geohash, () => []); - if (!_treeCache[geohash]!.any((t) => t.id == tree.id)) { - _treeCache[geohash]!.add(tree); - } + + _addTreeToCache(geohash, tree); } if (offset == 0) { @@ -230,15 +243,21 @@ class TreeMapService { await fetchAllTrees(walletProvider: walletProvider, limit: 100); } - // Organize trees by geohash + // Convert geohashes to Set for O(1) prefix matching + final geohashSet = geohashes.toSet(); + + // Single pass over all trees - O(n) instead of O(n×m) for (final tree in _allTrees) { - for (final geohash in geohashes) { - if (tree.geoHash.startsWith(geohash) || - _geohashService.encode(tree.latitude, tree.longitude).startsWith(geohash)) { - _treeCache.putIfAbsent(geohash, () => []); - if (!_treeCache[geohash]!.any((t) => t.id == tree.id)) { - _treeCache[geohash]!.add(tree); - } + // Compute encoded geohash once per tree + final encodedGeohash = + _geohashService.encode(tree.latitude, tree.longitude); + + // Check each requested geohash for prefix match + for (final geohash in geohashSet) { + if (tree.geoHash.startsWith(geohash) || + encodedGeohash.startsWith(geohash)) { + // O(1) duplicate check and add + _addTreeToCache(geohash, tree); } } } diff --git a/lib/widgets/map_widgets/map_filter_widget.dart b/lib/widgets/map_widgets/map_filter_widget.dart index 009aa73..084eaca 100644 --- a/lib/widgets/map_widgets/map_filter_widget.dart +++ b/lib/widgets/map_widgets/map_filter_widget.dart @@ -2,6 +2,9 @@ import 'package:flutter/material.dart'; import 'package:tree_planting_protocol/utils/constants/ui/color_constants.dart'; import 'package:tree_planting_protocol/utils/constants/ui/dimensions.dart'; +/// Sentinel object to distinguish "not provided" from "explicitly null" +const _unset = Object(); + /// Filter options for the tree map class MapFilterOptions { final bool showAliveOnly; @@ -20,21 +23,30 @@ class MapFilterOptions { this.plantedBefore, }); + /// Creates a copy with the given fields replaced. + /// Uses sentinel pattern to allow explicitly setting nullable fields to null. MapFilterOptions copyWith({ bool? showAliveOnly, bool? showDeceasedOnly, - String? speciesFilter, - int? minCareCount, - DateTime? plantedAfter, - DateTime? plantedBefore, + Object? speciesFilter = _unset, + Object? minCareCount = _unset, + Object? plantedAfter = _unset, + Object? plantedBefore = _unset, }) { return MapFilterOptions( showAliveOnly: showAliveOnly ?? this.showAliveOnly, showDeceasedOnly: showDeceasedOnly ?? this.showDeceasedOnly, - speciesFilter: speciesFilter ?? this.speciesFilter, - minCareCount: minCareCount ?? this.minCareCount, - plantedAfter: plantedAfter ?? this.plantedAfter, - plantedBefore: plantedBefore ?? this.plantedBefore, + speciesFilter: speciesFilter == _unset + ? this.speciesFilter + : speciesFilter as String?, + minCareCount: + minCareCount == _unset ? this.minCareCount : minCareCount as int?, + plantedAfter: plantedAfter == _unset + ? this.plantedAfter + : plantedAfter as DateTime?, + plantedBefore: plantedBefore == _unset + ? this.plantedBefore + : plantedBefore as DateTime?, ); } From d04da0464aae49247c5a93452d8fc67d737801b9 Mon Sep 17 00:00:00 2001 From: Kartikey Gupta Date: Sat, 13 Dec 2025 22:17:55 +0530 Subject: [PATCH 5/8] fix: Add _mapReady guard to _updateVisibleTrees Prevents accessing _mapController.camera before map is initialized, avoiding potential errors when called from _loadTrees() before _onMapReady() --- lib/pages/explore_trees_map_page.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/pages/explore_trees_map_page.dart b/lib/pages/explore_trees_map_page.dart index cd09316..0437262 100644 --- a/lib/pages/explore_trees_map_page.dart +++ b/lib/pages/explore_trees_map_page.dart @@ -154,6 +154,9 @@ class _ExploreTreesMapPageState extends State { void _updateVisibleTrees() { if (!mounted) return; + // Guard: don't access camera before map is ready + if (!_mapReady) return; + final bounds = _mapController.camera.visibleBounds; final zoom = _mapController.camera.zoom; From 06a3f9b429e3419d7ce8de501b7a10aeee5e3126 Mon Sep 17 00:00:00 2001 From: Kartikey Gupta Date: Sat, 13 Dec 2025 22:33:08 +0530 Subject: [PATCH 6/8] fix: Correct isAlive logic for tree death timestamp Tree is alive when death == 0 (not set) or death >= now (future timestamp). Previous logic incorrectly used > instead of >= for comparison. --- lib/services/tree_map_service.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/services/tree_map_service.dart b/lib/services/tree_map_service.dart index 266c363..0cb0aa5 100644 --- a/lib/services/tree_map_service.dart +++ b/lib/services/tree_map_service.dart @@ -36,7 +36,9 @@ class MapTreeData { final lat = _convertCoordinate(data['latitude'] ?? 0); final lng = _convertCoordinate(data['longitude'] ?? 0); final death = data['death'] ?? 0; - final isAlive = death == 0 || death > DateTime.now().millisecondsSinceEpoch ~/ 1000; + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + // Tree is alive if death is 0 (not set) or death timestamp is in the future + final isAlive = death == 0 || death >= now; return MapTreeData( id: data['id'] ?? 0, From e1833f53c4674ee4ac62d75a45f5b3df26e5361b Mon Sep 17 00:00:00 2001 From: Kartikey Gupta Date: Sat, 13 Dec 2025 22:40:05 +0530 Subject: [PATCH 7/8] fix: Correct coordinate conversion and use neighborGeohashes - Split _convertCoordinate into _convertLatitude (-90 offset) and _convertLongitude (-180 offset) for correct decoding - Use neighborGeohashes to pre-filter trees before distance calculations for better performance in getTreesNearLocation() --- lib/services/tree_map_service.dart | 40 ++++++++++++++++++++++++------ 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/lib/services/tree_map_service.dart b/lib/services/tree_map_service.dart index 0cb0aa5..b0b6526 100644 --- a/lib/services/tree_map_service.dart +++ b/lib/services/tree_map_service.dart @@ -33,8 +33,8 @@ class MapTreeData { LatLng get position => LatLng(latitude, longitude); factory MapTreeData.fromContractData(Map data) { - final lat = _convertCoordinate(data['latitude'] ?? 0); - final lng = _convertCoordinate(data['longitude'] ?? 0); + final lat = _convertLatitude(data['latitude'] ?? 0); + final lng = _convertLongitude(data['longitude'] ?? 0); final death = data['death'] ?? 0; final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; // Tree is alive if death is 0 (not set) or death timestamp is in the future @@ -54,9 +54,17 @@ class MapTreeData { ); } - static double _convertCoordinate(int coordinate) { + /// Convert stored latitude from fixed-point to decimal degrees + /// Contract stores as (latitude + 90) * 1000000 + static double _convertLatitude(int coordinate) { return (coordinate / 1000000.0) - 90.0; } + + /// Convert stored longitude from fixed-point to decimal degrees + /// Contract stores as (longitude + 180) * 1000000 + static double _convertLongitude(int coordinate) { + return (coordinate / 1000000.0) - 180.0; + } } /// Cluster of trees for efficient rendering @@ -314,17 +322,33 @@ class TreeMapService { required double longitude, double radiusMeters = 5000, }) async { - final centerGeohash = _geohashService.encode(latitude, longitude); - final neighborGeohashes = _geohashService.getNeighbors(centerGeohash); - // Ensure we have trees loaded if (_allTrees.isEmpty) { await fetchAllTrees(walletProvider: walletProvider, limit: 100); } - // Filter trees within radius + // Use geohash to pre-filter trees for better performance + final centerGeohash = _geohashService.encode(latitude, longitude); + final neighborGeohashes = _geohashService.getNeighbors(centerGeohash); + final geohashSet = neighborGeohashes.toSet(); + + // Pre-filter by geohash (reduces distance calculations) + final candidateTrees = _allTrees.where((tree) { + // Check if tree's geohash matches any neighbor geohash prefix + if (tree.geoHash.isNotEmpty) { + final treePrefix = tree.geoHash.length >= centerGeohash.length + ? tree.geoHash.substring(0, centerGeohash.length) + : tree.geoHash; + return geohashSet.any((gh) => gh.startsWith(treePrefix) || treePrefix.startsWith(gh)); + } + // Compute geohash for trees without one + final computedHash = _geohashService.encode(tree.latitude, tree.longitude); + return geohashSet.any((gh) => computedHash.startsWith(gh) || gh.startsWith(computedHash)); + }).toList(); + + // Filter by exact distance and sort final center = LatLng(latitude, longitude); - return _allTrees.where((tree) { + return candidateTrees.where((tree) { final distance = _geohashService.calculateDistance(center, tree.position); return distance <= radiusMeters; }).toList() From 642aba8056c3604d62c25baf89b460222aaf1023 Mon Sep 17 00:00:00 2001 From: Kartikey Gupta Date: Sat, 13 Dec 2025 22:49:49 +0530 Subject: [PATCH 8/8] fix: address CodeRabbit review comments in tree_map_service - Add type conversion helpers (_asInt, _asString) for contract data - Handle both plantingDate and planting field names - Remove _hasMore gate in fetchTreesInBounds for proper cache population - Fix clamp() to int type mismatch for substring - Return unmodifiable list from allTrees getter - Include centerGeohash in nearby prefilter - Cache distances to avoid recalculating during sort - Fix misleading O(n) complexity comment --- lib/services/tree_map_service.dart | 70 +++++++++++++++++++----------- 1 file changed, 45 insertions(+), 25 deletions(-) diff --git a/lib/services/tree_map_service.dart b/lib/services/tree_map_service.dart index b0b6526..dc8cfc2 100644 --- a/lib/services/tree_map_service.dart +++ b/lib/services/tree_map_service.dart @@ -32,25 +32,41 @@ class MapTreeData { LatLng get position => LatLng(latitude, longitude); + /// Safely convert dynamic value to int (handles BigInt, num, String) + static int _asInt(dynamic v, {int fallback = 0}) { + if (v == null) return fallback; + if (v is int) return v; + if (v is BigInt) return v.toInt(); + if (v is num) return v.toInt(); + return int.tryParse(v.toString()) ?? fallback; + } + + /// Safely convert dynamic value to String + static String _asString(dynamic v, {String fallback = ''}) { + if (v == null) return fallback; + if (v is String) return v; + return v.toString(); + } + factory MapTreeData.fromContractData(Map data) { - final lat = _convertLatitude(data['latitude'] ?? 0); - final lng = _convertLongitude(data['longitude'] ?? 0); - final death = data['death'] ?? 0; + final lat = _convertLatitude(_asInt(data['latitude'])); + final lng = _convertLongitude(_asInt(data['longitude'])); + final death = _asInt(data['death']); final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; // Tree is alive if death is 0 (not set) or death timestamp is in the future final isAlive = death == 0 || death >= now; return MapTreeData( - id: data['id'] ?? 0, + id: _asInt(data['id']), latitude: lat, longitude: lng, - species: data['species'] ?? 'Unknown', - imageUri: data['imageUri'] ?? '', - geoHash: data['geoHash'] ?? '', + species: _asString(data['species'], fallback: 'Unknown'), + imageUri: _asString(data['imageUri']), + geoHash: _asString(data['geoHash']), isAlive: isAlive, - careCount: data['careCount'] ?? 0, - plantingDate: data['planting'] ?? 0, - numberOfTrees: data['numberOfTrees'] ?? 1, + careCount: _asInt(data['careCount']), + plantingDate: _asInt(data['plantingDate'] ?? data['planting']), + numberOfTrees: _asInt(data['numberOfTrees'], fallback: 1), ); } @@ -105,7 +121,8 @@ class TreeMapService { int _totalTreeCount = 0; bool _hasMore = true; - List get allTrees => _allTrees; + /// Returns an unmodifiable view of all trees to prevent external mutation + List get allTrees => List.unmodifiable(_allTrees); int get totalTreeCount => _totalTreeCount; /// Clear all cached data @@ -162,7 +179,7 @@ class TreeMapService { } // Fetch new geohashes if needed - if (geohashesToFetch.isNotEmpty && _hasMore) { + if (geohashesToFetch.isNotEmpty) { await _fetchTreesFromBlockchain( walletProvider: walletProvider, geohashes: geohashesToFetch, @@ -216,7 +233,9 @@ class TreeMapService { final geohash = tree.geoHash.isNotEmpty ? tree.geoHash.substring( 0, - GeohashService.defaultPrecision.clamp(1, tree.geoHash.length)) + GeohashService.defaultPrecision + .clamp(1, tree.geoHash.length) + .toInt()) : _geohashService.encode(tree.latitude, tree.longitude); _addTreeToCache(geohash, tree); @@ -253,10 +272,10 @@ class TreeMapService { await fetchAllTrees(walletProvider: walletProvider, limit: 100); } - // Convert geohashes to Set for O(1) prefix matching + // Convert geohashes to Set for membership check final geohashSet = geohashes.toSet(); - // Single pass over all trees - O(n) instead of O(n×m) + // Iterate over all trees and check prefix matches - O(n * m) for (final tree in _allTrees) { // Compute encoded geohash once per tree final encodedGeohash = @@ -330,7 +349,8 @@ class TreeMapService { // Use geohash to pre-filter trees for better performance final centerGeohash = _geohashService.encode(latitude, longitude); final neighborGeohashes = _geohashService.getNeighbors(centerGeohash); - final geohashSet = neighborGeohashes.toSet(); + // Include center geohash to avoid dropping trees in the current cell + final geohashSet = {centerGeohash, ...neighborGeohashes}; // Pre-filter by geohash (reduces distance calculations) final candidateTrees = _allTrees.where((tree) { @@ -346,16 +366,16 @@ class TreeMapService { return geohashSet.any((gh) => computedHash.startsWith(gh) || gh.startsWith(computedHash)); }).toList(); - // Filter by exact distance and sort + // Filter by exact distance and cache distances to avoid recalculating during sort final center = LatLng(latitude, longitude); - return candidateTrees.where((tree) { + final treesWithDistance = <(MapTreeData, double)>[]; + for (final tree in candidateTrees) { final distance = _geohashService.calculateDistance(center, tree.position); - return distance <= radiusMeters; - }).toList() - ..sort((a, b) { - final distA = _geohashService.calculateDistance(center, a.position); - final distB = _geohashService.calculateDistance(center, b.position); - return distA.compareTo(distB); - }); + if (distance <= radiusMeters) { + treesWithDistance.add((tree, distance)); + } + } + treesWithDistance.sort((a, b) => a.$2.compareTo(b.$2)); + return treesWithDistance.map((e) => e.$1).toList(); } }