diff --git a/lib/providers/location_provider.dart b/lib/providers/location_provider.dart new file mode 100644 index 00000000..d0580799 --- /dev/null +++ b/lib/providers/location_provider.dart @@ -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(( + 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(( + ref, +) { + const settings = LocationSettings( + accuracy: LocationAccuracy.high, + distanceFilter: 5, // small filter for responsive UI in tests/dev + ); + return Geolocator.getPositionStream(locationSettings: settings); +}); diff --git a/lib/providers/profile_provider.dart b/lib/providers/profile_provider.dart index 86f5a104..3847012e 100644 --- a/lib/providers/profile_provider.dart +++ b/lib/providers/profile_provider.dart @@ -13,7 +13,7 @@ final currentUserIdProvider = Provider((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, ); }); diff --git a/lib/routing/app_router.dart b/lib/routing/app_router.dart index 60ace0ee..c6c6b191 100644 --- a/lib/routing/app_router.dart +++ b/lib/routing/app_router.dart @@ -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((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((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(); }); diff --git a/lib/screens/admin/geofence_test_screen.dart b/lib/screens/admin/geofence_test_screen.dart new file mode 100644 index 00000000..baa893d9 --- /dev/null +++ b/lib/screens/admin/geofence_test_screen.dart @@ -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 createState() => _GeofenceTestScreenState(); +} + +class _GeofenceTestScreenState extends ConsumerState { + 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? 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() ?? + []; + + final markers = []; + 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'); + } +} diff --git a/lib/screens/dashboard/dashboard_screen.dart b/lib/screens/dashboard/dashboard_screen.dart index 5c999d80..83d9502f 100644 --- a/lib/screens/dashboard/dashboard_screen.dart +++ b/lib/screens/dashboard/dashboard_screen.dart @@ -604,7 +604,7 @@ class _StaffTableBody extends ConsumerWidget { (av) => av.when>( data: (m) => m.staffRows, loading: () => const [], - error: (_, __) => const [], + error: (error, _) => const [], ), ), ); diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart index 9e5dd8af..1cb0f31f 100644 --- a/lib/widgets/app_shell.dart +++ b/lib/widgets/app_shell.dart @@ -375,6 +375,12 @@ List _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', diff --git a/pubspec.lock b/pubspec.lock index 95624496..7ddf1aba 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -193,6 +193,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + dart_earcut: + dependency: transitive + description: + name: dart_earcut + sha256: e485001bfc05dcbc437d7bfb666316182e3522d4c3f9668048e004d0eb2ce43b + url: "https://pub.dev" + source: hosted + version: "1.2.0" dart_jsonwebtoken: dependency: transitive description: @@ -201,6 +209,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.3.1" + dart_polylabel2: + dependency: transitive + description: + name: dart_polylabel2 + sha256: "7eeab15ce72894e4bdba6a8765712231fc81be0bd95247de4ad9966abc57adc6" + url: "https://pub.dev" + source: hosted + version: "1.0.0" ed25519_edwards: dependency: transitive description: @@ -270,6 +286,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_map: + dependency: "direct main" + description: + name: flutter_map + sha256: "391e7dc95cc3f5190748210a69d4cfeb5d8f84dcdfa9c3235d0a9d7742ccb3f8" + url: "https://pub.dev" + source: hosted + version: "8.2.2" flutter_riverpod: dependency: "direct main" description: @@ -424,6 +448,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.7.2" + intl: + dependency: transitive + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" json_annotation: dependency: transitive description: @@ -440,6 +472,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.1" + latlong2: + dependency: "direct main" + description: + name: latlong2 + sha256: "98227922caf49e6056f91b6c56945ea1c7b166f28ffcd5fb8e72fc0b453cc8fe" + url: "https://pub.dev" + source: hosted + version: "0.9.1" leak_tracker: dependency: transitive description: @@ -472,6 +512,22 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" + lists: + dependency: transitive + description: + name: lists + sha256: "4ca5c19ae4350de036a7e996cdd1ee39c93ac0a2b840f4915459b7d0a7d4ab27" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + logger: + dependency: transitive + description: + name: logger + sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3 + url: "https://pub.dev" + source: hosted + version: "2.6.2" logging: dependency: transitive description: @@ -504,6 +560,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.17.0" + mgrs_dart: + dependency: transitive + description: + name: mgrs_dart + sha256: fb89ae62f05fa0bb90f70c31fc870bcbcfd516c843fb554452ab3396f78586f7 + url: "https://pub.dev" + source: hosted + version: "2.0.0" mime: dependency: transitive description: @@ -632,6 +696,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.0" + proj4dart: + dependency: transitive + description: + name: proj4dart + sha256: c8a659ac9b6864aa47c171e78d41bbe6f5e1d7bd790a5814249e6b68bc44324e + url: "https://pub.dev" + source: hosted + version: "2.1.0" pub_semver: dependency: transitive description: @@ -837,6 +909,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + unicode: + dependency: transitive + description: + name: unicode + sha256: "0f69e46593d65245774d4f17125c6084d2c20b4e473a983f6e21b7d7762218f1" + url: "https://pub.dev" + source: hosted + version: "0.3.1" url_launcher: dependency: transitive description: @@ -949,6 +1029,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + wkt_parser: + dependency: transitive + description: + name: wkt_parser + sha256: "8a555fc60de3116c00aad67891bcab20f81a958e4219cc106e3c037aa3937f13" + url: "https://pub.dev" + source: hosted + version: "2.0.0" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 1dc2a787..cbd2672f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -18,6 +18,8 @@ dependencies: audioplayers: ^6.1.0 geolocator: ^13.0.1 timezone: ^0.9.4 + flutter_map: ^8.2.2 + latlong2: ^0.9.0 dev_dependencies: flutter_test: diff --git a/test/geofence_admin_screen_test.dart b/test/geofence_admin_screen_test.dart new file mode 100644 index 00000000..b33803ff --- /dev/null +++ b/test/geofence_admin_screen_test.dart @@ -0,0 +1,254 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:geolocator/geolocator.dart'; + +import 'package:tasq/models/app_settings.dart'; +import 'package:tasq/models/profile.dart'; +import 'package:tasq/providers/profile_provider.dart'; +import 'package:tasq/providers/workforce_provider.dart'; +import 'package:tasq/providers/location_provider.dart'; +import 'package:tasq/screens/admin/geofence_test_screen.dart'; + +void main() { + // Polygon from existing unit test (CRMC) + final polygonJson = [ + {"lat": 7.2025231, "lng": 124.2339774}, + {"lat": 7.2018791, "lng": 124.2334463}, + {"lat": 7.2013362, "lng": 124.2332049}, + {"lat": 7.2011393, "lng": 124.2332907}, + {"lat": 7.2009531, "lng": 124.2334195}, + {"lat": 7.200655, "lng": 124.2339344}, + {"lat": 7.2000749, "lng": 124.2348465}, + {"lat": 7.199501, "lng": 124.2357645}, + {"lat": 7.1990597, "lng": 124.2364595}, + {"lat": 7.1986557, "lng": 124.2371331}, + {"lat": 7.1992252, "lng": 124.237168}, + {"lat": 7.199494, "lng": 124.2372713}, + {"lat": 7.1997415, "lng": 124.2374604}, + {"lat": 7.1999383, "lng": 124.2377071}, + {"lat": 7.2001938, "lng": 124.2380934}, + {"lat": 7.2011411, "lng": 124.2364357}, + {"lat": 7.2025231, "lng": 124.2339774}, + ]; + + ProviderScope buildApp({required List overrides}) { + return ProviderScope( + overrides: overrides, + child: const MaterialApp(home: GeofenceTestScreen()), + ); + } + + testWidgets('non-admin cannot access', (tester) async { + await tester.pumpWidget( + buildApp(overrides: [isAdminProvider.overrideWith((ref) => false)]), + ); + + await tester.pump(); + expect(find.text('Admin access required.'), findsOneWidget); + }); + + testWidgets('polygon geofence - inside shown', (tester) async { + final profile = Profile(id: 'p1', role: 'admin', fullName: 'Admin'); + final insidePos = Position( + latitude: 7.2009, + longitude: 124.2360, + timestamp: DateTime.now(), + accuracy: 1.0, + altitude: 0.0, + altitudeAccuracy: 0.0, + heading: 0.0, + speed: 0.0, + speedAccuracy: 0.0, + headingAccuracy: 0.0, + ); + + await tester.pumpWidget( + buildApp( + overrides: [ + currentProfileProvider.overrideWith((ref) => Stream.value(profile)), + isAdminProvider.overrideWith((ref) => true), + geofenceProvider.overrideWith( + (ref) => + Future.value(GeofenceConfig.fromJson({'polygon': polygonJson})), + ), + currentPositionProvider.overrideWith( + (ref) => Future.value(insidePos), + ), + ], + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('Inside geofence'), findsOneWidget); + expect(find.byIcon(Icons.check_circle), findsOneWidget); + }); + + testWidgets('polygon geofence - outside shown', (tester) async { + final profile = Profile(id: 'p1', role: 'admin', fullName: 'Admin'); + final outsidePos = Position( + latitude: 7.2060, + longitude: 124.2360, + timestamp: DateTime.now(), + accuracy: 1.0, + altitude: 0.0, + altitudeAccuracy: 0.0, + heading: 0.0, + speed: 0.0, + speedAccuracy: 0.0, + headingAccuracy: 0.0, + ); + + await tester.pumpWidget( + buildApp( + overrides: [ + currentProfileProvider.overrideWith((ref) => Stream.value(profile)), + isAdminProvider.overrideWith((ref) => true), + geofenceProvider.overrideWith( + (ref) => + Future.value(GeofenceConfig.fromJson({'polygon': polygonJson})), + ), + currentPositionProvider.overrideWith( + (ref) => Future.value(outsidePos), + ), + ], + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('Outside geofence'), findsOneWidget); + expect(find.byIcon(Icons.cancel), findsOneWidget); + }); + + testWidgets('circle geofence - inside shown', (tester) async { + final profile = Profile(id: 'p1', role: 'admin', fullName: 'Admin'); + final cfg = GeofenceConfig(lat: 0.0, lng: 0.0, radiusMeters: 1000.0); + final insidePos = Position( + latitude: 0.005, + longitude: 0.0, + timestamp: DateTime.now(), + accuracy: 1.0, + altitude: 0.0, + altitudeAccuracy: 0.0, + heading: 0.0, + speed: 0.0, + speedAccuracy: 0.0, + headingAccuracy: 0.0, + ); + + await tester.pumpWidget( + buildApp( + overrides: [ + currentProfileProvider.overrideWith((ref) => Stream.value(profile)), + isAdminProvider.overrideWith((ref) => true), + geofenceProvider.overrideWith((ref) => Future.value(cfg)), + currentPositionProvider.overrideWith( + (ref) => Future.value(insidePos), + ), + ], + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('Inside geofence'), findsOneWidget); + expect(find.byIcon(Icons.check_circle), findsOneWidget); + }); + + testWidgets('circle geofence - outside shown', (tester) async { + final profile = Profile(id: 'p1', role: 'admin', fullName: 'Admin'); + final cfg = GeofenceConfig(lat: 0.0, lng: 0.0, radiusMeters: 1000.0); + final outsidePos = Position( + latitude: 0.02, + longitude: 0.0, + timestamp: DateTime.now(), + accuracy: 1.0, + altitude: 0.0, + altitudeAccuracy: 0.0, + heading: 0.0, + speed: 0.0, + speedAccuracy: 0.0, + headingAccuracy: 0.0, + ); + + await tester.pumpWidget( + buildApp( + overrides: [ + currentProfileProvider.overrideWith((ref) => Stream.value(profile)), + isAdminProvider.overrideWith((ref) => true), + geofenceProvider.overrideWith((ref) => Future.value(cfg)), + currentPositionProvider.overrideWith( + (ref) => Future.value(outsidePos), + ), + ], + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('Outside geofence'), findsOneWidget); + expect(find.byIcon(Icons.cancel), findsOneWidget); + }); + + testWidgets('live tracking toggle follows stream', (tester) async { + final profile = Profile(id: 'p1', role: 'admin', fullName: 'Admin'); + final cfg = GeofenceConfig(lat: 0.0, lng: 0.0, radiusMeters: 1000.0); + + final initialPos = Position( + latitude: 0.02, + longitude: 0.0, + timestamp: DateTime.now(), + accuracy: 1.0, + altitude: 0.0, + altitudeAccuracy: 0.0, + heading: 0.0, + speed: 0.0, + speedAccuracy: 0.0, + headingAccuracy: 0.0, + ); + + final livePos = Position( + latitude: 0.005, + longitude: 0.0, + timestamp: DateTime.now(), + accuracy: 1.0, + altitude: 0.0, + altitudeAccuracy: 0.0, + heading: 0.0, + speed: 0.0, + speedAccuracy: 0.0, + headingAccuracy: 0.0, + ); + + await tester.pumpWidget( + buildApp( + overrides: [ + currentProfileProvider.overrideWith((ref) => Stream.value(profile)), + isAdminProvider.overrideWith((ref) => true), + geofenceProvider.overrideWith((ref) => Future.value(cfg)), + currentPositionProvider.overrideWith( + (ref) => Future.value(initialPos), + ), + currentPositionStreamProvider.overrideWith( + (ref) => Stream.value(livePos), + ), + ], + ), + ); + + await tester.pumpAndSettle(); + + // initial state is the one-shot position (outside) + expect(find.text('Outside geofence'), findsOneWidget); + + // enable live tracking + await tester.tap(find.byKey(const Key('live-switch'))); + await tester.pumpAndSettle(); + + // stream value should now be used and indicate inside + expect(find.text('Inside geofence'), findsOneWidget); + expect(find.byIcon(Icons.check_circle), findsOneWidget); + }); +}