Skip to content

Commit a0f8b59

Browse files
committed
feat: Implement Day-12 Responder Activation System
1 parent d4b48fe commit a0f8b59

File tree

11 files changed

+508
-5
lines changed

11 files changed

+508
-5
lines changed
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import 'dart:async';
2+
3+
import 'package:flutter/foundation.dart';
4+
import '../services/responder_state_service.dart';
5+
import '../services/location_service.dart';
6+
import '../services/tile_prefetch_service.dart';
7+
8+
/// Acts as the glue: listens to [ResponderStateService]. When active, it uses
9+
/// [LocationService] to get the location, and then triggers an offline tile
10+
/// prefetch centered around the responder for 5km using [TilePrefetchService].
11+
class ResponderController {
12+
final ResponderStateService _stateService;
13+
final LocationService _locationService;
14+
final TilePrefetchService _prefetchService;
15+
16+
StreamSubscription<ResponderState>? _stateSub;
17+
bool _hasTriggeredForCurrentSession = false;
18+
19+
ResponderController({
20+
required ResponderStateService stateService,
21+
required LocationService locationService,
22+
required TilePrefetchService prefetchService,
23+
}) : _stateService = stateService,
24+
_locationService = locationService,
25+
_prefetchService = prefetchService {
26+
_init();
27+
}
28+
29+
void _init() {
30+
_stateSub = _stateService.stateStream.listen(_onStateChanged);
31+
32+
// Check initial state
33+
_onStateChanged(_stateService.currentState);
34+
}
35+
36+
Future<void> _onStateChanged(ResponderState state) async {
37+
if (state == ResponderState.inactive) {
38+
_hasTriggeredForCurrentSession = false;
39+
return;
40+
}
41+
42+
if (state == ResponderState.active) {
43+
if (_hasTriggeredForCurrentSession) return;
44+
_hasTriggeredForCurrentSession = true;
45+
46+
// 1. Get the current location
47+
final location = await _locationService.getCurrentLocation();
48+
49+
if (location == null) {
50+
debugPrint('ResponderController: Failed to get location, skipping tile prefetch');
51+
return;
52+
}
53+
54+
// 2. Trigger tile prefetch for 5km radius
55+
debugPrint('Starting tile prefetch for radius');
56+
57+
try {
58+
await _prefetchService.startPrefetchForRadius(
59+
location,
60+
5000, // 5km radius
61+
);
62+
} catch (e) {
63+
debugPrint('ResponderController: Failed to start prefetch: $e');
64+
}
65+
}
66+
}
67+
68+
void dispose() {
69+
_stateSub?.cancel();
70+
}
71+
}

mobile_app/lib/features/map/map_screen.dart

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import '../../models/models.dart' as models;
1313
import '../messaging/messaging_screen.dart';
1414
import '../prefetch/prefetch_screen.dart';
1515
import 'map_service.dart';
16+
import '../../widgets/responder_toggle.dart';
1617

1718
// Temporarily disabled for tile debugging:
1819
// import 'package:maplibre_gl/maplibre_gl.dart';
@@ -653,9 +654,18 @@ class _MapScreenState extends State<MapScreen> {
653654
),
654655
],
655656
),
656-
floatingActionButton: FloatingActionButton(
657-
onPressed: () => _openCreateIncidentForm(),
658-
child: const Icon(Icons.add_location_alt),
657+
floatingActionButton: Column(
658+
mainAxisSize: MainAxisSize.min,
659+
crossAxisAlignment: CrossAxisAlignment.end,
660+
children: [
661+
const ResponderToggle(),
662+
const SizedBox(height: 16),
663+
FloatingActionButton(
664+
heroTag: 'add_incident',
665+
onPressed: () => _openCreateIncidentForm(),
666+
child: const Icon(Icons.add_location_alt),
667+
),
668+
],
659669
),
660670
);
661671
}

mobile_app/lib/main.dart

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ import 'features/map/map_screen.dart';
1313
import 'features/map/map_service.dart';
1414
import 'features/prefetch/prefetch_controller.dart';
1515
import 'services/tile_prefetch_service.dart';
16+
import 'package:shared_preferences/shared_preferences.dart';
17+
import 'services/responder_state_service.dart';
18+
import 'services/location_service.dart';
19+
import 'controllers/responder_controller.dart';
1620

1721
void main() async {
1822
WidgetsFlutterBinding.ensureInitialized();
@@ -38,6 +42,16 @@ void main() async {
3842
);
3943
final prefetchController = PrefetchController(prefetchService);
4044

45+
// 4. Responder System
46+
final prefs = await SharedPreferences.getInstance();
47+
final responderStateService = ResponderStateService(prefs);
48+
final locationService = LocationService();
49+
final responderController = ResponderController(
50+
stateService: responderStateService,
51+
locationService: locationService,
52+
prefetchService: prefetchService,
53+
);
54+
4155
runApp(
4256
MultiProvider(
4357
providers: [
@@ -53,6 +67,9 @@ void main() async {
5367
Provider<TilePrefetchService>.value(value: prefetchService),
5468
ChangeNotifierProvider<PrefetchController>.value(
5569
value: prefetchController),
70+
Provider<ResponderStateService>.value(value: responderStateService),
71+
Provider<LocationService>.value(value: locationService),
72+
Provider<ResponderController>.value(value: responderController),
5673
],
5774
child: const OpenRescueApp(),
5875
),
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import 'package:location/location.dart';
2+
import 'package:latlong2/latlong.dart';
3+
4+
/// Service to handle retrieving device GPS location and managing permissions.
5+
class LocationService {
6+
final Location _location = Location();
7+
8+
/// Retrieves the current device location.
9+
/// Requests permissions and services if necessary.
10+
/// Returns [LatLng] if successful, or null if permissions denied or unavailable.
11+
Future<LatLng?> getCurrentLocation() async {
12+
bool serviceEnabled;
13+
PermissionStatus permissionGranted;
14+
15+
// Check if location services are enabled
16+
serviceEnabled = await _location.serviceEnabled();
17+
if (!serviceEnabled) {
18+
serviceEnabled = await _location.requestService();
19+
if (!serviceEnabled) {
20+
return null;
21+
}
22+
}
23+
24+
// Check if permissions are granted
25+
permissionGranted = await _location.hasPermission();
26+
if (permissionGranted == PermissionStatus.denied) {
27+
permissionGranted = await _location.requestPermission();
28+
if (permissionGranted != PermissionStatus.granted) {
29+
return null;
30+
}
31+
}
32+
33+
// Get the actual location
34+
try {
35+
final locationData = await _location.getLocation();
36+
if (locationData.latitude != null && locationData.longitude != null) {
37+
return LatLng(locationData.latitude!, locationData.longitude!);
38+
}
39+
} catch (e) {
40+
// Ignored: fallback to null
41+
}
42+
43+
return null;
44+
}
45+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import 'dart:async';
2+
import 'package:flutter/foundation.dart';
3+
import 'package:shared_preferences/shared_preferences.dart';
4+
5+
enum ResponderState {
6+
inactive,
7+
active,
8+
}
9+
10+
/// Manages responder mode state (inactive / active), persists to SharedPreferences,
11+
/// and broadcasts changes via an observable Stream.
12+
class ResponderStateService {
13+
static const String _kStateKey = 'responder_state';
14+
15+
final SharedPreferences _prefs;
16+
final _stateController = StreamController<ResponderState>.broadcast();
17+
18+
ResponderState _currentState = ResponderState.inactive;
19+
20+
ResponderStateService(this._prefs) {
21+
_loadInitialState();
22+
}
23+
24+
/// The current, synchronously available state.
25+
ResponderState get currentState => _currentState;
26+
27+
/// Subscribe to state changes.
28+
Stream<ResponderState> get stateStream => _stateController.stream;
29+
30+
void _loadInitialState() {
31+
final active = _prefs.getBool(_kStateKey) ?? false;
32+
_currentState = active ? ResponderState.active : ResponderState.inactive;
33+
_stateController.add(_currentState);
34+
}
35+
36+
/// Toggle the current responder state.
37+
Future<void> toggleState() async {
38+
final newState = _currentState == ResponderState.active
39+
? ResponderState.inactive
40+
: ResponderState.active;
41+
42+
await setState(newState);
43+
}
44+
45+
/// Explicitly set the responder state.
46+
Future<void> setState(ResponderState newState) async {
47+
if (_currentState == newState) return;
48+
49+
_currentState = newState;
50+
await _prefs.setBool(_kStateKey, newState == ResponderState.active);
51+
52+
if (newState == ResponderState.active) {
53+
debugPrint('Responder mode activated');
54+
}
55+
56+
_stateController.add(newState);
57+
}
58+
59+
/// Dispose the stream controller.
60+
void dispose() {
61+
_stateController.close();
62+
}
63+
}

mobile_app/lib/services/tile_prefetch_service.dart

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import '../core/map/tile_math.dart';
1111
import '../data/tiles_repository.dart';
1212
import '../data/db/prefetch_database.dart';
1313
import '../features/map/map_service.dart';
14+
import 'package:latlong2/latlong.dart';
1415

1516
/// Progress snapshot for a prefetch job.
1617
class PrefetchProgress {
@@ -120,6 +121,31 @@ class TilePrefetchService {
120121
return jobId;
121122
}
122123

124+
/// Start a tile prefetch specifically for a geographic radius around [center].
125+
/// This bridges domain models [LatLng] with system implementations.
126+
Future<String> startPrefetchForRadius(LatLng center, int radiusMeters) async {
127+
final jobId = await startJob(
128+
lat: center.latitude,
129+
lon: center.longitude,
130+
radiusMeters: radiusMeters.toDouble(),
131+
minZoom: 14,
132+
maxZoom: 16,
133+
allowLargeJob: true, // Emergency responses might need many tiles
134+
);
135+
136+
// After starting, fetch estimate to fulfill logging requirement
137+
final estimate = totalTilesForJob(
138+
lat: center.latitude,
139+
lon: center.longitude,
140+
radiusMeters: radiusMeters.toDouble(),
141+
minZoom: 14,
142+
maxZoom: 16,
143+
);
144+
debugPrint('Prefetch queue size: ${estimate.total}');
145+
146+
return jobId;
147+
}
148+
123149
/// Pause a running job.
124150
Future<void> pauseJob(String jobId) async {
125151
_activeJobs[jobId]?.pause();
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:provider/provider.dart';
3+
import '../services/responder_state_service.dart';
4+
5+
/// FloatingActionButton to toggle between Active and Inactive responder states.
6+
class ResponderToggle extends StatelessWidget {
7+
const ResponderToggle({super.key});
8+
9+
@override
10+
Widget build(BuildContext context) {
11+
return Consumer<ResponderStateService>(
12+
builder: (context, stateService, child) {
13+
return StreamBuilder<ResponderState>(
14+
initialData: stateService.currentState,
15+
stream: stateService.stateStream,
16+
builder: (context, snapshot) {
17+
final state = snapshot.data ?? ResponderState.inactive;
18+
final isActive = state == ResponderState.active;
19+
20+
return FloatingActionButton.extended(
21+
heroTag: 'responder_toggle',
22+
onPressed: () => stateService.toggleState(),
23+
backgroundColor: isActive ? Colors.green : Theme.of(context).colorScheme.primary,
24+
foregroundColor: Colors.white,
25+
icon: Icon(
26+
isActive ? Icons.verified_user : Icons.health_and_safety,
27+
),
28+
label: Text(
29+
isActive ? "Responder Mode Active" : "Become Active Responder",
30+
style: const TextStyle(fontWeight: FontWeight.bold),
31+
),
32+
);
33+
},
34+
);
35+
},
36+
);
37+
}
38+
}

mobile_app/macos/Flutter/GeneratedPluginRegistrant.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,13 @@ import FlutterMacOS
66
import Foundation
77

88
import flutter_secure_storage_darwin
9+
import location
10+
import shared_preferences_foundation
911
import sqflite_darwin
1012

1113
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
1214
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
15+
LocationPlugin.register(with: registry.registrar(forPlugin: "LocationPlugin"))
16+
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
1317
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
1418
}

0 commit comments

Comments
 (0)