Di Sebalik Tabir "Derap": Sisa Peluh, Debugging, dan Impian Navigasi Tempatan
1. Pembuka: Perjalanan Mencipta "Derap" yang Penuh Liku
Membina sebuah aplikasi mudah alih navigasi dari titik kosong (scratch) bukanlah satu tugas yang mudah. Di atas kertas, idea melahirkan sebuah aplikasi peta dan amaran komuniti tempatan bernama "Derap" kedengaran sangat megah—sebuah alternatif bebas yang dekat di hati pemandu dan penunggang motosikal di Malaysia. Namun, realiti di alam pengkodan (coding) sering kali menuntut kesabaran yang tinggi.
Sehingga ke hari ini, hasil usaha ini ibarat benih yang baru disemai; hasilnya masih belum berbuah sepenuhnya, dan perjalanannya dipenuhi dengan pelbagai rintangan teknikal yang mencabar fikiran. Daripada ralat sistem Chromium yang degil hingga kepada peta kosong akibat koordinat emulator yang melompat jauh ke California, setiap baris kod adalah medan ujian ketahanan mental seorang pembangun.
2. Ulasan Kod dan Bedah Siasat Teknikal: Dari Ralat ke Kestabilan
Projek Derap telah melalui pelbagai fasa pembaikan kritikal (bug fixes) yang membentuk seni bina semasanya. Berikut adalah bedah siasat terhadap beberapa komponen kod utama yang telah dirombak:
- Coding:
.Package com.example.derap
import android.Manifest
import android.annotation.SuppressLint
import android.app.Application
import android.content.pm.PackageManager
import android.os.Bundle
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.room.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.net.HttpURLConnection
import java.net.URL
// ==========================================
// 1. ROOM DATABASE & REPOSITORY
// ==========================================
@Entity(tableName = "hazard_reports")
data class HazardReportEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0L,
val type: String, // KEMALANGAN, POLIS, TRAFIK, KAMERA_AES, LUBANG
val latitude: Double,
val longitude: Double,
val description: String,
val timestamp: Long = System.currentTimeMillis(),
val upvotes: Int = 1
)
@Dao
interface HazardReportDao {
@Query("SELECT * FROM hazard_reports ORDER BY timestamp DESC")
fun getAllHazards(): Flow<List<HazardReportEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertHazard(report: HazardReportEntity)
}
@Database(entities = [HazardReportEntity::class], version = 1, exportSchema = false)
abstract class DerapDatabase : RoomDatabase() {
abstract fun hazardReportDao(): HazardReportDao
companion object {
@Volatile
private var INSTANCE: DerapDatabase? = null
fun getDatabase(context: android.content.Context): DerapDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
DerapDatabase::class.java,
"derap_database"
).build()
INSTANCE = instance
instance
}
}
}
}
class HazardRepository(private val dao: HazardReportDao) {
val allHazards: Flow<List<HazardReportEntity>> = dao.getAllHazards()
suspend fun addHazard(report: HazardReportEntity) {
dao.insertHazard(report)
}
}
// ==========================================
// 2. VIEWMODEL
// ==========================================
class NavigationViewModel @JvmOverloads constructor(
application: Application,
private val repository: HazardRepository = HazardRepository(DerapDatabase.getDatabase(application).hazardReportDao())
) : AndroidViewModel(application) {
val hazards: StateFlow<List<HazardReportEntity>> = repository.allHazards
.stateIn(
scope = androidx.lifecycle.viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = emptyList()
)
fun reportHazard(type: String, lat: Double, lng: Double, desc: String) {
androidx.lifecycle.viewModelScope.launch(Dispatchers.IO) {
repository.addHazard(
HazardReportEntity(
type = type,
latitude = lat,
longitude = lng,
description = desc
)
)
}
}
}
// ==========================================
// 3. OSRM ROAD ROUTING SERVICE & MALAYSIAN TOLL
// ==========================================
class RoadRoutingService {
suspend fun fetchRouteGeometry(startLat: Double, startLng: Double, endLat: Double, endLng: Double): String {
return withContext(Dispatchers.IO) {
try {
val urlStr = "https://router.project-osrm.org/route/v1/driving/$startLng,$startLat;$endLng,$endLng?overview=full&geometries=geojson"
val url = URL(urlStr)
val connection = url.openConnection() as HttpURLConnection
connection.requestMethod = "GET"
connection.connectTimeout = 5000
connection.readTimeout = 5000
if (connection.responseCode == 200) {
connection.inputStream.bufferedReader().use { it.readText() }
} else {
""
}
} catch (e: Exception) {
""
}
}
}
fun calculateDistanceKm(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
val earthRadius = 6371.0
val dLat = Math.toRadians(lat2 - lat1)
val dLon = Math.toRadians(lon2 - lon1)
val a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2)
val c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
return earthRadius * c
}
}
object MalaysiaTollService {
fun estimateToll(destination: String): Pair<Double, String> {
return when {
destination.contains("Peringgit", true) || destination.contains("Simpang Ampat", true) ->
Pair(2.80, "Plaza Tol Ayer Keroh ➔ Simpang Ampat (PLUS E2)")
destination.contains("Seremban", true) || destination.contains("Pedas", true) ->
Pair(11.20, "Plaza Tol Ayer Keroh ➔ Pedas Linggi / Seremban (PLUS E2)")
destination.contains("Kuala Lumpur", true) || destination.contains("KLCC", true) || destination.contains("Sungai Besi", true) ->
Pair(16.50, "Plaza Tol Ayer Keroh ➔ Kuala Lumpur / Sg Besi (PLUS E2)")
else -> Pair(4.50, "Plaza Tol Lebuhraya Utara-Selatan (PLUS)")
}
}
}
// ==========================================
// 4. OPENSTREETMAP WEBVIEW COMPONENT (FIXED & OFFLINE-FALLBACK)
// ==========================================
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun OsmMapWebView(
modifier: Modifier = Modifier,
latitude: Double,
longitude: Double,
layerType: String,
apiKey: String = ""
) {
val tileUrl = when {
apiKey.isNotBlank() -> "https://api.maptiler.com/maps/streets-v2/{z}/{x}/{y}.png?key=$apiKey"
layerType == "satellite" -> "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
else -> "https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png"
}
val htmlContent = """
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body, html { margin: 0; padding: 0; height: 100%; width: 100%; background: #E5E7EB; overflow: hidden; }
#map { height: 100%; width: 100%; }
</style>
<!-- Inlined Leaflet Assets for Zero Latency & Offline Fallback -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
</head>
<body>
<div id="map"></div>
<script>
var map = L.map('map', { zoomControl: false }).setView([$latitude, $longitude], 15);
L.tileLayer('$tileUrl', {
maxZoom: 19,
subdomains: 'abcd',
attribution: '© OpenStreetMap & CartoDB'
}).addTo(map);
var marker = L.marker([$latitude, $longitude]).addTo(map)
.bindPopup('Lokasi Semasa Anda (Derap)').openPopup();
window.updateCarPosition = function(lat, lng) {
var newLatLng = new L.LatLng(lat, lng);
marker.setLatLng(newLatLng);
map.panTo(newLatLng);
};
window.zoomIn = function() { map.zoomIn(); };
window.zoomOut = function() { map.zoomOut(); };
</script>
</body>
</html>
""".trimIndent()
AndroidView(
modifier = modifier,
factory = { context ->
WebView(context).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.databaseEnabled = true
settings.allowFileAccess = true
settings.useWideViewPort = true
settings.loadWithOverviewMode = true
settings.safeBrowsingEnabled = false
setBackgroundColor(0x00000000)
webViewClient = object : WebViewClient() {
override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) {
super.onReceivedError(view, request, error)
}
}
loadDataWithBaseURL("https://basemaps.cartocdn.com", htmlContent, "text/html", "UTF-8", null)
}
},
update = { webView ->
webView.loadDataWithBaseURL("https://basemaps.cartocdn.com", htmlContent, "text/html", "UTF-8", null)
}
)
}
// ==========================================
// 5. TURN-BY-TURN NAVIGATION HUD SCREEN
// ==========================================
@Composable
fun NavigationScreen(
destinationName: String,
estimatedToll: Double,
tollName: String,
onEndNavigation: () -> Unit
) {
var speed by remember { mutableStateOf(108) } // Simulating dynamic speed around 110km/h limit
Box(modifier = Modifier.fillMaxSize()) {
// Top JKR Highway Signboard
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.align(Alignment.TopCenter),
colors = CardDefaults.cardColors(containerColor = Color(0xFF005C2B)), // JKR Green
shape = RoundedCornerShape(12.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("E2 PLUS", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 20.sp)
Text("300 m", color = Color.Yellow, fontWeight = FontWeight.Bold, fontSize = 20.sp)
}
Spacer(modifier = Modifier.height(4.dp))
Text("Simpang Keluar ke $destinationName", color = Color.White, fontSize = 14.sp)
Spacer(modifier = Modifier.height(8.dp))
// 3-Lane Guidance Bar
Row(
modifier = Modifier
.fillMaxWidth()
.background(Color.Black.copy(alpha = 0.3f), RoundedCornerShape(8.dp))
.padding(8.dp),
horizontalArrangement = Arrangement.SpaceAround
) {
Text("⬅️ Kiri", color = Color.LightGray, fontSize = 12.sp)
Text("▲ Tengah [Laluan Anda]", color = Color.Green, fontWeight = FontWeight.Bold, fontSize = 12.sp)
Text("➡️ Kanan", color = Color.LightGray, fontSize = 12.sp)
}
}
}
// Speed Limit & Speedometer Indicator (Bottom Right)
Column(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Malaysian National Speed Limit Sign (Red circle white bg)
Box(
modifier = Modifier
.size(56.dp)
.background(Color.White, CircleShape)
.padding(4.dp)
.background(Color.White, CircleShape),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.fillMaxSize()
.clip(CircleShape)
.background(Color.White),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("HAD", fontSize = 8.sp, color = Color.Red, fontWeight = FontWeight.Bold)
Text("110", fontSize = 18.sp, color = Color.Black, fontWeight = FontWeight.Bold)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
// Digital Speedometer
Card(
colors = CardDefaults.cardColors(containerColor = Color(0xFF0F172A)),
shape = RoundedCornerShape(12.dp)
) {
Column(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("$speed", color = Color.Green, fontSize = 28.sp, fontWeight = FontWeight.Bold)
Text("km/j", color = Color.White, fontSize = 10.sp)
Text("🟢 Trafik Lancar", color = Color.Green, fontSize = 10.sp)
}
}
}
// Bottom Trip Info & Controls
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.align(Alignment.BottomCenter),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E293B)),
shape = RoundedCornerShape(16.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Column {
Text("11:45 PG (ETA)", color = Color.Green, fontWeight = FontWeight.Bold, fontSize = 18.sp)
Text("12.4 km • 14 minit", color = Color.LightGray, fontSize = 12.sp)
}
Column(horizontalAlignment = Alignment.End) {
Text("Tol: RM %.2f".format(estimatedToll), color = Color.Yellow, fontWeight = FontWeight.Bold, fontSize = 16.sp)
Text(tollName, color = Color.LightGray, fontSize = 10.sp)
}
}
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = onEndNavigation,
colors = ButtonDefaults.buttonColors(containerColor = Color.Red),
modifier = Modifier.fillMaxWidth()
) {
Text("Tamatkan Navigasi", color = Color.White, fontWeight = FontWeight.Bold)
}
}
}
}
}
// ==========================================
// 6. DIRECTION MENU (ROUTE & TOLL SELECTION)
// ==========================================
@Composable
fun DirectionMenu(
destinationName: String,
destinationLat: Double,
destinationLng: Double,
onStartNavigation: (Boolean, Double, String) -> Unit, // avoidTolls, toll, tollName
onDismiss: () -> Unit
) {
var avoidTolls by remember { mutableStateOf(false) }
val (tollPrice, tollName) = MalaysiaTollService.estimateToll(destinationName)
val finalToll = if (avoidTolls) 0.0 else tollPrice
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.6f)),
contentAlignment = Alignment.BottomCenter
) {
Card(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight(),
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFFF8FAFC))
) {
Column(modifier = Modifier.padding(24.dp)) {
Text("Pilih Laluan & Arah", fontSize = 20.sp, fontWeight = FontWeight.Bold, color = Color(0xFF0F172A))
Spacer(modifier = Modifier.height(4.dp))
Text("Destinasi: $destinationName", fontSize = 14.sp, color = Color.DarkGray)
Spacer(modifier = Modifier.height(16.dp))
// Avoid Tolls Switch
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Elak Tol (Avoid Tolls)", fontWeight = FontWeight.Medium, color = Color(0xFF0F172A))
Switch(checked = avoidTolls, onCheckedChange = { avoidTolls = it })
}
Spacer(modifier = Modifier.height(12.dp))
// Route 1: Fastest / PLUS Highway
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = if (!avoidTolls) Color(0xFFE2E8F0) else Color.White)
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text("⚡ Terpantas (Lebuhraya PLUS E2)", fontWeight = FontWeight.Bold, color = Color(0xFF0F172A))
Text(if (avoidTolls) "Tidak Dipilih" else "RM %.2f".format(tollPrice), fontWeight = FontWeight.Bold, color = if (avoidTolls) Color.Gray else Color.Red)
}
Text("14 minit • 12.4 km • Aliran Trafik Lancar", fontSize = 12.sp, color = Color.DarkGray)
Text("Plaza Tol: $tollName (RFID / Touch 'n Go)", fontSize = 10.sp, color = Color.Gray)
}
}
Spacer(modifier = Modifier.height(8.dp))
// Route 2: No Toll / Federal Route
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = if (avoidTolls) Color(0xFFE2E8F0) else Color.White)
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text("🛣️ Tanpa Tol (Jalan Persekutuan 1)", fontWeight = FontWeight.Bold, color = Color(0xFF0F172A))
Text("RM 0.00 (Percuma)", fontWeight = FontWeight.Bold, color = Color(0xFF16A34A))
}
Text("22 minit • 14.1 km • Jalan Tempatan Santai", fontSize = 12.sp, color = Color.DarkGray)
}
}
Spacer(modifier = Modifier.height(20.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedButton(onClick = onDismiss, modifier = Modifier.weight(1f)) {
Text("Batal")
}
Button(
onClick = { onStartNavigation(avoidTolls, finalToll, if (avoidTolls) "Laluan Percuma" else tollName) },
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF0F172A))
) {
Text("Mula Navigasi", color = Color.White, fontWeight = FontWeight.Bold)
}
}
}
}
}
}
// ==========================================
// 7. MAIN APP SCREEN & ACTIVITY
// ==========================================
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DerapMainScreen() {
var layerType by remember { mutableStateOf("standard") }
var searchQuery by remember { mutableStateOf("") }
var selectedDestination by remember { mutableStateOf<Pair<String, Pair<Double, Double>>?>(null) }
var isNavigating by remember { mutableStateOf(false) }
var activeToll by remember { mutableStateOf(0.0) }
var activeTollName by remember { mutableStateOf("") }
var activeDestinationName by remember { mutableStateOf("") }
// Lokasi lalai Ayer Keroh / Melaka
val currentLat = 2.3164
val currentLng = 102.3150
val sampleLocations = listOf(
Pair("Jln Solok Pantai Peringgit, Melaka", Pair(2.2150, 102.2500)),
Pair("A Famosa Fort, Melaka", Pair(2.1936, 102.2508)),
Pair("Melaka Sentral Bus Terminal", Pair(2.2341, 102.2471)),
Pair("Plaza Tol Ayer Keroh", Pair(2.3456, 102.3120)),
Pair("KLCC Menara Berkembar Petronas", Pair(3.1579, 101.7116))
)
val routingService = remember { RoadRoutingService() }
val sortedLocations = sampleLocations.sortedBy {
routingService.calculateDistanceKm(currentLat, currentLng, it.second.first, it.second.second)
}.filter { it.first.contains(searchQuery, ignoreCase = true) || searchQuery.isBlank() }
Scaffold(
topBar = {
if (!isNavigating) {
TopAppBar(
title = { Text("Derap Navigasi & Komuniti") },
actions = {
IconButton(onClick = { layerType = if (layerType == "standard") "satellite" else "standard" }) {
Text(if (layerType == "standard") "🛰️" else "🗺️")
}
}
)
}
}
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
// Peta OpenStreetMap Leaflet dengan Fallback
OsmMapWebView(
modifier = Modifier.fillMaxSize(),
latitude = currentLat,
longitude = currentLng,
layerType = layerType
)
if (!isNavigating) {
// Bar Carian & Senarai Destinasi Paling Hampir Dahulu
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.align(Alignment.TopCenter)
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFFF8FAFC))
) {
OutlinedTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
placeholder = { Text("Cari destinasi, R&R atau lebuh raya...") },
singleLine = true
)
}
if (searchQuery.isNotBlank() || true) {
Spacer(modifier = Modifier.height(8.dp))
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.95f))
) {
LazyColumn(modifier = Modifier.heightIn(max = 200.dp)) {
items(sortedLocations) { loc ->
val dist = routingService.calculateDistanceKm(currentLat, currentLng, loc.second.first, loc.second.second)
ListItem(
headlineContent = { Text(loc.first, fontWeight = FontWeight.Bold, color = Color(0xFF0F172A)) },
supportingContent = { Text("Jarak: %.1f km dari lokasi semasa".format(dist), fontSize = 12.sp, color = Color.Gray) },
modifier = Modifier.clickable {
selectedDestination = loc
}
)
Divider()
}
}
}
}
}
// Butang Mula Pantas (One-Tap Launch) di bahagian bawah
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.align(Alignment.BottomCenter)
) {
Button(
onClick = {
activeDestinationName = "Jln Solok Pantai Peringgit, Melaka"
activeToll = 2.80
activeTollName = "Plaza Tol Ayer Keroh ➔ Simpang Ampat (PLUS E2)"
isNavigating = true
},
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF16A34A)),
shape = RoundedCornerShape(16.dp)
) {
Text("🚀 Mula Navigasi: Jln Solok Pantai Peringgit", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 16.sp)
}
}
}
// Direction Menu Modal
selectedDestination?.let { dest ->
DirectionMenu(
destinationName = dest.first,
destinationLat = dest.second.first,
destinationLng = dest.second.second,
onStartNavigation = { avoidTolls, toll, tollName ->
activeDestinationName = dest.first
activeToll = toll
activeTollName = tollName
isNavigating = true
selectedDestination = null
},
onDismiss = { selectedDestination = null }
)
}
// Active Turn-by-Turn Navigation Screen Overlay
if (isNavigating) {
NavigationScreen(
destinationName = activeDestinationName,
estimatedToll = activeToll,
tollName = activeTollName,
onEndNavigation = { isNavigating = false }
)
}
}
}
}
class MainActivity : ComponentActivity() {
private val requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted ->
// Pengendalian status kebenaran GPS
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
requestPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION)
}
setContent {
MaterialTheme(
colorScheme = lightColorScheme(
primary = Color(0xFF0F172A),
background = Color(0xFFFFFFFF),
surface = Color(0xFFF8FAFC)
)
) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
DerapMainScreen()
}
}
}
}
}
3. Penutup: Mampukah "Derap" Bersaing dengan Aplikasi "TuJu" Milik Kerajaan?
Apabila membincangkan landskap aplikasi navigasi tempatan di Malaysia, nama TuJu—aplikasi rasmi hasil kerjasama Lembaga Lebuhraya Malaysia (LLM) dan Prosignal Consortium—pasti menjadi sebutan. TuJu hadir dengan kelebihan sokongan infostruktur kerajaan, integrasi data rasmi lebuh raya, paparan CCTV langsung, serta pautan terus kepada agensi bantuan.
Lantas, di manakah kedudukan Derap yang diusahakan secara indie ini?
Walaupun hasil usaha pembangunan Derap masih belum berbuah sepenuhnya dari segi komersial atau skala pelancaran nasional, kekuatan utamanya terletak pada sifatnya yang lincah, terbuka, dan membumi:
- Kebebasan Tanpa Bergantung pada API Berbayar: Dengan enjin teras OpenStreetMap yang percuma selamanya, Derap tidak terikat dengan kos pelesenan yang tinggi.
- Fokus Komuniti Terus (Hyper-local Crowdsourcing): Sistem pelaporan bahaya berasaskan Room DB dan arahan suara memberikan alternatif pantas tanpa gangguan antara muka yang rumit.
- Falsafah Sumber Terbuka: Derap membuktikan bahawa pembangun tempatan mampu merungkai isu teknikal kompleks—daripada penambahbaikan prestasi kanvas GPU hingga ke penyelesaian ralat WebView—secara mandiri.
Perjalanan Derap mungkin masih panjang. Namun, setiap baris kod yang bebas ralat dan setiap ujian unit yang mencatat status BUILD SUCCESSFUL membuktikan bahawa benih inovasi tempatan ini mempunyai potensi besar untuk berdiri megah di persada teknologi navigasi tanah air pada masahadapan.

0 Ulasan