|
|
|
|
@ -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<String, dynamic> properties;
|
|
|
|
|
final List<LatLng> points;
|
|
|
|
|
final List<AssetTrackingModel> 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<MapTrackingPage> {
|
|
|
|
|
|
|
|
|
|
void _onFloorMapLoaded(FloorMapModel? loadedFloorMap) {
|
|
|
|
|
floorMapModel = loadedFloorMap;
|
|
|
|
|
_selectedZoneProperties = null;
|
|
|
|
|
|
|
|
|
|
if (floorMapModel?.geojsonData == null) {
|
|
|
|
|
setState(() {});
|
|
|
|
|
@ -113,14 +130,18 @@ class _MapTrackingPageState extends State<MapTrackingPage> {
|
|
|
|
|
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<MapTrackingPage> {
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Map<String, dynamic>? _selectedZoneProperties;
|
|
|
|
|
|
|
|
|
|
Polygon _buildZonePolygon(List<LatLng> points, List<List<LatLng>>? holes, Map<String, dynamic> properties, List<AssetTrackingModel> 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<Object>(
|
|
|
|
|
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<Marker> _buildZoneBadgeMarkers(List<AssetTrackingModel> floorAssets) {
|
|
|
|
|
if (!inAppView || floorAssets.isEmpty || _geojson == null) return const [];
|
|
|
|
|
|
|
|
|
|
List<LatLng> 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 = <Marker>[];
|
|
|
|
|
|
|
|
|
|
void addZone(dynamic polygonCoords, Map<String, dynamic> 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<String, dynamic>?) ?? <String, dynamic>{};
|
|
|
|
|
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<LatLng> outer, List<List<LatLng>>? 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 <List<LatLng>>[]) {
|
|
|
|
|
if (_isPointInRing(point, hole)) return false;
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Ray-casting point-in-polygon test (longitude = x, latitude = y).
|
|
|
|
|
bool _isPointInRing(LatLng point, List<LatLng> 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<GeoZone>().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<MapTrackingPage> {
|
|
|
|
|
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<String, dynamic>) {
|
|
|
|
|
// print(hitValue);
|
|
|
|
|
// } else {
|
|
|
|
|
// // ignore: avoid_print
|
|
|
|
|
// print('Unexpected hit value type: ${hitValue.runtimeType} -> $hitValue');
|
|
|
|
|
// }
|
|
|
|
|
Selector<AssetTrackingProvider, List<AssetTrackingModel>>(
|
|
|
|
|
selector: (_, provider) => inAppView ? List<AssetTrackingModel>.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<MapTrackingPage> {
|
|
|
|
|
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<MapTrackingPage> {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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<Widget> children;
|
|
|
|
|
|