Geofence test screen

This commit is contained in:
Marc Rejohn Castillano 2026-02-19 06:49:21 +08:00
parent f9f3509188
commit 5488238051
9 changed files with 852 additions and 4 deletions

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);
});

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,
);
});

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();
});

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');
}
}

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>[],
),
),
);

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',

View File

@ -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:

View File

@ -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:

View File

@ -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<Override> 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);
});
}