MIF_E31231027/smart_cycling/lib/screens/navigation_screen.dart

1214 lines
47 KiB
Dart

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<String, dynamic> routeData;
final int targetMinutes;
NavigationScreen({super.key, required this.routeData, required this.targetMinutes});
@override
State<NavigationScreen> createState() => _NavigationScreenState();
}
class _NavigationScreenState extends State<NavigationScreen> {
final MapController _mapController = MapController();
final WeatherService _weatherService = WeatherService();
List<LatLng> _routePoints = [];
LatLng? _currentPosition;
Map<String, dynamic>? _weatherData;
StreamSubscription<Position>? _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<void> _initTTS() async {
await _tts.setLanguage("id-ID");
await _tts.setPitch(1.0);
await _tts.setSpeechRate(0.5);
}
Future<void> _speak(String text) async {
await _tts.speak(text);
}
void _parseRoutePoints() {
try {
final String coordsString = widget.routeData['titik_koordinat'];
final List<dynamic> 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<void> _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<LatLng> startPart = _routePoints.sublist(_targetPointIndex);
List<LatLng> 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<void> _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<void> _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<void> _fetchWeather(double lat, double lon) async {
final data = await _weatherService.fetchWeather(lat, lon);
if (data != null && mounted) {
setState(() {
_weatherData = data;
_checkWeatherAlerts(data);
});
}
}
Future<void> _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<String, dynamic> 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<String, dynamic> 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
);
}
}