diff --git a/lib/modules/asset_tracking/asset_tracking_provider.dart b/lib/modules/asset_tracking/asset_tracking_provider.dart index 59cdb7cf..40eb9952 100644 --- a/lib/modules/asset_tracking/asset_tracking_provider.dart +++ b/lib/modules/asset_tracking/asset_tracking_provider.dart @@ -61,6 +61,7 @@ class AssetTrackingProvider extends ChangeNotifier { selectedSite = null; assetsList = []; assetsOnTheFloorList = []; + coordinates = []; notify(); } diff --git a/lib/modules/asset_tracking/map_tracking_page.dart b/lib/modules/asset_tracking/map_tracking_page.dart index 66075eb8..b9507306 100644 --- a/lib/modules/asset_tracking/map_tracking_page.dart +++ b/lib/modules/asset_tracking/map_tracking_page.dart @@ -27,6 +27,22 @@ import 'models/floor_map_model.dart'; import 'models/penguin_site_model.dart'; import 'models/zone_data_model.dart'; +class GeoZone { + final Map properties; + final List points; + final List assets; + + GeoZone({required this.properties, required this.points, required this.assets}); + + String get name { + for (final key in const ['name', 'Name', 'label', 'title', 'zoneName', 'id']) { + final value = properties[key]; + if (value != null && value.toString().trim().isNotEmpty) return value.toString(); + } + return "Zone"; + } +} + class MapTrackingPage extends StatefulWidget { final AssetTrackingModel? assetTrackingData; @@ -103,6 +119,7 @@ class _MapTrackingPageState extends State { void _onFloorMapLoaded(FloorMapModel? loadedFloorMap) { floorMapModel = loadedFloorMap; + _selectedZoneProperties = null; if (floorMapModel?.geojsonData == null) { setState(() {}); @@ -113,14 +130,18 @@ class _MapTrackingPageState extends State { final decoded = floorMapModel!.geojsonData!; final bounds = _computeBoundsFromGeoJson(decoded); + final assetLocation = widget.assetTrackingData?.location; + final hasAssetPosition = !inAppView && assetLocation?.latitude != null && assetLocation?.longitude != null; + setState(() { _geojson = decoded; _computedBounds = bounds; markers = [ - Marker( - point: LatLng(widget.assetTrackingData!.location!.latitude!, widget.assetTrackingData!.location!.longitude!), - child: const Icon(Icons.location_pin, color: AppColor.red30), - ), + if (hasAssetPosition) + Marker( + point: LatLng(assetLocation!.latitude!, assetLocation.longitude!), + child: const Icon(Icons.location_pin, color: AppColor.red30), + ), ]; }); @@ -172,6 +193,175 @@ class _MapTrackingPageState extends State { ); } + Map? _selectedZoneProperties; + + Polygon _buildZonePolygon(List points, List>? holes, Map properties, List floorAssets) { + final base = defaultOnPolygon(points, holes, properties, defaults: GeoJsonStyleDefaults.leaflet); + final zoneAssets = floorAssets.where((asset) => _isAssetInsideZone(asset, points, holes)).toList(); + final zone = GeoZone(properties: properties, points: points, assets: zoneAssets); + final isSelected = identical(properties, _selectedZoneProperties); + + return Polygon( + points: base.points, + holePointsList: base.holePointsList, + color: isSelected ? AppColor.red30.withOpacity(0.35) : base.color, + borderColor: isSelected ? AppColor.red30 : base.borderColor, + borderStrokeWidth: base.borderStrokeWidth, + // Count is shown as a circular badge marker (see _buildZoneBadgeMarkers). + hitValue: zone, + ); + } + + /// Parses polygon features from [_geojson] and returns a circular badge marker + /// (with the asset count) placed at the center of every zone that has assets. + List _buildZoneBadgeMarkers(List floorAssets) { + if (!inAppView || floorAssets.isEmpty || _geojson == null) return const []; + + List parseRing(dynamic ring) => [ + for (final c in (ring as List)) + if (c is List && c.length >= 2 && c[0] is num && c[1] is num) LatLng((c[1] as num).toDouble(), (c[0] as num).toDouble()), + ]; + + final badges = []; + + void addZone(dynamic polygonCoords, Map properties) { + if (polygonCoords is! List || polygonCoords.isEmpty) return; + final outer = parseRing(polygonCoords.first); + if (outer.length < 3) return; + final holes = [for (final r in polygonCoords.skip(1)) parseRing(r)].where((h) => h.length >= 3).toList(); + + final zoneAssets = floorAssets.where((a) => _isAssetInsideZone(a, outer, holes)).toList(); + if (zoneAssets.isEmpty) return; + + final center = Polygon( + points: outer, + holePointsList: holes.isEmpty ? null : holes, + labelPlacementCalculator: const PolygonLabelPlacementCalculator.simpleCentroid(), + ).labelPosition; + final zone = GeoZone(properties: properties, points: outer, assets: zoneAssets); + + badges.add( + Marker( + point: center, + width: 21, + height: 21, + child: GestureDetector( + onTap: () => _showZoneDetails(zone), + child: _ZoneCountBadge(count: zoneAssets.length), + ), + ), + ); + } + + for (final feature in (_geojson!['features'] as List? ?? const [])) { + if (feature is! Map) continue; + final geometry = feature['geometry']; + if (geometry is! Map) continue; + final properties = (feature['properties'] as Map?) ?? {}; + final coordinates = geometry['coordinates']; + switch (geometry['type']) { + case 'Polygon': + addZone(coordinates, properties); + case 'MultiPolygon': + for (final polygon in (coordinates as List? ?? const [])) { + addZone(polygon, properties); + } + } + } + return badges; + } + + bool _isAssetInsideZone(AssetTrackingModel asset, List outer, List>? holes) { + final lat = asset.location?.latitude; + final lng = asset.location?.longitude; + if (lat == null || lng == null) return false; + final point = LatLng(lat, lng); + if (!_isPointInRing(point, outer)) return false; + for (final hole in holes ?? const >[]) { + if (_isPointInRing(point, hole)) return false; + } + return true; + } + + /// Ray-casting point-in-polygon test (longitude = x, latitude = y). + bool _isPointInRing(LatLng point, List ring) { + bool inside = false; + final x = point.longitude, y = point.latitude; + for (int i = 0, j = ring.length - 1; i < ring.length; j = i++) { + final xi = ring[i].longitude, yi = ring[i].latitude; + final xj = ring[j].longitude, yj = ring[j].latitude; + final intersects = ((yi > y) != (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi); + if (intersects) inside = !inside; + } + return inside; + } + + void _onFeatureTapped() { + final hitResult = _polygonHitNotifier.value; + if (hitResult == null) return; + final zone = hitResult.hitValues.whereType().firstOrNull; + if (zone == null) return; + + setState(() { + // _selectedZoneProperties = zone.properties; + // if (inAppView) markers = [for (final asset in zone.assets) _buildAssetMarker(asset)]; + }); + _showZoneDetails(zone); + } + + Marker _buildAssetMarker(AssetTrackingModel asset) { + return Marker( + point: LatLng(asset.location!.latitude!, asset.location!.longitude!), + child: Tooltip( + triggerMode: TooltipTriggerMode.tap, + message: "${asset.label ?? ''}\nLast seen: ${asset.location?.lastSeen?.toServiceRequestDetailsFormat}", + child: const Icon(Icons.location_pin, color: AppColor.red30), + ), + ); + } + + void _showZoneDetails(GeoZone zone) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (sheetContext) { + return Container( + constraints: BoxConstraints(maxHeight: MediaQuery.of(sheetContext).size.height * 0.6), + decoration: BoxDecoration( + color: AppColor.background(sheetContext), + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(zone.name, style: AppTextStyles.heading5.copyWith(color: AppColor.textColor(sheetContext))), + 4.height, + InfoTextLabelWidget(label: inAppView ? "${zone.assets.length} Asset(s) in this zone" : "Zone"), + if (inAppView && zone.assets.isNotEmpty) ...[ + 12.height, + Flexible( + child: ListView.separated( + shrinkWrap: true, + itemCount: zone.assets.length, + separatorBuilder: (_, __) => 12.height, + itemBuilder: (_, index) => AssetTrackingListItemView(zone.assets[index], false), + ), + ), + ], + ], + ), + ), + ); + }, + ).whenComplete(() { + if (mounted) setState(() => _selectedZoneProperties = null); + }); + } + double _dragPosition = 0.5; late double _sheetPosition = _dragPosition; final minChildSize = 0.25; @@ -191,27 +381,28 @@ class _MapTrackingPageState extends State { userAgentPackageName: 'com.hmg.atoms', ), if (_geojson != null) ...[ - GestureDetector( - onTap: () { - // final hitResult = _polygonHitNotifier.value; - // - // if (hitResult == null || hitResult.hitValues.isEmpty) return; - // print("hitResult:$hitResult"); - // final hitValue = hitResult.hitValues.first; - // - // // Narrow safely instead of assuming the type. - // if (hitValue is Map) { - // print(hitValue); - // } else { - // // ignore: avoid_print - // print('Unexpected hit value type: ${hitValue.runtimeType} -> $hitValue'); - // } + Selector>( + selector: (_, provider) => inAppView ? List.of(provider.assetsOnTheFloorList) : const [], + builder: (_, floorAssets, __) { + final zoneBadges = _buildZoneBadgeMarkers(floorAssets); + return Stack( + children: [ + GestureDetector( + onTap: _onFeatureTapped, + // A new MemoryGeoJson is created on every build, so GeoJsonLayer re-parses + // the features and `onPolygon` is called again with the latest assets. + child: GeoJsonLayer.memory( + _geojson!, + hitNotifier: _polygonHitNotifier, + styleDefaults: GeoJsonStyleDefaults.leaflet, + onPolygon: (points, holes, properties) => _buildZonePolygon(points, holes, properties, floorAssets), + ), + ), + // Circular count badges drawn on top of each zone. + if (zoneBadges.isNotEmpty) MarkerLayer(markers: zoneBadges), + ], + ); }, - child: GeoJsonLayer.memory( - _geojson!, - hitNotifier: _polygonHitNotifier, - styleDefaults: GeoJsonStyleDefaults.leaflet, - ), ), // if (!inAppView || ) @@ -270,17 +461,8 @@ class _MapTrackingPageState extends State { showAsFullScreenDialog: true, onSelect: (selectedAssets) { setState(() { - markers = [ - for (var asset in selectedAssets!) - Marker( - point: LatLng(asset.location!.latitude!, asset.location!.longitude!), - child: Tooltip( - triggerMode: TooltipTriggerMode.tap, - message: "Last seen: ${asset.location?.lastSeen?.toServiceRequestDetailsFormat}", - child: const Icon(Icons.location_pin, color: AppColor.red30), - ), - ), - ]; + _selectedZoneProperties = null; + markers = [for (var asset in selectedAssets!) _buildAssetMarker(asset)]; }); // markers }, @@ -453,6 +635,35 @@ class _MapTrackingPageState extends State { } } +class _ZoneCountBadge extends StatelessWidget { + final int count; + + const _ZoneCountBadge({required this.count}); + + @override + Widget build(BuildContext context) { + return Container( + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppColor.red30, + border: Border.all(color: Colors.white, width: 1), + boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.25), blurRadius: 4, offset: const Offset(0, 2))], + ), + child: FittedBox( + fit: BoxFit.scaleDown, + child: Padding( + padding: const EdgeInsets.all(0), + child: Text( + count > 99 ? "99+" : "$count", + style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.w600), + ), + ), + ), + ); + } +} + class UpwardExpansionTile extends StatefulWidget { final Widget title; final List children;