feat(map): implement map navigation, OSRM routing, and Nominatim search

This commit is contained in:
2026-05-17 01:53:02 +07:00
parent f9a23dee58
commit c8a1818e97
3 changed files with 2001 additions and 3 deletions
@@ -1 +1,963 @@
export '../screens.dart' show NavigationModeScreen;
// ignore_for_file: use_build_context_synchronously
// lib/features/navigation_mode/navigation_mode_screen.dart
//
// NavigationModeScreen — map + current location + search Nominatim + OSRM route + TTS
// Stack yang dipakai: flutter_map, latlong2, geolocator, dio, flutter_tts (via TtsService)
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:geolocator/geolocator.dart';
import 'package:latlong2/latlong.dart';
import '../../app/injection_container.dart';
import '../../core/network/api_client.dart';
import '../../core/services/tts_service.dart';
// ─── helpers ────────────────────────────────────────────────────────────────
Dio get _api => sl<ApiClient>().dio;
// Nominatim search result
class _Place {
final String displayName;
final LatLng position;
const _Place({required this.displayName, required this.position});
}
// One turn-by-turn step
class _Step {
final String instruction;
final double distanceM;
final LatLng point;
const _Step(
{required this.instruction,
required this.distanceM,
required this.point});
}
// ─── BLoC-lite state (plain ChangeNotifier to avoid heavy BLoC boilerplate
// while staying consistent with the rest of screens.dart approach) ─────
enum _NavPhase { idle, locating, routing, navigating, error }
class _NavState extends ChangeNotifier {
_NavPhase phase = _NavPhase.idle;
String statusText = 'Ketuk tombol lokasi atau cari tujuan.';
LatLng? currentPosition;
_Place? destination;
List<LatLng> routePoints = const [];
List<_Step> steps = const [];
int currentStepIndex = 0;
String? errorMessage;
// tracking
StreamSubscription<Position>? _posStream;
void _set(_NavPhase p, String status) {
phase = p;
statusText = status;
notifyListeners();
}
// ── locate ──────────────────────────────────────────────────────────────
Future<bool> locate() async {
_set(_NavPhase.locating, 'Mencari lokasi GPS…');
try {
LocationPermission perm = await Geolocator.checkPermission();
if (perm == LocationPermission.denied) {
perm = await Geolocator.requestPermission();
}
if (perm == LocationPermission.deniedForever) {
_set(_NavPhase.error, 'Izin lokasi diblokir permanen.');
return false;
}
final pos = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high,
).timeout(const Duration(seconds: 12));
currentPosition = LatLng(pos.latitude, pos.longitude);
_set(_NavPhase.idle, 'Lokasi ditemukan. Cari tujuan atau ketuk peta.');
_reportToBackend(pos);
return true;
} on TimeoutException {
_set(_NavPhase.error, 'GPS timeout. Pastikan GPS aktif.');
return false;
} catch (e) {
_set(_NavPhase.error, 'GPS error: $e');
return false;
}
}
void _reportToBackend(Position pos) {
_api
.post('/user/location', data: {
'lat': pos.latitude,
'lng': pos.longitude,
'accuracy': pos.accuracy,
'speed': pos.speed,
'heading': pos.heading,
})
.timeout(const Duration(seconds: 5))
.catchError((_) => null);
}
// ── search Nominatim ─────────────────────────────────────────────────────
Future<List<_Place>> searchPlaces(String query) async {
if (query.trim().isEmpty) return const [];
try {
final res = await Dio().get(
'https://nominatim.openstreetmap.org/search',
queryParameters: {
'q': query,
'format': 'jsonv2',
'limit': 6,
'addressdetails': 0,
if (currentPosition != null) 'viewbox': _viewbox(currentPosition!),
if (currentPosition != null) 'bounded': 0,
},
options: Options(
headers: {'User-Agent': 'WalkGuide/1.0 (walkguide@campus.ac.id)'},
receiveTimeout: const Duration(seconds: 8),
),
);
final list = res.data as List;
return list.map((e) {
final lat = double.tryParse(e['lat'].toString()) ?? 0;
final lng = double.tryParse(e['lon'].toString()) ?? 0;
return _Place(
displayName: e['display_name'].toString(),
position: LatLng(lat, lng),
);
}).toList();
} catch (_) {
return const [];
}
}
String _viewbox(LatLng c) =>
'${c.longitude - 0.5},${c.latitude + 0.5},${c.longitude + 0.5},${c.latitude - 0.5}';
// ── reverse geocode ──────────────────────────────────────────────────────
Future<String> reverseGeocode(LatLng pos) async {
try {
final res = await Dio().get(
'https://nominatim.openstreetmap.org/reverse',
queryParameters: {
'lat': pos.latitude,
'lon': pos.longitude,
'format': 'jsonv2',
},
options: Options(
headers: {'User-Agent': 'WalkGuide/1.0 (walkguide@campus.ac.id)'},
receiveTimeout: const Duration(seconds: 6),
),
);
return res.data['display_name']?.toString() ??
'${pos.latitude.toStringAsFixed(5)}, ${pos.longitude.toStringAsFixed(5)}';
} catch (_) {
return '${pos.latitude.toStringAsFixed(5)}, ${pos.longitude.toStringAsFixed(5)}';
}
}
// ── OSRM routing ─────────────────────────────────────────────────────────
Future<void> buildRoute(_Place dest) async {
if (currentPosition == null) {
await locate();
if (currentPosition == null) return;
}
destination = dest;
_set(_NavPhase.routing, 'Menghitung rute ke ${_shortName(dest)}…');
final origin = currentPosition!;
try {
final url = 'http://router.project-osrm.org/route/v1/foot/'
'${origin.longitude},${origin.latitude};'
'${dest.position.longitude},${dest.position.latitude}'
'?steps=true&geometries=geojson&overview=full&annotations=false';
final res = await Dio().get(
url,
options: Options(receiveTimeout: const Duration(seconds: 12)),
);
final route = res.data['routes'][0];
final geom = route['geometry']['coordinates'] as List;
routePoints = geom.map((c) {
final lst = c as List;
return LatLng((lst[1] as num).toDouble(), (lst[0] as num).toDouble());
}).toList();
// parse steps
final legs = route['legs'] as List;
final rawSteps = <_Step>[];
for (final leg in legs) {
for (final step in leg['steps'] as List) {
final maneuver = step['maneuver'] as Map;
final instruction = _buildInstruction(maneuver, step);
final dist = (step['distance'] as num?)?.toDouble() ?? 0;
final loc = maneuver['location'] as List;
rawSteps.add(_Step(
instruction: instruction,
distanceM: dist,
point:
LatLng((loc[1] as num).toDouble(), (loc[0] as num).toDouble()),
));
}
}
steps = rawSteps;
currentStepIndex = 0;
_set(_NavPhase.navigating,
steps.isNotEmpty ? steps[0].instruction : 'Ikuti rute biru.');
notifyListeners();
_startTracking();
} catch (e) {
_set(_NavPhase.error, 'Gagal mendapat rute: $e');
}
}
String _shortName(_Place p) {
final parts = p.displayName.split(',');
return parts.first.trim();
}
String _buildInstruction(Map maneuver, Map step) {
final type = maneuver['type']?.toString() ?? '';
final modifier = maneuver['modifier']?.toString() ?? '';
final dist = (step['distance'] as num?)?.toInt() ?? 0;
final streetName = step['name']?.toString() ?? '';
final distStr = dist > 0 ? ' dalam ${dist}m' : '';
final street = streetName.isNotEmpty ? ' ke $streetName' : '';
switch (type) {
case 'depart':
return 'Mulai berjalan$street.';
case 'arrive':
return 'Anda telah tiba di tujuan.';
case 'turn':
return 'Belok ${_modifier(modifier)}$distStr$street.';
case 'continue':
return 'Lanjutkan lurus$distStr$street.';
case 'merge':
return 'Bergabung ke jalan$street.';
case 'roundabout':
return 'Masuk bundaran$street.';
case 'exit roundabout':
return 'Keluar bundaran$street.';
default:
return 'Lanjutkan$distStr$street.';
}
}
String _modifier(String m) {
switch (m) {
case 'left':
return 'kiri';
case 'right':
return 'kanan';
case 'slight left':
return 'sedikit ke kiri';
case 'slight right':
return 'sedikit ke kanan';
case 'sharp left':
return 'tajam ke kiri';
case 'sharp right':
return 'tajam ke kanan';
case 'uturn':
return 'balik arah';
default:
return m;
}
}
// ── live tracking ─────────────────────────────────────────────────────────
void _startTracking() {
_posStream?.cancel();
_posStream = Geolocator.getPositionStream(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
distanceFilter: 5,
),
).listen((pos) {
currentPosition = LatLng(pos.latitude, pos.longitude);
_reportToBackend(pos);
_updateStep();
notifyListeners();
});
}
void _updateStep() {
if (steps.isEmpty || phase != _NavPhase.navigating) return;
if (currentStepIndex >= steps.length - 1) return;
final current = steps[currentStepIndex];
final dist =
const Distance().as(LengthUnit.Meter, currentPosition!, current.point);
// advance step when within 20 m
if (dist < 20) {
currentStepIndex++;
if (currentStepIndex < steps.length) {
final next = steps[currentStepIndex];
statusText = next.instruction;
sl<TtsService>().speak(next.instruction);
notifyListeners();
}
}
}
void stopNavigation() {
_posStream?.cancel();
_posStream = null;
destination = null;
routePoints = const [];
steps = const [];
currentStepIndex = 0;
_set(_NavPhase.idle, 'Navigasi dihentikan. Cari tujuan baru.');
}
@override
void dispose() {
_posStream?.cancel();
super.dispose();
}
}
// ─── Screen ─────────────────────────────────────────────────────────────────
class NavigationModeScreen extends StatefulWidget {
const NavigationModeScreen({super.key});
@override
State<NavigationModeScreen> createState() => _NavigationModeScreenState();
}
class _NavigationModeScreenState extends State<NavigationModeScreen> {
final _navState = _NavState();
final _mapCtrl = MapController();
final _searchCtrl = TextEditingController();
final _searchFocus = FocusNode();
List<_Place> _suggestions = const [];
bool _searchLoading = false;
bool _showSuggestions = false;
Timer? _debounce;
bool _followUser = true;
@override
void initState() {
super.initState();
_navState.addListener(_onStateChange);
WidgetsBinding.instance.addPostFrameCallback((_) => _init());
}
Future<void> _init() async {
sl<TtsService>()
.speak('Mode navigasi. Ucapkan tujuan atau gunakan kolom pencarian.');
await _navState.locate();
if (_navState.currentPosition != null) {
_mapCtrl.move(_navState.currentPosition!, 16);
}
}
void _onStateChange() {
if (!mounted) return;
setState(() {});
final pos = _navState.currentPosition;
if (pos != null && _followUser) {
_mapCtrl.move(pos, _mapCtrl.camera.zoom);
}
}
Future<void> _onSearchChanged(String query) async {
_debounce?.cancel();
if (query.trim().isEmpty) {
setState(() {
_suggestions = const [];
_showSuggestions = false;
});
return;
}
_debounce = Timer(const Duration(milliseconds: 450), () async {
setState(() => _searchLoading = true);
final results = await _navState.searchPlaces(query);
if (mounted) {
setState(() {
_suggestions = results;
_showSuggestions = results.isNotEmpty;
_searchLoading = false;
});
}
});
}
Future<void> _selectPlace(_Place place) async {
_searchCtrl.text = place.displayName.split(',').first.trim();
_searchFocus.unfocus();
setState(() => _showSuggestions = false);
_followUser = false;
_mapCtrl.move(place.position, 15);
await _navState.buildRoute(place);
// TTS first instruction
if (_navState.steps.isNotEmpty) {
sl<TtsService>().speak(_navState.steps.first.instruction);
}
_followUser = true;
}
Future<void> _onMapTap(TapPosition _, LatLng pos) async {
if (_navState.phase == _NavPhase.navigating) return;
final name = await _navState.reverseGeocode(pos);
final place = _Place(displayName: name, position: pos);
_selectPlace(place);
}
void _centerOnUser() {
final pos = _navState.currentPosition;
if (pos == null) {
_navState.locate();
return;
}
_followUser = true;
_mapCtrl.move(pos, 17);
}
void _stopNav() {
_navState.stopNavigation();
_searchCtrl.clear();
setState(() => _showSuggestions = false);
sl<TtsService>().speak('Navigasi dihentikan.');
}
@override
void dispose() {
_debounce?.cancel();
_navState.removeListener(_onStateChange);
_navState.dispose();
_searchCtrl.dispose();
_searchFocus.dispose();
super.dispose();
}
// ── build ──────────────────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
final phase = _navState.phase;
final pos = _navState.currentPosition;
final isNavigating = phase == _NavPhase.navigating;
return SafeArea(
child: Stack(
children: [
// ── map ──────────────────────────────────────────────────────
FlutterMap(
mapController: _mapCtrl,
options: MapOptions(
initialCenter: pos ?? const LatLng(-7.2575, 112.7521),
initialZoom: 15,
onTap: _onMapTap,
onPositionChanged: (_, hasGesture) {
if (hasGesture) _followUser = false;
},
),
children: [
// OSM tile layer
TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.walkguide.app',
),
// route polyline
if (_navState.routePoints.isNotEmpty)
PolylineLayer(
polylines: [
Polyline(
points: _navState.routePoints,
strokeWidth: 5,
color: const Color(0xFF1A56DB),
),
],
),
// markers
MarkerLayer(
markers: [
// current position – big blue pulsing dot
if (pos != null)
Marker(
point: pos,
width: 56,
height: 56,
child: const _PulsingDot(),
),
// destination pin
if (_navState.destination != null)
Marker(
point: _navState.destination!.position,
width: 40,
height: 48,
alignment: Alignment.topCenter,
child: const Icon(
Icons.location_pin,
color: Color(0xFFDC2626),
size: 40,
),
),
// step waypoints (dimmed)
for (int i = 0; i < _navState.steps.length; i++)
Marker(
point: _navState.steps[i].point,
width: 14,
height: 14,
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: i == _navState.currentStepIndex
? const Color(0xFF1A56DB)
: const Color(0xFF93C5FD),
border: Border.all(color: Colors.white, width: 2),
),
),
),
],
),
],
),
// ── top search panel ─────────────────────────────────────────
Positioned(
top: 12,
left: 12,
right: 12,
child: Column(
children: [
// search bar
_SearchBar(
controller: _searchCtrl,
focusNode: _searchFocus,
loading: _searchLoading,
onChanged: _onSearchChanged,
onClear: () {
_searchCtrl.clear();
setState(() => _showSuggestions = false);
},
),
// suggestions dropdown
if (_showSuggestions)
_SuggestionList(
items: _suggestions,
onSelect: _selectPlace,
),
],
),
),
// ── turn-by-turn banner (when navigating) ────────────────────
if (isNavigating && _navState.steps.isNotEmpty)
Positioned(
bottom: 130,
left: 12,
right: 12,
child: _TurnCard(
step: _navState.steps[_navState.currentStepIndex],
stepIndex: _navState.currentStepIndex,
totalSteps: _navState.steps.length,
onRepeat: () => sl<TtsService>().speak(
_navState.steps[_navState.currentStepIndex].instruction),
),
),
// ── bottom status bar ────────────────────────────────────────
Positioned(
bottom: 16,
left: 12,
right: 12,
child: _StatusBar(
phase: phase,
text: _navState.statusText,
isNavigating: isNavigating,
onStop: _stopNav,
),
),
// ── FAB: center on user ──────────────────────────────────────
Positioned(
right: 16,
bottom: isNavigating ? 196 : 80,
child: FloatingActionButton.small(
heroTag: 'nav_center',
backgroundColor: Colors.white,
foregroundColor: const Color(0xFF1A56DB),
onPressed: _centerOnUser,
child: const Icon(Icons.my_location),
),
),
// ── loading overlay ──────────────────────────────────────────
if (phase == _NavPhase.locating || phase == _NavPhase.routing)
Positioned.fill(
child: Container(
color: Colors.black26,
child: const Center(child: CircularProgressIndicator()),
),
),
],
),
);
}
}
// ─── Sub-widgets ─────────────────────────────────────────────────────────────
class _SearchBar extends StatelessWidget {
final TextEditingController controller;
final FocusNode focusNode;
final bool loading;
final ValueChanged<String> onChanged;
final VoidCallback onClear;
const _SearchBar({
required this.controller,
required this.focusNode,
required this.loading,
required this.onChanged,
required this.onClear,
});
@override
Widget build(BuildContext context) {
return Material(
elevation: 4,
borderRadius: BorderRadius.circular(14),
child: TextField(
controller: controller,
focusNode: focusNode,
onChanged: onChanged,
textInputAction: TextInputAction.search,
decoration: InputDecoration(
hintText: 'Cari tujuan…',
prefixIcon: const Icon(Icons.search, color: Color(0xFF1A56DB)),
suffixIcon: loading
? const Padding(
padding: EdgeInsets.all(12),
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: controller.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.close),
onPressed: onClear,
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide.none,
),
filled: true,
fillColor: Colors.white,
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
),
),
);
}
}
class _SuggestionList extends StatelessWidget {
final List<_Place> items;
final ValueChanged<_Place> onSelect;
const _SuggestionList({required this.items, required this.onSelect});
@override
Widget build(BuildContext context) {
return Material(
elevation: 6,
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(14)),
child: ClipRRect(
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(14)),
child: Column(
children: [
for (final place in items)
InkWell(
onTap: () => onSelect(place),
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
const Icon(Icons.place_outlined,
color: Color(0xFF64748B), size: 20),
const SizedBox(width: 12),
Expanded(
child: Text(
place.displayName,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 14),
),
),
],
),
),
),
],
),
),
);
}
}
class _TurnCard extends StatelessWidget {
final _Step step;
final int stepIndex;
final int totalSteps;
final VoidCallback onRepeat;
const _TurnCard({
required this.step,
required this.stepIndex,
required this.totalSteps,
required this.onRepeat,
});
IconData _directionIcon(String instruction) {
final lower = instruction.toLowerCase();
if (lower.contains('kiri')) return Icons.turn_left;
if (lower.contains('kanan')) return Icons.turn_right;
if (lower.contains('balik')) return Icons.u_turn_left;
if (lower.contains('bundaran')) return Icons.roundabout_right;
if (lower.contains('tiba')) return Icons.flag;
return Icons.straight;
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xFF1A56DB),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.18),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Row(
children: [
Icon(_directionIcon(step.instruction), color: Colors.white, size: 40),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
step.instruction,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w700),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
'Langkah ${stepIndex + 1} dari $totalSteps'
'${step.distanceM > 0 ? ' · ${step.distanceM.toInt()} m' : ''}',
style:
const TextStyle(color: Color(0xFFBFDBFE), fontSize: 12),
),
],
),
),
IconButton(
onPressed: onRepeat,
icon: const Icon(Icons.volume_up, color: Colors.white),
tooltip: 'Ulangi instruksi',
),
],
),
);
}
}
class _StatusBar extends StatelessWidget {
final _NavPhase phase;
final String text;
final bool isNavigating;
final VoidCallback onStop;
const _StatusBar({
required this.phase,
required this.text,
required this.isNavigating,
required this.onStop,
});
Color get _bgColor {
switch (phase) {
case _NavPhase.error:
return const Color(0xFFFEF2F2);
case _NavPhase.navigating:
return const Color(0xFFF0FDF4);
default:
return Colors.white;
}
}
Color get _textColor {
switch (phase) {
case _NavPhase.error:
return const Color(0xFF991B1B);
case _NavPhase.navigating:
return const Color(0xFF166534);
default:
return const Color(0xFF0F172A);
}
}
IconData get _icon {
switch (phase) {
case _NavPhase.error:
return Icons.error_outline;
case _NavPhase.navigating:
return Icons.navigation;
case _NavPhase.locating:
return Icons.gps_fixed;
default:
return Icons.map;
}
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: _bgColor,
borderRadius: BorderRadius.circular(14),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 10,
),
],
border: Border.all(
color: phase == _NavPhase.error
? const Color(0xFFFECACA)
: const Color(0xFFE2E8F0),
),
),
child: Row(
children: [
Icon(_icon, color: _textColor, size: 20),
const SizedBox(width: 10),
Expanded(
child: Text(
text,
style: TextStyle(color: _textColor, fontWeight: FontWeight.w600),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
if (isNavigating) ...[
const SizedBox(width: 8),
TextButton.icon(
onPressed: onStop,
icon: const Icon(Icons.stop_circle_outlined,
color: Color(0xFFDC2626), size: 18),
label: const Text('Stop',
style: TextStyle(color: Color(0xFFDC2626))),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8)),
),
],
],
),
);
}
}
/// Animated blue dot for current position
class _PulsingDot extends StatefulWidget {
const _PulsingDot();
@override
State<_PulsingDot> createState() => _PulsingDotState();
}
class _PulsingDotState extends State<_PulsingDot>
with SingleTickerProviderStateMixin {
late AnimationController _ctrl;
late Animation<double> _scale;
late Animation<double> _opacity;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this, duration: const Duration(milliseconds: 1400))
..repeat(reverse: false);
_scale = Tween(begin: 0.6, end: 1.4)
.animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOut));
_opacity = Tween(begin: 0.6, end: 0.0)
.animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOut));
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _ctrl,
builder: (_, __) => Stack(
alignment: Alignment.center,
children: [
// pulse ring
Transform.scale(
scale: _scale.value,
child: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: const Color(0xFF1A56DB)
.withValues(alpha: _opacity.value * 0.4),
border: Border.all(
color:
const Color(0xFF1A56DB).withValues(alpha: _opacity.value),
width: 2,
),
),
),
),
// solid dot
Container(
width: 20,
height: 20,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: const Color(0xFF1A56DB),
border: Border.all(color: Colors.white, width: 3),
boxShadow: [
BoxShadow(
color: const Color(0xFF1A56DB).withValues(alpha: 0.4),
blurRadius: 6,
),
],
),
),
],
),
);
}
}