Geofence test screen

This commit is contained in:
2026-02-19 06:49:21 +08:00
parent f9f3509188
commit 5488238051
9 changed files with 852 additions and 4 deletions
+28
View File
@@ -0,0 +1,28 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:geolocator/geolocator.dart';
/// Provides the device current position on demand. Tests may override this
/// provider to inject a fake Position.
final currentPositionProvider = FutureProvider.autoDispose<Position>((
ref,
) async {
// Mirror the runtime usage in the app: ask geolocator for the current
// position with high accuracy. Caller (UI) should handle permission
// flows / errors.
final position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(accuracy: LocationAccuracy.high),
);
return position;
});
/// Stream of device positions for live tracking. Tests can override this
/// provider to inject a fake stream.
final currentPositionStreamProvider = StreamProvider.autoDispose<Position>((
ref,
) {
const settings = LocationSettings(
accuracy: LocationAccuracy.high,
distanceFilter: 5, // small filter for responsive UI in tests/dev
);
return Geolocator.getPositionStream(locationSettings: settings);
});
+1 -1
View File
@@ -13,7 +13,7 @@ final currentUserIdProvider = Provider<String?>((ref) {
return authState.when(
data: (state) => state.session?.user.id,
loading: () => ref.watch(sessionProvider)?.user.id,
error: (_, __) => ref.watch(sessionProvider)?.user.id,
error: (error, _) => ref.watch(sessionProvider)?.user.id,
);
});
+7 -2
View File
@@ -10,6 +10,7 @@ import '../screens/auth/login_screen.dart';
import '../screens/auth/signup_screen.dart';
import '../screens/admin/offices_screen.dart';
import '../screens/admin/user_management_screen.dart';
import '../screens/admin/geofence_test_screen.dart';
import '../screens/dashboard/dashboard_screen.dart';
import '../screens/notifications/notifications_screen.dart';
import '../screens/profile/profile_screen.dart';
@@ -34,7 +35,7 @@ final appRouterProvider = Provider<GoRouter>((ref) {
final session = authState.when(
data: (state) => state.session,
loading: () => ref.read(sessionProvider),
error: (_, __) => ref.read(sessionProvider),
error: (error, _) => ref.read(sessionProvider),
);
final isAuthRoute =
state.fullPath == '/login' || state.fullPath == '/signup';
@@ -133,6 +134,10 @@ final appRouterProvider = Provider<GoRouter>((ref) {
path: '/settings/offices',
builder: (context, state) => const OfficesScreen(),
),
GoRoute(
path: '/settings/geofence-test',
builder: (context, state) => const GeofenceTestScreen(),
),
GoRoute(
path: '/notifications',
builder: (context, state) => const NotificationsScreen(),
@@ -160,7 +165,7 @@ class RouterNotifier extends ChangeNotifier {
}
},
loading: () {},
error: (_, __) {},
error: (error, _) {},
);
notifyListeners();
});
+465
View File
@@ -0,0 +1,465 @@
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://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
// It's OK to leave default UA for dev; production
// should set `userAgentPackageName` per OSMTOS.
),
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: 2,
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'),
],
),
),
],
),
),
),
),
],
);
},
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');
}
}
+1 -1
View File
@@ -604,7 +604,7 @@ class _StaffTableBody extends ConsumerWidget {
(av) => av.when<List<StaffRowMetrics>>(
data: (m) => m.staffRows,
loading: () => const <StaffRowMetrics>[],
error: (_, __) => const <StaffRowMetrics>[],
error: (error, _) => const <StaffRowMetrics>[],
),
),
);
+6
View File
@@ -375,6 +375,12 @@ List<NavSection> _buildSections(String role) {
icon: Icons.apartment_outlined,
selectedIcon: Icons.apartment,
),
NavItem(
label: 'Geofence test',
route: '/settings/geofence-test',
icon: Icons.map_outlined,
selectedIcon: Icons.map,
),
NavItem(
label: 'IT Staff Teams',
route: '/settings/teams',