Cubaan Membangunkan App Navigasi Derap Ganti Tuju

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: '&copy; 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:

  1. ​Kebebasan Tanpa Bergantung pada API Berbayar: Dengan enjin teras OpenStreetMap yang percuma selamanya, Derap tidak terikat dengan kos pelesenan yang tinggi.
  2. ​Fokus Komuniti Terus (Hyper-local Crowdsourcing): Sistem pelaporan bahaya berasaskan Room DB dan arahan suara memberikan alternatif pantas tanpa gangguan antara muka yang rumit.
  3. ​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.







Catat Ulasan

0 Ulasan