tasq/lib/screens/admin/geofence_test_screen.dart

477 lines
24 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:geolocator/geolocator.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart' show LatLng;
import '../../models/app_settings.dart';
import '../../providers/location_provider.dart';
import '../../providers/workforce_provider.dart';
import '../../providers/profile_provider.dart';
import '../../widgets/responsive_body.dart';
class GeofenceTestScreen extends ConsumerStatefulWidget {
const GeofenceTestScreen({super.key});
@override
ConsumerState<GeofenceTestScreen> createState() => _GeofenceTestScreenState();
}
class _GeofenceTestScreenState extends ConsumerState<GeofenceTestScreen> {
final _mapController = MapController();
bool _followMe = true;
bool _liveTracking = false; // enable periodic updates from the device stream
bool _isInside(Position pos, GeofenceConfig? cfg) {
if (cfg == null) return false;
if (cfg.hasPolygon) {
return cfg.containsPolygon(pos.latitude, pos.longitude);
}
if (cfg.hasCircle) {
final dist = Geolocator.distanceBetween(
pos.latitude,
pos.longitude,
cfg.lat!,
cfg.lng!,
);
return dist <= (cfg.radiusMeters ?? 0);
}
return false;
}
void _centerOn(LatLng latLng) {
// flutter_map v8+ MapController does not expose current zoom getter — use a
// sensible default zoom when centering.
_mapController.move(latLng, 16.0);
}
@override
Widget build(BuildContext context) {
final isAdmin = ref.watch(isAdminProvider);
final geofenceAsync = ref.watch(geofenceProvider);
final positionAsync = ref.watch(currentPositionProvider);
// Only watch the live stream when the user enables "Live" so tests that
// don't enable live tracking won't need to provide a stream override.
final AsyncValue<Position>? streamAsync = _liveTracking
? ref.watch(currentPositionStreamProvider)
: null;
// If live-tracking is active and the stream yields a position while the
// UI is following the device, recenter the map.
if (_liveTracking && streamAsync != null) {
streamAsync.whenData((p) {
if (_followMe) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_centerOn(LatLng(p.latitude, p.longitude));
});
}
});
}
return ResponsiveBody(
child: !isAdmin
? const Center(child: Text('Admin access required.'))
: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Row(
children: [
Expanded(
child: Card(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Geofence status'),
const SizedBox(height: 8),
// Show either the one-shot position or the live-stream
// value depending on the user's toggle.
_liveTracking
? (streamAsync == null
? const Text('Live tracking disabled')
: streamAsync.when(
data: (pos) {
final inside = _isInside(
pos,
geofenceAsync.valueOrNull,
);
return Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
inside
? Icons
.check_circle
: Icons.cancel,
color: inside
? Colors.green
: Colors.red,
),
const SizedBox(
width: 8,
),
Text(
inside
? 'Inside geofence'
: 'Outside geofence',
),
const SizedBox(
width: 12,
),
Flexible(
child: Text(
'Lat: ${pos.latitude.toStringAsFixed(6)}, Lng: ${pos.longitude.toStringAsFixed(6)}',
overflow:
TextOverflow
.ellipsis,
),
),
],
),
const SizedBox(height: 8),
_buildGeofenceSummary(
geofenceAsync.valueOrNull,
),
],
);
},
loading: () => const Text(
'Waiting for live position...',
),
error: (err, st) => Text(
'Live location error: $err',
),
))
: positionAsync.when(
data: (pos) {
final inside = _isInside(
pos,
geofenceAsync.valueOrNull,
);
return Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
inside
? Icons.check_circle
: Icons.cancel,
color: inside
? Colors.green
: Colors.red,
),
const SizedBox(width: 8),
Text(
inside
? 'Inside geofence'
: 'Outside geofence',
),
const SizedBox(width: 12),
Flexible(
child: Text(
'Lat: ${pos.latitude.toStringAsFixed(6)}, Lng: ${pos.longitude.toStringAsFixed(6)}',
overflow:
TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 8),
_buildGeofenceSummary(
geofenceAsync.valueOrNull,
),
],
);
},
loading: () =>
const Text('Fetching location...'),
error: (err, st) =>
Text('Location error: $err'),
),
],
),
),
),
),
const SizedBox(width: 12),
Material(
type: MaterialType.transparency,
child: Column(
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Live'),
const SizedBox(width: 6),
Switch.adaptive(
key: const Key('live-switch'),
value: _liveTracking,
onChanged: (v) =>
setState(() => _liveTracking = v),
),
],
),
const SizedBox(height: 6),
SizedBox(
height: 48,
child: FilledButton.icon(
icon: const Icon(Icons.my_location),
label: const Text('Refresh'),
onPressed: () =>
ref.refresh(currentPositionProvider),
),
),
const SizedBox(height: 8),
IconButton(
tooltip: 'Center map on current location',
onPressed: () {
final pos = (_liveTracking
? streamAsync?.valueOrNull
: positionAsync.valueOrNull);
if (pos != null) {
_centerOn(
LatLng(pos.latitude, pos.longitude),
);
setState(() => _followMe = true);
}
},
icon: const Icon(Icons.center_focus_strong),
),
],
),
),
],
),
),
const SizedBox(height: 12),
Expanded(
child: Card(
margin: const EdgeInsets.symmetric(horizontal: 16.0),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: geofenceAsync.when(
data: (cfg) {
final polygonPoints =
cfg?.polygon
?.map((p) => LatLng(p.lat, p.lng))
.toList() ??
<LatLng>[];
final markers = <Marker>[];
if (positionAsync.valueOrNull != null) {
final p = positionAsync.value!;
markers.add(
Marker(
point: LatLng(p.latitude, p.longitude),
width: 40,
height: 40,
// `child` is used by newer flutter_map Marker API.
child: const Icon(
Icons.person_pin_circle,
size: 36,
color: Colors.blue,
),
),
);
}
final polygonLayer = polygonPoints.isNotEmpty
? PolygonLayer(
polygons: [
Polygon(
points: polygonPoints,
color: Colors.green.withValues(
alpha: 0.15,
),
borderColor: Colors.green,
borderStrokeWidth: 2,
),
],
)
: const SizedBox.shrink();
final circleLayer = (cfg?.hasCircle == true)
? CircleLayer(
circles: [
CircleMarker(
point: LatLng(cfg!.lat!, cfg.lng!),
color: Colors.green.withValues(
alpha: 0.10,
),
borderStrokeWidth: 2,
useRadiusInMeter: true,
radius: cfg.radiusMeters ?? 100,
),
],
)
: const SizedBox.shrink();
// Prefer the live-stream position when enabled, else
// fall back to the one-shot value used by the rest of
// the app/tests.
final activePos = _liveTracking
? streamAsync?.valueOrNull
: positionAsync.valueOrNull;
final effectiveCenter = cfg?.hasCircle == true
? LatLng(cfg!.lat!, cfg.lng!)
: (activePos != null
? LatLng(
activePos.latitude,
activePos.longitude,
)
: const LatLng(7.2009, 124.2360));
// Build the map inside a Stack so we can overlay a
// small legend/radius label on top of the map.
return Stack(
children: [
FlutterMap(
mapController: _mapController,
options: MapOptions(
initialCenter: effectiveCenter,
initialZoom: 16.0,
maxZoom: 18,
minZoom: 3,
onPositionChanged: (pos, hasGesture) {
if (hasGesture) {
setState(() => _followMe = false);
}
},
),
children: [
TileLayer(
urlTemplate:
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
// Per OSM tile usage policy: set a User-Agent that
// identifies this application. Use the Android
// applicationId so tile servers can contact the
// publisher if necessary.
userAgentPackageName: 'com.example.tasq',
),
if (polygonPoints.isNotEmpty) polygonLayer,
if (cfg?.hasCircle == true) circleLayer,
if (markers.isNotEmpty)
MarkerLayer(markers: markers),
],
),
// Legend / radius label
Positioned(
right: 12,
top: 12,
child: Card(
elevation: 0,
shadowColor: Colors.transparent,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 10.0,
vertical: 8.0,
),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: Colors.green.withValues(
alpha: 0.25,
),
border: Border.all(
color: Colors.green,
),
),
),
const SizedBox(width: 8),
Text(
cfg?.hasPolygon == true
? 'Geofence (polygon)'
: 'Geofence (circle)',
),
],
),
if (cfg?.hasCircle == true)
Padding(
padding: const EdgeInsets.only(
top: 6.0,
),
child: Text(
'Radius: ${cfg!.radiusMeters?.toStringAsFixed(0) ?? '-'} m',
style: Theme.of(
context,
).textTheme.bodySmall,
),
),
Padding(
padding: const EdgeInsets.only(
top: 6.0,
),
child: Row(
children: const [
Icon(
Icons.person_pin_circle,
size: 14,
color: Colors.blue,
),
SizedBox(width: 8),
Text('You'),
],
),
),
const SizedBox(height: 6),
Text(
'Map tiles: © OpenStreetMap contributors',
style: Theme.of(
context,
).textTheme.bodySmall,
),
],
),
),
),
),
],
);
},
loading: () =>
const Center(child: CircularProgressIndicator()),
error: (err, st) => Center(
child: Text('Failed to load geofence: $err'),
),
),
),
),
),
const SizedBox(height: 12),
],
),
);
}
Widget _buildGeofenceSummary(GeofenceConfig? cfg) {
if (cfg == null) return const Text('Geofence: not configured');
if (cfg.hasCircle) {
return Text(
'Geofence: Circle • Radius: ${cfg.radiusMeters?.toStringAsFixed(0) ?? '-'} m',
);
}
if (cfg.hasPolygon) {
return Text('Geofence: Polygon • ${cfg.polygon?.length ?? 0} points');
}
return const Text('Geofence: not configured');
}
}