import 'dart:async'; import 'dart:convert'; import 'package:http/http.dart' as http; import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; import 'package:geolocator/geolocator.dart'; import 'package:flutter_tts/flutter_tts.dart'; import '../services/weather_service.dart'; import '../utils/navigation_helper.dart'; import '../utils/session_manager.dart'; import '../utils/api_config.dart'; enum NavStatus { waiting, readyToStart, active, paused, finished } class NavigationScreen extends StatefulWidget { final Map routeData; final int targetMinutes; NavigationScreen({super.key, required this.routeData, required this.targetMinutes}); @override State createState() => _NavigationScreenState(); } class _NavigationScreenState extends State { final MapController _mapController = MapController(); final WeatherService _weatherService = WeatherService(); List _routePoints = []; LatLng? _currentPosition; Map? _weatherData; StreamSubscription? _positionStream; bool _isLoading = true; bool _isFollowingUser = true; // Mode auto-center // --- Fitur Navigasi --- final FlutterTts _tts = FlutterTts(); NavStatus _navStatus = NavStatus.waiting; int _targetPointIndex = 0; bool _isOffRoute = false; String _nextInstruction = "Mencari Rute..."; int _lastManeuverIndex = -1; DateTime? _lastAlertTime; double _distanceToNextTurn = 0; bool _isRouteReversed = false; int _lastReachedIndex = 0; bool _directionDetected = false; bool _isCircular = true; // Default rute melingkar bool _isRaining = false; bool _rainWarningSpoken = false; bool _dryWarningSpoken = false; int _rainWarningCount = 0; int _dryWarningCount = 0; // --- Real-time Stats --- double _remainingDistance = 0; int _remainingSeconds = 0; int _elapsedSeconds = 0; double _accumulatedDistance = 0; LatLng? _lastTrackedPosition; Timer? _timer; Timer? _weatherTimer; // Timer khusus untuk refresh cuaca periodik LatLng? _pausePosition; // Titik di mana perjalanan dijeda @override void initState() { super.initState(); _initTTS(); _parseRoutePoints(); _initLocationTracking(); // Set sisa jarak awal _remainingDistance = double.tryParse(widget.routeData['jarak'].toString()) ?? 0; // Set sisa waktu awal (detik) _remainingSeconds = widget.targetMinutes * 60; _elapsedSeconds = 0; // Aktifkan refresh cuaca otomatis setiap 5 menit (300 detik) _weatherTimer = Timer.periodic(const Duration(minutes: 5), (timer) { if (_currentPosition != null) { _fetchWeather(_currentPosition!.latitude, _currentPosition!.longitude); _fetchForecast(_currentPosition!.latitude, _currentPosition!.longitude); } }); } Future _initTTS() async { await _tts.setLanguage("id-ID"); await _tts.setPitch(1.0); await _tts.setSpeechRate(0.5); } Future _speak(String text) async { await _tts.speak(text); } void _parseRoutePoints() { try { final String coordsString = widget.routeData['titik_koordinat']; final List decoded = jsonDecode(coordsString); setState(() { _routePoints = decoded.map((c) => LatLng( double.parse(c['lat'].toString()), double.parse(c['lng'].toString()) )).toList(); // DETEKSI RUTE MELINGKAR (Circular Route) // Jika titik awal dan akhir sangat dekat (< 50m), anggap rute melingkar. if (_routePoints.isNotEmpty) { double startEndDist = NavigationHelper.calculateDistance(_routePoints.first, _routePoints.last); _isCircular = startEndDist < 50; } }); } catch (e) { print("Error parsing route points: $e"); } } Future _initLocationTracking() async { bool serviceEnabled; LocationPermission permission; serviceEnabled = await Geolocator.isLocationServiceEnabled(); if (!serviceEnabled) { return Future.error('Location services are disabled.'); } permission = await Geolocator.checkPermission(); if (permission == LocationPermission.denied) { permission = await Geolocator.requestPermission(); if (permission == LocationPermission.denied) { return Future.error('Location permissions are denied'); } } // Ambil posisi awal Position position = await Geolocator.getCurrentPosition(); _updatePosition(position); // Ambil cuaca awal sekali saja saat GPS terdeteksi pertama kali _fetchWeather(position.latitude, position.longitude); _fetchForecast(position.latitude, position.longitude); // Stream posisi real-time try { final LocationSettings locationSettings = AndroidSettings( accuracy: LocationAccuracy.bestForNavigation, distanceFilter: 1, // Set ke 1 agar update sesering mungkin intervalDuration: const Duration(seconds: 1), // Paksa update tiap 1 detik foregroundNotificationConfig: const ForegroundNotificationConfig( notificationText: "Smart Cycling sedang melacak perjalanan Anda", notificationTitle: "Navigasi Aktif", enableWakeLock: true, ), ); _positionStream = Geolocator.getPositionStream(locationSettings: locationSettings) .listen((Position position) { _updatePosition(position); }, onError: (e) { debugPrint("GPS Stream Error: $e"); }); } catch (e) { debugPrint("Error starting location stream: $e"); // Fallback: Try standard settings if AndroidSettings fails _positionStream = Geolocator.getPositionStream( locationSettings: const LocationSettings( accuracy: LocationAccuracy.best, distanceFilter: 0, ), ).listen(_updatePosition); } setState(() => _isLoading = false); } void _updatePosition(Position position) { if (!mounted) return; final newPos = LatLng(position.latitude, position.longitude); debugPrint("GPS Update: ${position.latitude}, ${position.longitude}"); setState(() { _currentPosition = newPos; if (_navStatus == NavStatus.active) { if (_lastTrackedPosition != null) { double dist = NavigationHelper.calculateDistance(_lastTrackedPosition!, newPos); _accumulatedDistance += (dist / 1000); // km } _lastTrackedPosition = newPos; } }); // --- LOGIKA NAVIGASI --- if (_routePoints.isNotEmpty) { _processNavigation(newPos); } // Auto center map jika mode following aktif if (_isFollowingUser) { _mapController.move(newPos, 16); } } void _processNavigation(LatLng currentPos) { // 0. Cek jika sudah selesai if (_navStatus == NavStatus.finished) return; // 1. Hitung Kedekatan dengan Jalur untuk semua Case double minDistance = double.infinity; int nearestIndex = -1; for (int i = 0; i < _routePoints.length - 1; i++) { double dist = NavigationHelper.getDistanceToSegment(currentPos, _routePoints[i], _routePoints[i+1]); if (dist < minDistance) { minDistance = dist; nearestIndex = i; } } // 2. Cek Entry Point (Jika belum mulai) if (_navStatus == NavStatus.waiting) { if (minDistance < 25 && nearestIndex != -1) { // [SYARAT START KHUSUS RUTE SATU JALUR] if (!_isCircular) { bool isAtStart = nearestIndex <= 2; // Dekat titik awal asli bool isAtEnd = nearestIndex >= _routePoints.length - 3; // Dekat titik akhir asli if (!isAtStart && !isAtEnd) { // Pengguna di tengah rute satu jalur, jangan izinkan start return; } } setState(() { _navStatus = NavStatus.readyToStart; _targetPointIndex = nearestIndex; // Mark titik mulai terdekat // Hitung sisa jarak dari titik masuk ke finish _calculateRemainingDistance(nearestIndex, currentPos); }); _showStartDialog(); } return; } // 3. Cek Off-Route (Hanya saat perjalanan sedang berlangsung atau dijeda) if (_navStatus == NavStatus.active || _navStatus == NavStatus.paused) { // Peringatan Keluar Jalur: Tingkatkan ke 15m karena 5m masih sering false-positive if (minDistance > 10.0) { if (!_isOffRoute) { setState(() { _isOffRoute = true; if (_navStatus == NavStatus.active) { _navStatus = NavStatus.paused; _pausePosition = currentPos; _timer?.cancel(); } }); _speak("Anda keluar dari jalur, perjalanan dihentikan sementara"); } } else if (minDistance <= 10.0) { // Kembali ke jalur (Toleransi 10m agar lebih stabil) if (_isOffRoute) { // 1. Cek dulu apakah dia meloncat rute (Potong Jalan) if (nearestIndex > _lastReachedIndex + 3) { _handleMissedPath(); return; } // Jika tidak meloncat, otomatis resume perjalanan setState(() { _isOffRoute = false; if (_navStatus == NavStatus.paused) { _navStatus = NavStatus.active; _startCountdown(); _speak("Anda kembali ke jalur, melanjutkan perjalanan"); } }); } } } // 4. Strict Point Sequence & U-Turn Detection if (_navStatus == NavStatus.active && nearestIndex != -1) { // A. DETEKSI ARAH AWAL (Tentukan arah maju atau mundur saat mulai bergerak) if (!_directionDetected) { if (nearestIndex != _lastReachedIndex) { // Jika rute melingkar, pengguna bisa gowes ke arah titik 1 (Maju) atau titik Akhir (Mundur) if (_isCircular && nearestIndex > _routePoints.length - 5) { setState(() { _isRouteReversed = true; _routePoints = _routePoints.reversed.toList(); _lastReachedIndex = 0; }); _speak("Arah perjalanan: Mundur. Tetap pada jalur ini."); } else { // Arah Maju (Rute satu jalur sudah dibalik di _startTrip jika perlu) setState(() { _lastReachedIndex = nearestIndex; }); _speak("Arah perjalanan: Maju. Selamat bersepeda."); } _directionDetected = true; } return; } // B. DETEKSI PUTAR BALIK (U-TURN) // Jika user mundur dari titik terjauh yang pernah dicapai (toleransi 3 titik) if (nearestIndex < _lastReachedIndex - 3) { _handleUTurn(); return; } // 4b. DETEKSI POTONG JALUR (STRICT ZERO TOLERANCE) // Jika user melompati lebih dari 3 titik koordinat (indikasi potong kompas) if (nearestIndex > _lastReachedIndex + 3) { _handleMissedPath(); return; } // Update progress target jika user bergerak maju if (nearestIndex > _lastReachedIndex) { setState(() { _lastReachedIndex = nearestIndex; }); } // Hitung sisa jarak dinamis _calculateRemainingDistance(nearestIndex, currentPos); // Turn-by-Turn Guidance - Threshold 2-5 meter sesuai permintaan for (int j = nearestIndex + 1; j <= nearestIndex + 5 && j < _routePoints.length - 1; j++) { String? maneuver = NavigationHelper.detectManeuver(_routePoints, j); if (maneuver != null && _lastManeuverIndex != j) { double distToTurn = NavigationHelper.calculateDistance(currentPos, _routePoints[j]); // Trigger Peringatan H-2m (disesuaikan rentang 2-5m agar GPS tidak terlewat) if (distToTurn <= 3 && distToTurn >= 1) { setState(() { _nextInstruction = "$maneuver di depan"; _lastManeuverIndex = j; _distanceToNextTurn = distToTurn; }); break; } } } } // 5. Deteksi Selesai (Finish Point) // Syarat Selesai: // 1. Dekat dengan titik terakhir rute // 2. Jika rute melingkar: Sudah menempuh minimal 80% dari rute (mencegah selesai prematur di awal) // 3. Jika rute satu jalur: Sudah menempuh minimal 90% dari rute double distToEnd = NavigationHelper.calculateDistance(currentPos, _routePoints.last); double progressPercent = (_routePoints.isNotEmpty) ? _lastReachedIndex / (_routePoints.length - 1) : 0; bool canFinish = _isCircular ? (distToEnd < 25 && progressPercent > 0.9) // Harus satu putaran penuh : (distToEnd < 25 && progressPercent > 0.9); // Harus sampai di ujung seberang if (_navStatus == NavStatus.active && canFinish) { _showFinishDialog(); } } void _showStartDialog() { _speak("Anda sudah berada di jalur rute. Mulai perjalanan anda sekarang?"); showDialog( context: context, barrierDismissible: false, builder: (context) => BackdropFilter( filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5), child: AlertDialog( backgroundColor: const Color(0xFF1E2140), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), title: const Text("Mulai Perjalanan?", style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), content: const Text("Anda sudah memasuki area rute. Klik 'Start' untuk mencatat performa gowes Anda.", style: TextStyle(color: Colors.white70)), actions: [ TextButton( onPressed: () { Navigator.pop(context); _startTrip(); }, child: const Text("START", style: TextStyle(color: Colors.greenAccent, fontWeight: FontWeight.bold)) ), ], ), ), ); } void _startTrip() { setState(() { if (!_isCircular) { // RUTE SATU JALUR: Jika start dari ujung akhir, balik rutenya agar flow tetap 0 -> N if (_targetPointIndex >= _routePoints.length - 3) { _routePoints = _routePoints.reversed.toList(); _targetPointIndex = 0; } _lastReachedIndex = _targetPointIndex; } else { // RUTE MELINGKAR: Susun ulang rute agar titik masuk menjadi titik 0 if (_targetPointIndex > 0 && _targetPointIndex < _routePoints.length - 1) { List startPart = _routePoints.sublist(_targetPointIndex); List endPart = _routePoints.sublist(0, _targetPointIndex + 1); _routePoints = [...startPart, ...endPart]; } _targetPointIndex = 0; _lastReachedIndex = 0; } _navStatus = NavStatus.active; _isOffRoute = false; // Reset status off-route saat mulai _directionDetected = false; _isRouteReversed = false; _accumulatedDistance = 0; _lastTrackedPosition = _currentPosition; }); // Hitung sisa jarak awal secara eksplisit agar UI langsung terupdate if (_currentPosition != null) { _calculateRemainingDistance(_lastReachedIndex, _currentPosition!); } _speak("Perjalanan dimulai. Selamat menempuh rute ${_isCircular ? 'bundar' : 'satu jalur'}."); _startCountdown(); } void _togglePauseTrip() { if (_isOffRoute) { _speak("Anda tidak bisa memulai perjalanan sebelum memasuki jalur rute"); showDialog( context: context, builder: (context) => AlertDialog( backgroundColor: Colors.red.shade900, title: const Text("Peringatan", style: TextStyle(color: Colors.white)), content: const Text("Anda tidak bisa melanjutkan perjalanan sebelum memasuki jalur rute kembali.", style: TextStyle(color: Colors.white)), actions: [ TextButton(onPressed: () => Navigator.pop(context), child: const Text("OK", style: TextStyle(color: Colors.white))), ], ), ); return; } setState(() { if (_navStatus == NavStatus.active) { _navStatus = NavStatus.paused; _pausePosition = _currentPosition; // Simpan titik saat ini untuk cek resume _timer?.cancel(); _speak("Perjalanan dijeda"); } else if (_navStatus == NavStatus.paused) { // Cek jika posisi resume masih sama (toleransi 10m) if (_pausePosition != null && _currentPosition != null) { double dist = NavigationHelper.calculateDistance(_currentPosition!, _pausePosition!); if (dist > 10.0) { _speak("Gagal melanjutkan. Anda harus kembali ke titik awal saat menjeda perjalanan."); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text("⚠️ Anda harus kembali ke titik jeda terakhir (Toleransi 10m)."), backgroundColor: Colors.orange, ) ); return; } } _navStatus = NavStatus.active; _startCountdown(); _speak("Perjalanan dilanjutkan"); } }); } void _handleJumpAhead() { _navStatus = NavStatus.finished; _timer?.cancel(); _positionStream?.cancel(); _speak("Anda me-resume rute di lokasi berbeda, misi otomatis gagal."); showDialog( context: context, barrierDismissible: false, builder: (context) => AlertDialog( backgroundColor: Colors.red.shade900, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), title: const Row( children: [ Icon(Icons.warning_amber_rounded, color: Colors.white), SizedBox(width: 10), Text("Misi Gagal!", style: TextStyle(color: Colors.white)), ], ), content: const Text("Anda terdeteksi melanjutkan perjalanan di titik yang berbeda dari titik jeda terakhir. Misi ini otomatis gagal karena ketidaksesuaian data.", style: TextStyle(color: Colors.white)), actions: [ TextButton( onPressed: () { Navigator.pop(context); // Tutup dialog Navigator.pop(context); // Kembali ke Home }, child: const Text("KEMBALI KE HOME", style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)) ), ], ), ); } void _handleUTurn() { _navStatus = NavStatus.finished; _timer?.cancel(); _positionStream?.cancel(); _speak("Anda memutar balik arah, misi otomatis gagal."); showDialog( context: context, barrierDismissible: false, builder: (context) => AlertDialog( backgroundColor: Colors.orange.shade900, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), title: const Row( children: [ Icon(Icons.warning_amber_rounded, color: Colors.white), SizedBox(width: 10), Text("Misi Gagal!", style: TextStyle(color: Colors.white)), ], ), content: const Text("Anda memutar balik arah. Misi ini otomatis gagal dan tidak akan tersimpan ke riwayat.", style: TextStyle(color: Colors.white)), actions: [ TextButton( onPressed: () { Navigator.pop(context); // Tutup dialog Navigator.pop(context); // Kembali ke Home }, child: const Text("KEMBALI KE HOME", style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)) ), ], ), ); } void _handleMissedPath() { _navStatus = NavStatus.finished; _timer?.cancel(); _positionStream?.cancel(); _speak("Ada jalur yang terlewat, misi otomatis gagal."); showDialog( context: context, barrierDismissible: false, builder: (context) => AlertDialog( backgroundColor: Colors.red.shade900, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), title: const Row( children: [ Icon(Icons.error_outline, color: Colors.white), SizedBox(width: 10), Text("Misi Gagal!", style: TextStyle(color: Colors.white)), ], ), content: const Text("Gps mendeteksi Anda memotong rute. Misi ini otomatis gagal dan tidak akan tersimpan ke riwayat.", style: TextStyle(color: Colors.white)), actions: [ TextButton( onPressed: () { Navigator.pop(context); // Tutup dialog Navigator.pop(context); // Kembali ke Home }, child: const Text("KEMBALI KE HOME", style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)) ), ], ), ); } void _confirmManualFinish() { showDialog( context: context, builder: (context) => AlertDialog( backgroundColor: const Color(0xFF1E2140), title: const Text("Selesaikan Perjalanan?", style: TextStyle(color: Colors.white)), content: const Text("Apakah anda yakin ingin mengakhiri perjalanan ini?", style: TextStyle(color: Colors.white70)), actions: [ TextButton(onPressed: () => Navigator.pop(context), child: const Text("BATAL")), TextButton( onPressed: () { Navigator.pop(context); _finishTrip(manually: true); }, child: const Text("AKHIRI", style: TextStyle(color: Colors.redAccent)) ), ], ), ); } void _showFinishDialog() { if (_navStatus == NavStatus.finished) return; setState(() => _navStatus = NavStatus.finished); _timer?.cancel(); _speak("Misi selesai! Anda sudah berhasil menyelesaikan perjalanan ini."); showDialog( context: context, barrierDismissible: false, builder: (context) => BackdropFilter( filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5), child: AlertDialog( backgroundColor: const Color(0xFF1E2140), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), title: const Row( children: [ Icon(Icons.stars, color: Colors.yellowAccent), SizedBox(width: 10), Text("Misi Selesai!", style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), ], ), content: const Text("Selamat! Anda sudah berhasil menyelesaikan perjalanan ini dan melewati semua titik rute.", style: TextStyle(color: Colors.white70)), actions: [ ElevatedButton( onPressed: () { Navigator.pop(context); _finishTrip(manually: false); }, style: ElevatedButton.styleFrom( backgroundColor: Colors.greenAccent, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)) ), child: const Text("SIMPAN & SELESAI", style: TextStyle(color: Colors.black, fontWeight: FontWeight.bold)) ), ], ), ), ); } void _calculateRemainingDistance(int nearestIndex, LatLng currentPos) { double total = 0; // Gunakan _lastReachedIndex atau nearestIndex untuk sisa jarak yang konsisten int currentIndex = nearestIndex; if (currentIndex + 1 < _routePoints.length) { // Jarak dari posisi saat ini ke titik rute berikutnya total += NavigationHelper.calculateDistance(currentPos, _routePoints[currentIndex + 1]); // Tambahkan jarak antar semua titik rute yang tersisa for (int i = currentIndex + 1; i < _routePoints.length - 1; i++) { total += NavigationHelper.calculateDistance(_routePoints[i], _routePoints[i + 1]); } } setState(() { _remainingDistance = total / 1000; // Ubah ke KM }); } void _startCountdown() { _timer?.cancel(); // Pastikan tidak ada timer ganda _timer = Timer.periodic(const Duration(seconds: 1), (timer) { if (_navStatus != NavStatus.active) { timer.cancel(); return; } setState(() { _remainingSeconds--; _elapsedSeconds++; if (_remainingSeconds == 300) _speak("Sisa waktu lima menit lagi"); if (_remainingSeconds == 60) _speak("Sisa waktu satu menit lagi, ayo percepat gowes anda!"); }); }); } String _formatTime(int seconds) { bool isNegative = seconds < 0; int absSecs = seconds.abs(); int m = absSecs ~/ 60; int s = absSecs % 60; String result = "${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}"; return isNegative ? "+$result" : result; // Ganti '-' dengan '+' sesuai permintaan } Future _finishTrip({required bool manually}) async { _navStatus = NavStatus.finished; _timer?.cancel(); _positionStream?.cancel(); bool isSuccess = !manually && _remainingSeconds >= 0; String status = isSuccess ? "BERHASIL" : "GAGAL"; // Simpan ke Riwayat await _saveToHistory(status); if (!mounted) return; // Jika manual finish butuh dialog hasil berbeda showDialog( context: context, barrierDismissible: false, builder: (context) => BackdropFilter( filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5), child: AlertDialog( backgroundColor: const Color(0xFF1E2140), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), content: Column( mainAxisSize: MainAxisSize.min, children: [ Icon( isSuccess ? Icons.emoji_events : (manually ? Icons.flag_outlined : Icons.timer_off_outlined), size: 80, color: isSuccess ? Colors.yellowAccent : (manually ? Colors.blueAccent : Colors.redAccent), ), const SizedBox(height: 20), Text( manually ? "PERJALANAN SELESAI" : "MISI $status!", style: const TextStyle(color: Colors.white, fontSize: 24, fontWeight: FontWeight.bold), ), const SizedBox(height: 10), Text( isSuccess ? "Selamat! Anda berhasil mencapai target waktu sebagai pesepeda tangguh." : (manually ? "Perjalanan telah dihentikan oleh pengguna. Data Anda sudah tersimpan di riwayat." : "Sayang sekali, Anda belum mencapai target waktu. Jangan menyerah, coba lagi nanti!"), textAlign: TextAlign.center, style: const TextStyle(color: Colors.white70), ), const SizedBox(height: 30), ElevatedButton( onPressed: () { Navigator.pop(context); // Tutup dialog Navigator.pop(context); // Kembali ke Home }, style: ElevatedButton.styleFrom( backgroundColor: Colors.blueAccent, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)) ), child: const Text("KEMBALI", style: TextStyle(color: Colors.white)), ) ], ), ), ), ); if (manually) { _speak("Perjalanan diakhiri. Riwayat perjalanan Anda telah tersimpan."); } else { _speak(isSuccess ? "Selamat, perjalan anda selesai. Anda berhasil dalam misi perjalanan ini!" : "Perjalanan selesai. Anda gagal dalam misi perjalanan ini. Tetap semangat!"); } } Future _saveToHistory(String status) async { double totalRouteDistance = double.tryParse(widget.routeData['jarak'].toString()) ?? 0; double traveledDistance = _accumulatedDistance; if (traveledDistance < 0.05) traveledDistance = 0; // Anggap 0 jika di bawah 50 meter untuk mengatasi jitter GPS if (status == "BERHASIL") traveledDistance = totalRouteDistance; int timeSpentSeconds = (widget.targetMinutes * 60) - _remainingSeconds; // waktu_tempuh bisa lebih besar dari target (misal target 10 menit, tempuh 13 menit) // Jika sisa waktu negatif, berarti telat. if (timeSpentSeconds < 0) timeSpentSeconds = 0; double avgSpeed = 0; if (timeSpentSeconds > 0) { double timeInHours = timeSpentSeconds / 3600; avgSpeed = traveledDistance / timeInHours; } final url = Uri.parse("${ApiConfig.baseUrl}/save_history.php"); try { await http.post(url, body: { 'id_users': SessionManager.userId.toString(), 'id_routes': widget.routeData['id_routes'].toString(), 'target_waktu': (widget.targetMinutes * 60).toString(), 'status': status, 'jarak_tempuh': traveledDistance.toStringAsFixed(2), 'waktu_tempuh': timeSpentSeconds.toString(), 'kecepatan_rata_rata': avgSpeed.toStringAsFixed(1), 'tanggal': DateTime.now().toString(), }); } catch (e) { debugPrint("Gagal menyimpan riwayat ke server: $e"); } } Future _fetchWeather(double lat, double lon) async { final data = await _weatherService.fetchWeather(lat, lon); if (data != null && mounted) { setState(() { _weatherData = data; _checkWeatherAlerts(data); }); } } Future _fetchForecast(double lat, double lon) async { final data = await _weatherService.fetchForecast(lat, lon); if (data != null && mounted) { // Cek forecast untuk peringatan "akan hujan" (H-5 menit disimulasikan dari probabilitas hujan terdekat) _checkForecastAlerts(data); } } void _checkWeatherAlerts(Map data) { // Cek kondisi saat ini String mainWeather = data['weather'][0]['main'].toString().toLowerCase(); bool currentlyRaining = mainWeather.contains('rain') || mainWeather.contains('drizzle') || mainWeather.contains('thunderstorm'); if (currentlyRaining && !_isRaining) { _isRaining = true; _dryWarningSpoken = false; if (!_rainWarningSpoken && _rainWarningCount < 2) { _speak("Peringatan, saat ini mulai turun hujan. Mohon segera berteduh demi keamanan."); _showWeatherSnackBar("⚠️ Hujan Turun! Mohon segera berteduh."); _rainWarningSpoken = true; _rainWarningCount++; } } else if (!currentlyRaining && _isRaining) { _isRaining = false; _rainWarningSpoken = false; if (!_dryWarningSpoken && _dryWarningCount < 2) { _speak("Hujan telah reda. Anda dapat melanjutkan perjalanan dengan hati-hati."); _showWeatherSnackBar("🌤️ Hujan Reda. Tetap waspada jalan licin."); _dryWarningSpoken = true; _dryWarningCount++; } } } void _checkForecastAlerts(Map data) { // Simulasi H-5 Menit menggunakan data forecast terdekat // Karena API gratis hanya memberikan per 3 jam, kita cek probability of precipitation (pop) // Jika pop tinggi (> 0.5) pada forecast terdekat, berikan peringatan dini. if (data['list'] != null && data['list'].isNotEmpty) { var nextForecast = data['list'][0]; double pop = double.tryParse(nextForecast['pop'].toString()) ?? 0; String nextWeather = nextForecast['weather'][0]['main'].toString().toLowerCase(); if (pop > 0.7 && (nextWeather.contains('rain')) && !_isRaining) { if (!_rainWarningSpoken && _rainWarningCount < 2) { // Anggap ini peringatan "Akan Hujan" _speak("Peringatan, diperkirakan akan turun hujan dalam waktu dekat. Persiapkan diri Anda."); _showWeatherSnackBar("⛈️ Perkiraan Hujan dalam waktu dekat!"); _rainWarningSpoken = true; _rainWarningCount++; } } } } void _showWeatherSnackBar(String message) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(message, style: const TextStyle(fontWeight: FontWeight.bold)), backgroundColor: Colors.blueGrey.shade900, behavior: SnackBarBehavior.floating, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), duration: const Duration(seconds: 5), ) ); } @override void dispose() { _timer?.cancel(); _weatherTimer?.cancel(); _positionStream?.cancel(); super.dispose(); } @override Widget build(BuildContext context) { String weatherIcon = _weatherData != null ? "https://openweathermap.org/img/wn/${_weatherData!['weather'][0]['icon']}@2x.png" : ""; return Scaffold( body: Stack( children: [ // MAP LAYER FlutterMap( mapController: _mapController, options: MapOptions( initialCenter: _routePoints.isNotEmpty ? _routePoints.first : const LatLng(-8.3, 113.6), initialZoom: 15, onPositionChanged: (pos, hasGesture) { if (hasGesture && _isFollowingUser) { setState(() => _isFollowingUser = false); // Matikan auto-center jika user geser peta } }, ), children: [ TileLayer( urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', userAgentPackageName: 'com.example.smart_cycling', ), if (_routePoints.isNotEmpty) PolylineLayer( polylines: [ Polyline( points: _routePoints, color: Colors.blueAccent.withOpacity(0.6), strokeWidth: 8, ), Polyline( points: _routePoints, color: Colors.white.withOpacity(0.3), strokeWidth: 2, ), ], ), if (_routePoints.isNotEmpty) MarkerLayer( markers: [ // Start/Finish Marker (Flag) Marker( point: _routePoints.last, width: 50, height: 50, child: Column( children: [ Container( padding: const EdgeInsets.all(4), decoration: const BoxDecoration( color: Colors.redAccent, shape: BoxShape.circle, ), child: const Icon(Icons.flag, color: Colors.white, size: 20), ), const Text("FINISH", style: TextStyle(color: Colors.redAccent, fontWeight: FontWeight.bold, fontSize: 10)), ], ), ), ], ), if (_currentPosition != null) MarkerLayer( markers: [ Marker( point: _currentPosition!, width: 40, height: 40, child: Stack( alignment: Alignment.center, children: [ // Shadow/Glow effect Container( width: 35, height: 35, decoration: BoxDecoration( shape: BoxShape.circle, color: Colors.blue.withOpacity(0.3), ), ), // Outer White Circle Container( width: 20, height: 20, decoration: const BoxDecoration( shape: BoxShape.circle, color: Colors.white, ), ), // Inner Blue Dot (Google Maps Style) Container( width: 14, height: 14, decoration: const BoxDecoration( shape: BoxShape.circle, color: Colors.blue, ), ), // Small Cyclist Overlay const Icon(Icons.directions_bike, size: 10, color: Colors.white), ], ), ), ], ), ], ), // TOP HEADER (Route Info & Navigation Instruction) Positioned( top: 50, left: 20, right: 20, child: Column( children: [ // Instruction Panel const SizedBox(height: 0), Row( children: [ CircleAvatar( backgroundColor: const Color(0xFF1E2140), child: IconButton( icon: const Icon(Icons.arrow_back, color: Colors.white), onPressed: () => Navigator.pop(context), ), ), const SizedBox(width: 15), Expanded( child: ClipRRect( borderRadius: BorderRadius.circular(20), child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), child: Container( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), decoration: BoxDecoration( color: const Color(0xFF1E2140).withOpacity(0.85), borderRadius: BorderRadius.circular(20), border: Border.all(color: Colors.white.withOpacity(0.2)), boxShadow: [ BoxShadow(color: Colors.black.withOpacity(0.3), blurRadius: 10) ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( widget.routeData['nama_rute'] ?? "Navigasi", style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 16), overflow: TextOverflow.ellipsis, ), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( "${_remainingDistance.toStringAsFixed(2)} KM lagi • ${widget.routeData['kondisi_medan']}", style: const TextStyle(color: Colors.blueAccent, fontSize: 12, fontWeight: FontWeight.w600), ), Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ if (_navStatus == NavStatus.active || _navStatus == NavStatus.paused) Padding( padding: const EdgeInsets.only(bottom: 4.0), child: Row( mainAxisSize: MainAxisSize.min, children: [ _buildSmallControlBtn( icon: _navStatus == NavStatus.active ? Icons.pause_circle_filled : Icons.play_circle_filled, color: _navStatus == NavStatus.active ? Colors.orangeAccent : Colors.greenAccent, onTap: _togglePauseTrip, ), const SizedBox(width: 8), _buildSmallControlBtn( icon: Icons.stop_circle, color: Colors.redAccent, onTap: _confirmManualFinish, ), ], ), ), Text( _formatTime(_remainingSeconds), style: TextStyle( color: _remainingSeconds < 0 ? Colors.redAccent : Colors.greenAccent, fontWeight: FontWeight.bold, fontSize: 16, ), ), ], ), ], ), ], ), ), ), ), ), ], ), ], ), ), // FLOATING ACTION BUTTONS Positioned( right: 20, bottom: 310, // Naikkan ke atas agar tidak tertutup child: Column( children: [ FloatingActionButton( heroTag: "follow", backgroundColor: _isFollowingUser ? Colors.blue : const Color(0xFF1E2140), mini: true, onPressed: () { setState(() => _isFollowingUser = !_isFollowingUser); if (_isFollowingUser && _currentPosition != null) { _mapController.move(_currentPosition!, 16); } }, child: Icon( _isFollowingUser ? Icons.my_location : Icons.location_searching, color: Colors.white, ), ), ], ), ), // WEATHER PANEL (Premium Deep Blue Glassmorphism) if (_weatherData != null) Positioned( bottom: 30, left: 20, right: 20, child: ClipRRect( borderRadius: BorderRadius.circular(30), child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), child: Container( padding: const EdgeInsets.all(24), decoration: BoxDecoration( gradient: LinearGradient( colors: [ const Color(0xFF1E2140).withOpacity(0.95), const Color(0xFF0D0F21).withOpacity(0.85) ], begin: Alignment.topLeft, end: Alignment.bottomRight, ), borderRadius: BorderRadius.circular(30), border: Border.all(color: Colors.white.withOpacity(0.1)), ), child: Column( children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row( children: [ Image.network(weatherIcon, width: 60, height: 60, errorBuilder: (c,e,s) => const Icon(Icons.wb_sunny, color: Colors.yellow, size: 40), ), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( "${_weatherData!['main']['temp'].round()}°C", style: const TextStyle(color: Colors.white, fontSize: 36, fontWeight: FontWeight.bold), ), Text( _weatherData!['weather'][0]['description'].toString().toUpperCase(), style: const TextStyle(color: Colors.blueAccent, fontSize: 12, fontWeight: FontWeight.w600, letterSpacing: 1.2), ), ], ), ], ), Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( color: Colors.blueAccent.withOpacity(0.2), borderRadius: BorderRadius.circular(10), border: Border.all(color: Colors.blueAccent.withOpacity(0.5)), ), child: const Text("REAL-TIME", style: TextStyle(color: Colors.blueAccent, fontSize: 10, fontWeight: FontWeight.bold)), ), ], ), const SizedBox(height: 20), Divider(color: Colors.white.withOpacity(0.1)), const SizedBox(height: 10), Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ _buildWeatherDetail(Icons.water_drop, "${_weatherData!['main']['humidity']}%", "Kelembaban"), _buildWeatherDetail(Icons.air, "${_weatherData!['wind']['speed']} m/s", "Angin"), _buildWeatherDetail(Icons.visibility, "${(_weatherData!['visibility']/1000).toStringAsFixed(1)} km", "Jarak Pandang"), ], ), ], ), ), ), ), ), if (_isLoading) const Center(child: CircularProgressIndicator(color: Colors.white)), ], ), ); } Widget _buildWeatherDetail(IconData icon, String value, String label) { return Column( children: [ Icon(icon, color: Colors.blueAccent, size: 20), const SizedBox(height: 8), Text(value, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), Text(label, style: const TextStyle(color: Colors.white54, fontSize: 10)), ], ); } Widget _buildSmallControlBtn({required IconData icon, required Color color, required VoidCallback onTap}) { return GestureDetector( onTap: onTap, child: Icon(icon, color: color, size: 24), // Ukuran kecil tanpa latar belakang ); } }