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..0437262 --- /dev/null +++ b/lib/pages/explore_trees_map_page.dart @@ -0,0 +1,1066 @@ +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/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 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 = []; + + // 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 + + @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 (!mounted) return; + + if (locationInfo.isValid) { + final location = LatLng(locationInfo.latitude!, locationInfo.longitude!); + setState(() { + _userLocation = location; + }); + + // Move map to user location if ready, otherwise defer + _moveMapTo(location, 12.0); + } + } catch (e) { + logger.w('Could not get user location: $e'); + // Use default center + } + } + + /// 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); + + 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; + + // Guard: don't access camera before map is ready + if (!_mapReady) 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 + _moveMapTo( + 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 + _moveMapTo(cluster.center, _currentZoom + 2); + } + } + + void _centerOnUserLocation() async { + if (_userLocation != null) { + _moveMapTo(_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: _onMapReady, + ), + 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.length >= 6 + ? tree.geoHash.substring(0, 6) + : tree.geoHash, + ), + ], + ), + + 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..d9daedd --- /dev/null +++ b/lib/services/geohash_service.dart @@ -0,0 +1,148 @@ +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) + /// Uses dart_geohash's native neighbor() function for correct traversal + List getNeighbors(String geohash) { + if (geohash.isEmpty) return []; + + final neighbors = [geohash]; + + // 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 = _geoHasher.neighbor(geohash, direction); + if (neighbor.isNotEmpty) { + neighbors.add(neighbor); + } + } catch (_) { + // Skip invalid neighbors at edges (e.g., at poles or date line) + } + } + + return neighbors; + } + + /// 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..dc8cfc2 --- /dev/null +++ b/lib/services/tree_map_service.dart @@ -0,0 +1,381 @@ +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); + + /// 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(_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: _asInt(data['id']), + latitude: lat, + longitude: lng, + species: _asString(data['species'], fallback: 'Unknown'), + imageUri: _asString(data['imageUri']), + geoHash: _asString(data['geoHash']), + isAlive: isAlive, + careCount: _asInt(data['careCount']), + plantingDate: _asInt(data['plantingDate'] ?? data['planting']), + numberOfTrees: _asInt(data['numberOfTrees'], fallback: 1), + ); + } + + /// 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 +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 = {}; + // O(1) lookup for duplicate prevention per geohash + final Map> _treeCacheIds = {}; + final Set _loadingGeohashes = {}; + + // All loaded trees + List _allTrees = []; + int _totalTreeCount = 0; + bool _hasMore = true; + + /// 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 + 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, + 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) { + 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 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) + .toInt()) + : _geohashService.encode(tree.latitude, tree.longitude); + + _addTreeToCache(geohash, 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); + } + + // Convert geohashes to Set for membership check + final geohashSet = geohashes.toSet(); + + // Iterate over all trees and check prefix matches - O(n * m) + for (final tree in _allTrees) { + // 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); + } + } + } + } 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 { + // Ensure we have trees loaded + if (_allTrees.isEmpty) { + await fetchAllTrees(walletProvider: walletProvider, limit: 100); + } + + // Use geohash to pre-filter trees for better performance + final centerGeohash = _geohashService.encode(latitude, longitude); + final neighborGeohashes = _geohashService.getNeighbors(centerGeohash); + // 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) { + // 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 cache distances to avoid recalculating during sort + final center = LatLng(latitude, longitude); + final treesWithDistance = <(MapTreeData, double)>[]; + for (final tree in candidateTrees) { + final distance = _geohashService.calculateDistance(center, tree.position); + if (distance <= radiusMeters) { + treesWithDistance.add((tree, distance)); + } + } + treesWithDistance.sort((a, b) => a.$2.compareTo(b.$2)); + return treesWithDistance.map((e) => e.$1).toList(); + } +} 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..084eaca --- /dev/null +++ b/lib/widgets/map_widgets/map_filter_widget.dart @@ -0,0 +1,438 @@ +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; + 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, + }); + + /// 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, + Object? speciesFilter = _unset, + Object? minCareCount = _unset, + Object? plantedAfter = _unset, + Object? plantedBefore = _unset, + }) { + return MapFilterOptions( + showAliveOnly: showAliveOnly ?? this.showAliveOnly, + showDeceasedOnly: showDeceasedOnly ?? this.showDeceasedOnly, + 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?, + ); + } + + 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..4eaa9a7 --- /dev/null +++ b/lib/widgets/map_widgets/nearby_trees_widget.dart @@ -0,0 +1,425 @@ +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 { + if (!mounted) return; + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + // Get user location + final locationInfo = await _locationService.getCurrentLocationWithTimeout( + timeout: const Duration(seconds: 15), + ); + + // Check mounted after await + if (!mounted) return; + + if (!locationInfo.isValid) { + setState(() { + _errorMessage = 'Could not determine your location'; + _isLoading = false; + }); + return; + } + + _userLocation = LatLng(locationInfo.latitude!, locationInfo.longitude!); + + // 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; + }); + return; + } + + // Fetch nearby trees + final trees = await _treeMapService.getTreesNearLocation( + walletProvider: walletProvider, + latitude: _userLocation!.latitude, + longitude: _userLocation!.longitude, + 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; + }); + } + } + + 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..cdc2920 --- /dev/null +++ b/lib/widgets/map_widgets/tree_heatmap_layer.dart @@ -0,0 +1,234 @@ +import 'dart:ui' as ui; +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:tree_planting_protocol/services/tree_map_service.dart'; + +/// 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 MapCamera camera; + final double opacity; + final double baseRadius; + + const TreeHeatmapLayer({ + super.key, + required this.trees, + required this.camera, + this.opacity = 0.5, + this.baseRadius = 30, + }); + + @override + Widget build(BuildContext context) { + // 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(camera.zoom), + ), + size: Size.infinite, + ); + } + + double _getRadiusForZoom(double zoom) { + // 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) { + 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.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; + + 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: const [ + 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'],