1214 lines
47 KiB
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
|
|
);
|
|
}
|
|
}
|