diff --git a/lib/main.dart b/lib/main.dart index d519938..40c8d28 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/map_view_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'; @@ -17,6 +18,7 @@ import 'package:tree_planting_protocol/pages/trees_page.dart'; import 'package:tree_planting_protocol/pages/user_profile_page.dart'; import 'package:tree_planting_protocol/pages/mint_nft/mint_nft_coordinates.dart'; +import 'package:tree_planting_protocol/providers/map_provider.dart'; import 'package:tree_planting_protocol/providers/wallet_provider.dart'; import 'package:tree_planting_protocol/providers/theme_provider.dart'; import 'package:tree_planting_protocol/providers/mint_nft_provider.dart'; @@ -152,6 +154,13 @@ class MyApp extends StatelessWidget { ), ], ), + GoRoute( + path: RouteConstants.mapViewPath, + name: RouteConstants.mapView, + builder: (BuildContext context, GoRouterState state) { + return const MapViewPage(); + }, + ), ], errorBuilder: (context, state) => Scaffold( body: Center( @@ -165,6 +174,7 @@ class MyApp extends StatelessWidget { ChangeNotifierProvider(create: (context) => WalletProvider()), ChangeNotifierProvider(create: (context) => ThemeProvider()), ChangeNotifierProvider(create: (context) => MintNftProvider()), + ChangeNotifierProvider(create: (context) => MapProvider()), ], child: Consumer( builder: (context, themeProvider, child) { diff --git a/lib/pages/map_view_page.dart b/lib/pages/map_view_page.dart new file mode 100644 index 0000000..1302617 --- /dev/null +++ b/lib/pages/map_view_page.dart @@ -0,0 +1,410 @@ +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/models/tree_details.dart'; +import 'package:tree_planting_protocol/providers/map_provider.dart'; +import 'package:tree_planting_protocol/providers/wallet_provider.dart'; +import 'package:tree_planting_protocol/utils/constants/ui/color_constants.dart'; +import 'package:tree_planting_protocol/utils/logger.dart'; +import 'package:tree_planting_protocol/utils/services/get_current_location.dart'; +import 'package:tree_planting_protocol/utils/services/tree_map_service.dart'; +import 'package:tree_planting_protocol/widgets/basic_scaffold.dart'; +import 'package:tree_planting_protocol/widgets/map_widgets/tree_map_widgets.dart'; + +class MapViewPage extends StatefulWidget { + const MapViewPage({super.key}); + + @override + State createState() => _MapViewPageState(); +} + +class _MapViewPageState extends State { + final MapController _mapController = MapController(); + Tree? _selectedTree; + bool _isInitialized = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _initializeMap(); + }); + } + + Future _initializeMap() async { + if (_isInitialized) return; + + final mapProvider = Provider.of(context, listen: false); + + try { + // Get user location + final location = await LocationService().getCurrentLocation(); + if (location.isValid) { + mapProvider.setUserLocation(LatLng(location.latitude!, location.longitude!)); + logger.d("User location set: ${location.latitude}, ${location.longitude}"); + } + + _isInitialized = true; + + // Load initial trees + await _loadTreesInArea(); + } catch (e) { + logger.e("Error initializing map: $e"); + mapProvider.setError("Failed to initialize map: $e"); + } + } + + Future _loadTreesInArea() async { + final mapProvider = Provider.of(context, listen: false); + final walletProvider = Provider.of(context, listen: false); + + if (!walletProvider.isConnected) { + mapProvider.setError("Please connect your wallet to view trees"); + return; + } + + mapProvider.setLoading(true); + mapProvider.clearError(); + + try { + final bounds = mapProvider.getBoundingBox(); + + logger.d("Loading trees in bounds: $bounds"); + + final trees = await TreeMapService.getTreesInBoundingBox( + walletProvider: walletProvider, + minLat: bounds['minLat']!, + maxLat: bounds['maxLat']!, + minLng: bounds['minLng']!, + maxLng: bounds['maxLng']!, + maxTrees: 100, + ); + + mapProvider.setLoadedTrees(trees); + + if (trees.isEmpty) { + mapProvider.setError("No trees found in this area. Try moving the map or zooming out."); + } + + logger.d("Loaded ${trees.length} trees"); + } catch (e) { + logger.e("Error loading trees: $e"); + mapProvider.setError("Failed to load trees: $e"); + } finally { + mapProvider.setLoading(false); + } + } + + void _onMapEvent(MapEvent event) { + final mapProvider = Provider.of(context, listen: false); + + if (event is MapEventMove) { + mapProvider.setCurrentCenter(event.camera.center); + mapProvider.setCurrentZoom(event.camera.zoom); + } else if (event is MapEventRotate) { + mapProvider.setCurrentCenter(event.camera.center); + mapProvider.setCurrentZoom(event.camera.zoom); + } + } + + void _zoomIn() { + final mapProvider = Provider.of(context, listen: false); + final newZoom = (mapProvider.currentZoom + 1).clamp(3.0, 18.0); + _mapController.move(mapProvider.currentCenter, newZoom); + } + + void _zoomOut() { + final mapProvider = Provider.of(context, listen: false); + final newZoom = (mapProvider.currentZoom - 1).clamp(3.0, 18.0); + _mapController.move(mapProvider.currentCenter, newZoom); + } + + void _centerOnUser() { + final mapProvider = Provider.of(context, listen: false); + if (mapProvider.hasUserLocation) { + _mapController.move(mapProvider.userLocation!, mapProvider.currentZoom); + } + } + + void _onTreeMarkerTap(Tree tree) { + setState(() { + _selectedTree = tree; + }); + } + + void _closeTreeCard() { + setState(() { + _selectedTree = null; + }); + } + + void _viewTreeDetails(Tree tree) { + context.push('/trees/${tree.id}'); + } + + double _convertLatitude(int coordinate) { + // Encoding: (latitude + 90.0) * 1e6 + return (coordinate / 1000000.0) - 90.0; + } + + double _convertLongitude(int coordinate) { + // Encoding: (longitude + 180.0) * 1e6 + return (coordinate / 1000000.0) - 180.0; + } + + @override + Widget build(BuildContext context) { + return Consumer2( + builder: (context, mapProvider, walletProvider, child) { + return BaseScaffold( + title: "Tree Map", + body: walletProvider.isConnected + ? _buildMapView(mapProvider) + : _buildConnectWalletPrompt(), + ); + }, + ); + } + + Widget _buildMapView(MapProvider mapProvider) { + return Stack( + children: [ + // Map + FlutterMap( + mapController: _mapController, + options: MapOptions( + initialCenter: mapProvider.currentCenter, + initialZoom: mapProvider.currentZoom, + onMapEvent: _onMapEvent, + interactionOptions: const InteractionOptions( + flags: InteractiveFlag.all, + ), + ), + children: [ + // OpenStreetMap tile layer + TileLayer( + urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: 'com.stabilitynexus.treeplantingprotocol', + ), + + // Tree markers + MarkerLayer( + markers: mapProvider.loadedTrees.map((tree) { + final lat = _convertLatitude(tree.latitude); + final lng = _convertLongitude(tree.longitude); + + return Marker( + point: LatLng(lat, lng), + width: 40, + height: 40, + child: TreeMarkerWidget( + tree: tree, + onTap: () => _onTreeMarkerTap(tree), + ), + ); + }).toList(), + ), + + // User location marker + if (mapProvider.hasUserLocation) + MarkerLayer( + markers: [ + Marker( + point: mapProvider.userLocation!, + width: 40, + height: 40, + child: const UserLocationMarker(), + ), + ], + ), + ], + ), + + // Map controls + Positioned( + right: 16, + top: 16, + child: MapControlsWidget( + onZoomIn: _zoomIn, + onZoomOut: _zoomOut, + onCenterUser: _centerOnUser, + onLoadTrees: _loadTreesInArea, + isLoading: mapProvider.isLoading, + hasUserLocation: mapProvider.hasUserLocation, + ), + ), + + // Tree info card + if (_selectedTree != null) + Positioned( + left: 0, + right: 0, + bottom: 0, + child: TreeInfoCard( + tree: _selectedTree!, + onViewDetails: () => _viewTreeDetails(_selectedTree!), + onClose: _closeTreeCard, + ), + ), + + // Error message + if (mapProvider.errorMessage != null) + Positioned( + top: 16, + left: 16, + right: 80, + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.red[100], + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.red), + ), + child: Row( + children: [ + const Icon(Icons.error_outline, color: Colors.red), + const SizedBox(width: 8), + Expanded( + child: Text( + mapProvider.errorMessage!, + style: const TextStyle(color: Colors.red), + ), + ), + IconButton( + icon: const Icon(Icons.close, size: 18), + onPressed: () => mapProvider.clearError(), + color: Colors.red, + ), + ], + ), + ), + ), + + // Loading indicator at top + if (mapProvider.isLoading && mapProvider.errorMessage == null) + Positioned( + top: 16, + left: 16, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 8), + Text( + 'Loading trees...', + style: TextStyle(color: Colors.grey[700]), + ), + ], + ), + ), + ), + + // Trees count + if (!mapProvider.isLoading && mapProvider.loadedTrees.isNotEmpty) + Positioned( + top: 16, + left: 16, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.green, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.eco, color: Colors.white, size: 16), + const SizedBox(width: 4), + Text( + '${mapProvider.loadedTrees.length} trees', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ), + ], + ); + } + + Widget _buildConnectWalletPrompt() { + final themeColors = getThemeColors(context); + final primaryColor = themeColors['primary'] ?? Theme.of(context).colorScheme.primary; + final backgroundColor = themeColors['background'] ?? Theme.of(context).colorScheme.surface; + final borderColor = themeColors['border'] ?? Theme.of(context).colorScheme.outline; + final textPrimaryColor = themeColors['textPrimary'] ?? Theme.of(context).colorScheme.onSurface; + final textSecondaryColor = themeColors['textSecondary'] ?? Theme.of(context).colorScheme.onSurfaceVariant; + + return Center( + child: Container( + margin: const EdgeInsets.all(24), + padding: const EdgeInsets.all(32), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: borderColor, + width: 2, + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.account_balance_wallet, + size: 64, + color: primaryColor, + ), + const SizedBox(height: 16), + Text( + 'Connect Your Wallet', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: textPrimaryColor, + ), + ), + const SizedBox(height: 8), + Text( + 'Please connect your wallet to view trees on the map', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16, + color: textSecondaryColor, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/providers/map_provider.dart b/lib/providers/map_provider.dart new file mode 100644 index 0000000..37e03ec --- /dev/null +++ b/lib/providers/map_provider.dart @@ -0,0 +1,164 @@ +import 'package:flutter/material.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:tree_planting_protocol/models/tree_details.dart'; +import 'package:tree_planting_protocol/utils/logger.dart'; + +class MapProvider extends ChangeNotifier { + // Map state + LatLng _currentCenter = LatLng(28.7041, 77.1025); // Default to Delhi, India + double _currentZoom = 13.0; + LatLng? _userLocation; + + // Tree data + List _loadedTrees = []; + bool _isLoading = false; + String? _errorMessage; + + // Map bounds for tracking viewport changes + LatLng? _lastFetchCenter; + double? _lastFetchZoom; + + // Configuration + static const double _significantMoveThreshold = 0.01; // ~1km + static const double _significantZoomThreshold = 1.0; + + // Getters + LatLng get currentCenter => _currentCenter; + double get currentZoom => _currentZoom; + LatLng? get userLocation => _userLocation; + List get loadedTrees => _loadedTrees; + bool get isLoading => _isLoading; + String? get errorMessage => _errorMessage; + bool get hasUserLocation => _userLocation != null; + + // Setters + void setCurrentCenter(LatLng center) { + _currentCenter = center; + notifyListeners(); + } + + void setCurrentZoom(double zoom) { + _currentZoom = zoom; + notifyListeners(); + } + + void setUserLocation(LatLng location) { + _userLocation = location; + _currentCenter = location; // Center map on user location + notifyListeners(); + } + + void setLoadedTrees(List trees) { + _loadedTrees = trees; + _lastFetchCenter = _currentCenter; + _lastFetchZoom = _currentZoom; + notifyListeners(); + } + + void setLoading(bool loading) { + _isLoading = loading; + notifyListeners(); + } + + void setError(String? error) { + _errorMessage = error; + notifyListeners(); + } + + void clearError() { + _errorMessage = null; + notifyListeners(); + } + + // Check if map has moved significantly since last fetch + bool shouldRefetchTrees() { + if (_lastFetchCenter == null || _lastFetchZoom == null) { + return true; + } + + final distance = _calculateDistance( + _lastFetchCenter!.latitude, + _lastFetchCenter!.longitude, + _currentCenter.latitude, + _currentCenter.longitude, + ); + + final zoomDiff = (_currentZoom - _lastFetchZoom!).abs(); + + return distance > _significantMoveThreshold || + zoomDiff > _significantZoomThreshold; + } + + // Calculate distance between two points (simple Euclidean for threshold check) + double _calculateDistance(double lat1, double lon1, double lat2, double lon2) { + final latDiff = lat1 - lat2; + final lonDiff = lon1 - lon2; + return (latDiff * latDiff + lonDiff * lonDiff); + } + + // Get bounding box for current map viewport + Map getBoundingBox() { + // Approximate: 1 degree latitude ≈ 111km + // Longitude varies by latitude, but for simplicity we use a rough estimate + final latDelta = 0.05 / _currentZoom; // Adjust based on zoom + final lngDelta = 0.05 / _currentZoom; + + return { + 'minLat': _currentCenter.latitude - latDelta, + 'maxLat': _currentCenter.latitude + latDelta, + 'minLng': _currentCenter.longitude - lngDelta, + 'maxLng': _currentCenter.longitude + lngDelta, + }; + } + + // Add a single tree to the loaded trees + void addTree(Tree tree) { + _loadedTrees.add(tree); + notifyListeners(); + } + + // Remove a tree from loaded trees + void removeTree(int treeId) { + _loadedTrees.removeWhere((tree) => tree.id == treeId); + notifyListeners(); + } + + // Update a tree in the loaded trees + void updateTree(Tree updatedTree) { + final index = _loadedTrees.indexWhere((tree) => tree.id == updatedTree.id); + if (index != -1) { + _loadedTrees[index] = updatedTree; + notifyListeners(); + } + } + + // Clear all loaded trees + void clearTrees() { + _loadedTrees.clear(); + _lastFetchCenter = null; + _lastFetchZoom = null; + notifyListeners(); + } + + // Reset map to user location + void centerOnUser() { + if (_userLocation != null) { + _currentCenter = _userLocation!; + notifyListeners(); + logger.d("Map centered on user location: $_userLocation"); + } + } + + // Reset provider state + void reset() { + _currentCenter = LatLng(28.7041, 77.1025); + _currentZoom = 13.0; + _userLocation = null; + _loadedTrees.clear(); + _isLoading = false; + _errorMessage = null; + _lastFetchCenter = null; + _lastFetchZoom = null; + notifyListeners(); + } +} diff --git a/lib/utils/constants/bottom_nav_constants.dart b/lib/utils/constants/bottom_nav_constants.dart index 9ad0a0f..0fc0f3c 100644 --- a/lib/utils/constants/bottom_nav_constants.dart +++ b/lib/utils/constants/bottom_nav_constants.dart @@ -29,6 +29,12 @@ class BottomNavConstants { activeIcon: Icons.forest, route: RouteConstants.allTreesPath, ), + BottomNavItem( + label: 'Map', + icon: Icons.map_outlined, + activeIcon: Icons.map, + route: RouteConstants.mapViewPath, + ), BottomNavItem( label: 'Settings', icon: Icons.settings_outlined, diff --git a/lib/utils/constants/route_constants.dart b/lib/utils/constants/route_constants.dart index 0e3877a..35e21c7 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 mapView = '/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 mapViewPath = '/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/utils/services/tree_map_service.dart b/lib/utils/services/tree_map_service.dart new file mode 100644 index 0000000..9d9d3db --- /dev/null +++ b/lib/utils/services/tree_map_service.dart @@ -0,0 +1,235 @@ +import 'dart:math' as math; +import 'package:dart_geohash/dart_geohash.dart'; +import 'package:tree_planting_protocol/models/tree_details.dart'; +import 'package:tree_planting_protocol/providers/wallet_provider.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'; + +class TreeMapService { + static final GeoHasher _geoHasher = GeoHasher(); + + /// Fetch trees within a bounding box by filtering from recent trees + /// This is the client-side approach - filters fetched trees by location + static Future> getTreesInBoundingBox({ + required WalletProvider walletProvider, + required double minLat, + required double maxLat, + required double minLng, + required double maxLng, + int maxTrees = 100, + }) async { + try { + logger.d("Fetching trees in bounding box: " + "lat[$minLat, $maxLat], lng[$minLng, $maxLng]"); + + // Fetch a large batch of recent trees + // In future, this could be optimized with backend or contract-level filtering + final result = await ContractReadFunctions.getRecentTreesPaginated( + walletProvider: walletProvider, + offset: 0, + limit: math.max(50, maxTrees), // Fetch enough trees to satisfy maxTrees after filtering + ); + + if (!result.success || result.data == null) { + logger.e("Failed to fetch trees: ${result.errorMessage}"); + return []; + } + + final List treesData = result.data['trees'] ?? []; + final List allTrees = []; + + // Parse trees from contract data + for (var treeData in treesData) { + try { + final tree = _parseTreeFromMap(treeData); + allTrees.add(tree); + } catch (e) { + logger.e("Error parsing tree: $e"); + continue; + } + } + + // Filter trees within bounding box + final List treesInBounds = allTrees.where((tree) { + final lat = _convertLatitude(tree.latitude); + final lng = _convertLongitude(tree.longitude); + + return lat >= minLat && + lat <= maxLat && + lng >= minLng && + lng <= maxLng; + }).take(maxTrees).toList(); + + logger.d("Found ${treesInBounds.length} trees in bounding box"); + return treesInBounds; + + } catch (e) { + logger.e("Error fetching trees in bounding box: $e"); + return []; + } + } + + /// Fetch trees near a specific location using geohash + /// Returns trees within the same geohash and neighboring geohashes + static Future> getTreesNearLocation({ + required WalletProvider walletProvider, + required double latitude, + required double longitude, + int precision = 6, // ~1.2km x 0.6km + int maxTrees = 100, + }) async { + try { + final centerGeohash = _geoHasher.encode(longitude, latitude, precision: precision); + logger.d("Searching for trees near geohash: $centerGeohash"); + + // Get neighboring geohashes to cover boundary cases + final neighborsMap = _geoHasher.neighbors(centerGeohash); + final neighbors = neighborsMap.values.toList(); + final geohashesToCheck = [centerGeohash, ...neighbors]; + + logger.d("Checking ${geohashesToCheck.length} geohash regions"); + + // Fetch trees and filter by geohash prefix + final result = await ContractReadFunctions.getRecentTreesPaginated( + walletProvider: walletProvider, + offset: 0, + limit: math.max(50, maxTrees), // Fetch enough trees to satisfy maxTrees after filtering + ); + + if (!result.success || result.data == null) { + logger.e("Failed to fetch trees: ${result.errorMessage}"); + return []; + } + + final List treesData = result.data['trees'] ?? []; + final List matchingTrees = []; + + for (var treeData in treesData) { + try { + final tree = _parseTreeFromMap(treeData); + + // Check if tree's geohash matches any of our target geohashes + if (_isGeohashInList(tree.geoHash, geohashesToCheck, precision)) { + matchingTrees.add(tree); + } + + if (matchingTrees.length >= maxTrees) break; + } catch (e) { + logger.e("Error parsing tree: $e"); + continue; + } + } + + logger.d("Found ${matchingTrees.length} trees near location"); + return matchingTrees; + + } catch (e) { + logger.e("Error fetching trees near location: $e"); + return []; + } + } + + /// Calculate distance between two coordinates in kilometers + static double calculateDistance( + double lat1, + double lon1, + double lat2, + double lon2, + ) { + const double earthRadius = 6371; // km + + final dLat = _degreesToRadians(lat2 - lat1); + final dLon = _degreesToRadians(lon2 - lon1); + + final a = math.sin(dLat / 2) * math.sin(dLat / 2) + + math.cos(_degreesToRadians(lat1)) * + math.cos(_degreesToRadians(lat2)) * + math.sin(dLon / 2) * + math.sin(dLon / 2); + + final c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)); + + return earthRadius * c; + } + + /// Get geohash for a coordinate + static String getGeohash(double latitude, double longitude, {int precision = 6}) { + return _geoHasher.encode(longitude, latitude, precision: precision); + } + + /// Get neighboring geohashes + static List getNeighboringGeohashes(String geohash) { + return _geoHasher.neighbors(geohash).values.toList(); + } + + /// Decode geohash to coordinates + static Map decodeGeohash(String geohash) { + final decoded = _geoHasher.decode(geohash); + return { + 'latitude': decoded[0], + 'longitude': decoded[1], + }; + } + + // Private helper methods + + static Tree _parseTreeFromMap(Map treeData) { + return Tree( + id: treeData['id'] as int, + latitude: treeData['latitude'] as int, + longitude: treeData['longitude'] as int, + planting: treeData['planting'] as int, + death: treeData['death'] as int, + species: treeData['species'] as String, + imageUri: treeData['imageUri'] as String, + qrIpfsHash: treeData['qrPhoto'] as String, + metadata: treeData['metadata'] as String, + photos: List.from(treeData['photos'] ?? []), + geoHash: treeData['geoHash'] as String, + ancestors: (treeData['ancestors'] as List? ?? []) + .map((e) => e.toString()) + .toList(), + lastCareTimestamp: treeData['lastCareTimestamp'] as int, + careCount: treeData['careCount'] as int, + verifiers: [], // Not included in map data + owner: '', // Not included in map data + ); + } + + static double _convertLatitude(int coordinate) { + // Convert from fixed-point representation to decimal degrees + // Encoding: (latitude + 90.0) * 1e6 + return (coordinate / 1000000.0) - 90.0; + } + + static double _convertLongitude(int coordinate) { + // Convert from fixed-point representation to decimal degrees + // Encoding: (longitude + 180.0) * 1e6 + return (coordinate / 1000000.0) - 180.0; + } + + static bool _isGeohashInList(String treeGeohash, List targetGeohashes, int precision) { + if (treeGeohash.isEmpty) return false; + + // Compare geohash prefix up to the specified precision + final treePrefix = treeGeohash.length >= precision + ? treeGeohash.substring(0, precision) + : treeGeohash; + + for (var targetHash in targetGeohashes) { + final targetPrefix = targetHash.length >= precision + ? targetHash.substring(0, precision) + : targetHash; + + if (treePrefix == targetPrefix) { + return true; + } + } + + return false; + } + + static double _degreesToRadians(double degrees) { + return degrees * (math.pi / 180); + } +} diff --git a/lib/widgets/map_widgets/tree_map_widgets.dart b/lib/widgets/map_widgets/tree_map_widgets.dart new file mode 100644 index 0000000..4dbf836 --- /dev/null +++ b/lib/widgets/map_widgets/tree_map_widgets.dart @@ -0,0 +1,347 @@ +import 'package:flutter/material.dart'; +import 'package:tree_planting_protocol/models/tree_details.dart'; +import 'package:tree_planting_protocol/utils/constants/ui/color_constants.dart'; + +class TreeMarkerWidget extends StatelessWidget { + final Tree tree; + final VoidCallback onTap; + + const TreeMarkerWidget({ + super.key, + required this.tree, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final isDead = tree.death > 0; + final markerColor = isDead ? Colors.red : Colors.green; + + return GestureDetector( + onTap: onTap, + child: Container( + decoration: BoxDecoration( + color: markerColor, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Icon( + isDead ? Icons.close : Icons.eco, + color: Colors.white, + size: 20, + ), + ), + ); + } +} + +class UserLocationMarker extends StatelessWidget { + const UserLocationMarker({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Colors.blue, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 3), + boxShadow: [ + BoxShadow( + color: Colors.blue.withValues(alpha: 0.3), + blurRadius: 8, + spreadRadius: 2, + ), + ], + ), + child: const Icon( + Icons.my_location, + color: Colors.white, + size: 20, + ), + ); + } +} + +class MapControlsWidget extends StatelessWidget { + final VoidCallback onZoomIn; + final VoidCallback onZoomOut; + final VoidCallback onCenterUser; + final VoidCallback onLoadTrees; + final bool isLoading; + final bool hasUserLocation; + + const MapControlsWidget({ + super.key, + required this.onZoomIn, + required this.onZoomOut, + required this.onCenterUser, + required this.onLoadTrees, + required this.isLoading, + required this.hasUserLocation, + }); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Zoom controls + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + IconButton( + icon: const Icon(Icons.add), + onPressed: onZoomIn, + tooltip: 'Zoom In', + ), + Container( + height: 1, + color: Colors.grey[300], + ), + IconButton( + icon: const Icon(Icons.remove), + onPressed: onZoomOut, + tooltip: 'Zoom Out', + ), + ], + ), + ), + const SizedBox(height: 8), + + // Center on user location + if (hasUserLocation) + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: IconButton( + icon: const Icon(Icons.my_location, color: Colors.blue), + onPressed: onCenterUser, + tooltip: 'Center on Me', + ), + ), + const SizedBox(height: 8), + + // Load trees button + Container( + decoration: BoxDecoration( + color: getThemeColors(context)['primary'] ?? Theme.of(context).colorScheme.primary, + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: IconButton( + icon: isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : const Icon(Icons.refresh, color: Colors.white), + onPressed: isLoading ? null : onLoadTrees, + tooltip: 'Load Trees in Area', + ), + ), + ], + ); + } +} + +class TreeInfoCard extends StatelessWidget { + final Tree tree; + final VoidCallback onViewDetails; + final VoidCallback onClose; + + const TreeInfoCard({ + super.key, + required this.tree, + required this.onViewDetails, + required this.onClose, + }); + + double _convertLatitude(int coordinate) { + // Encoding: (latitude + 90.0) * 1e6 + return (coordinate / 1000000.0) - 90.0; + } + + double _convertLongitude(int coordinate) { + // Encoding: (longitude + 180.0) * 1e6 + return (coordinate / 1000000.0) - 180.0; + } + + String _formatDate(int timestamp) { + if (timestamp == 0) return 'N/A'; + final date = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000); + return '${date.day}/${date.month}/${date.year}'; + } + + @override + Widget build(BuildContext context) { + final isDead = tree.death > 0; + + return Container( + margin: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Header + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isDead ? Colors.red[50] : Colors.green[50], + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(12), + topRight: Radius.circular(12), + ), + ), + child: Row( + children: [ + Icon( + isDead ? Icons.close : Icons.eco, + color: isDead ? Colors.red : Colors.green, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + tree.species.isNotEmpty ? tree.species : 'Unknown Species', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: isDead ? Colors.red[900] : Colors.green[900], + ), + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: onClose, + iconSize: 20, + ), + ], + ), + ), + + // Content + Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildInfoRow( + Icons.tag, + 'Tree ID', + '#${tree.id}', + ), + _buildInfoRow( + Icons.location_on, + 'Location', + '${_convertLatitude(tree.latitude).toStringAsFixed(5)}, ' + '${_convertLongitude(tree.longitude).toStringAsFixed(5)}', + ), + _buildInfoRow( + Icons.calendar_today, + 'Planted', + _formatDate(tree.planting), + ), + if (isDead) + _buildInfoRow( + Icons.event_busy, + 'Died', + _formatDate(tree.death), + ), + _buildInfoRow( + Icons.favorite, + 'Care Count', + '${tree.careCount} times', + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: onViewDetails, + icon: const Icon(Icons.visibility), + label: const Text('View Full Details'), + style: ElevatedButton.styleFrom( + backgroundColor: getThemeColors(context)['primary'] ?? Theme.of(context).colorScheme.primary, + foregroundColor: Colors.white, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildInfoRow(IconData icon, String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Icon(icon, size: 16, color: Colors.grey[600]), + const SizedBox(width: 8), + Text( + '$label: ', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.grey[700], + ), + ), + Expanded( + child: Text( + value, + style: const TextStyle(color: Colors.black87), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/nft_display_utils/recent_trees_widget.dart b/lib/widgets/nft_display_utils/recent_trees_widget.dart index 426c65e..3ed77cb 100644 --- a/lib/widgets/nft_display_utils/recent_trees_widget.dart +++ b/lib/widgets/nft_display_utils/recent_trees_widget.dart @@ -93,10 +93,17 @@ class _RecentTreesWidgetState extends State { await _loadTrees(); } - double _convertCoordinate(int coordinate) { + double _convertLatitude(int coordinate) { // Convert from fixed-point representation to decimal degrees + // Encoding: (latitude + 90.0) * 1e6 return (coordinate / 1000000.0) - 90.0; } + + double _convertLongitude(int coordinate) { + // Convert from fixed-point representation to decimal degrees + // Encoding: (longitude + 180.0) * 1e6 + return (coordinate / 1000000.0) - 180.0; + } String _formatDate(int timestamp) { if (timestamp == 0) return "Unknown"; @@ -253,7 +260,7 @@ class _RecentTreesWidgetState extends State { const SizedBox(width: 4), Expanded( child: Text( - 'Location: ${_convertCoordinate(tree['latitude']).toStringAsFixed(6)}, ${_convertCoordinate(tree['longitude']).toStringAsFixed(6)}', + 'Location: ${_convertLatitude(tree['latitude']).toStringAsFixed(6)}, ${_convertLongitude(tree['longitude']).toStringAsFixed(6)}', style: TextStyle( fontSize: 14, color: getThemeColors(context)['textPrimary'], diff --git a/lib/widgets/organisation_details_page/tabs/planting_proposals_tab.dart b/lib/widgets/organisation_details_page/tabs/planting_proposals_tab.dart index ad384ff..e2295e3 100644 --- a/lib/widgets/organisation_details_page/tabs/planting_proposals_tab.dart +++ b/lib/widgets/organisation_details_page/tabs/planting_proposals_tab.dart @@ -119,9 +119,16 @@ class _PlantingProposalsTabState extends State return '${address.substring(0, 6)}...${address.substring(address.length - 4)}'; } - double _convertCoordinate(int coordinate) { + double _convertLatitude(int coordinate) { // Convert from fixed-point representation to decimal degrees - return coordinate / 1000000.0; + // Encoding: (latitude + 90.0) * 1e6 + return (coordinate / 1000000.0) - 90.0; + } + + double _convertLongitude(int coordinate) { + // Convert from fixed-point representation to decimal degrees + // Encoding: (longitude + 180.0) * 1e6 + return (coordinate / 1000000.0) - 180.0; } Widget _buildProposalCard(Map proposal) { @@ -200,8 +207,8 @@ class _PlantingProposalsTabState extends State ), const SizedBox(width: 4), Text( - 'Lat: ${_convertCoordinate(proposal['latitude']).toStringAsFixed(6)}, ' - 'Lng: ${_convertCoordinate(proposal['longitude']).toStringAsFixed(6)}', + 'Lat: ${_convertLatitude(proposal['latitude']).toStringAsFixed(6)}, ' + 'Lng: ${_convertLongitude(proposal['longitude']).toStringAsFixed(6)}', style: TextStyle( fontSize: 12, color: getThemeColors(context)['textPrimary'],