diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 49bb381..3b702dc 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -38,6 +38,7 @@ kotlin { implementation(compose.preview) implementation(libs.androidx.activity.compose) implementation(libs.koin.android) + implementation(libs.ktor.client.android) } commonMain.dependencies { implementation(compose.runtime) @@ -64,6 +65,12 @@ kotlin { api(libs.compose.webview.multiplatform) implementation(libs.multiplatform.markdown.renderer) + + implementation(libs.mapcompose.mp) + implementation(libs.ktor.client.core) + } + iosMain.dependencies { + implementation(libs.ktor.client.darwin) } commonTest.dependencies { implementation(kotlin("test")) diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index c32d172..e26bb67 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -1,6 +1,7 @@ + @@ -10,7 +11,8 @@ android:label="@string/app_name" android:name=".MRTApp" android:supportsRtl="true" - android:theme="@android:style/Theme.Material.Light.NoActionBar"> + android:theme="@android:style/Theme.Material.Light.NoActionBar" + android:usesCleartextTraffic="true"> + try { + val url = URL("https://tile.openstreetmap.org/$zoomLvl/$col/$row.png") + val connection = url.openConnection() as HttpURLConnection + // OSM requires a user-agent + connection.setRequestProperty("User-Agent", "Chrome/120.0.0.0 Safari/537.36") + connection.doInput = true + connection.connect() + connection.inputStream.asSource() + } catch (e: Exception) { + e.printStackTrace() + null + } + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/net/adhikary/mrtbuddy/utils/KtorClient.android.kt b/composeApp/src/androidMain/kotlin/net/adhikary/mrtbuddy/utils/KtorClient.android.kt new file mode 100644 index 0000000..fb8f9d0 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/net/adhikary/mrtbuddy/utils/KtorClient.android.kt @@ -0,0 +1,8 @@ +package net.adhikary.mrtbuddy.utils + +import io.ktor.client.HttpClient +import io.ktor.client.engine.android.Android + +actual fun getKtorClient(): HttpClient { + return HttpClient(Android) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/marker.xml b/composeApp/src/commonMain/composeResources/drawable/marker.xml new file mode 100644 index 0000000..1ac4d8f --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/marker.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/marker_train.xml b/composeApp/src/commonMain/composeResources/drawable/marker_train.xml new file mode 100644 index 0000000..c9a2923 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/marker_train.xml @@ -0,0 +1,25 @@ + + + + + + + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/station_map.xml b/composeApp/src/commonMain/composeResources/drawable/station_map.xml new file mode 100644 index 0000000..78722f5 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/station_map.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/composeApp/src/commonMain/composeResources/values-bn/strings.xml b/composeApp/src/commonMain/composeResources/values-bn/strings.xml index d1c2e69..80f481f 100644 --- a/composeApp/src/commonMain/composeResources/values-bn/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-bn/strings.xml @@ -37,6 +37,7 @@ উত্তরা দক্ষিণ উত্তরা কেন্দ্র উত্তরা উত্তর + ঢাকা মেট্রোরেল ডিপো, দিয়াবাড়ী জানুয়ারি ফেব্রুয়ারি @@ -98,6 +99,11 @@ স্বয়ংক্রিয় কার্ড সংরক্ষণ স্ক্যান করার সময় স্বয়ংক্রিয়ভাবে কার্ডের তথ্য সংরক্ষণ করুন + + অন্যান্য + স্টেশন + মেট্রো স্টেশন ম্যাপ + এইমাত্র মিনিট আগে diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 667a5ab..ef75812 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -37,6 +37,7 @@ Uttara South Uttara Center Uttara North + Dhaka Metro Rail Depot, Diabari Jan Feb @@ -98,6 +99,11 @@ Auto-save card details Automatically save card details when scanning + + Others + Station + Metro Station Map + Just now minute ago diff --git a/composeApp/src/commonMain/kotlin/net/adhikary/mrtbuddy/Osm.kt b/composeApp/src/commonMain/kotlin/net/adhikary/mrtbuddy/Osm.kt new file mode 100644 index 0000000..cbfaf1a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/net/adhikary/mrtbuddy/Osm.kt @@ -0,0 +1,5 @@ +package net.adhikary.mrtbuddy + +import ovh.plrapps.mapcompose.core.TileStreamProvider + +expect fun makeOsmTileStreamProvider(): TileStreamProvider \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/net/adhikary/mrtbuddy/data/model/StationMapData.kt b/composeApp/src/commonMain/kotlin/net/adhikary/mrtbuddy/data/model/StationMapData.kt new file mode 100644 index 0000000..b742683 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/net/adhikary/mrtbuddy/data/model/StationMapData.kt @@ -0,0 +1,141 @@ +package net.adhikary.mrtbuddy.data.model + + +data class MarkerInfo( + val name: String, + val lat: Double, + val lon: Double, +) + +object StationMapData { + + val depot = listOf( + Pair(23.875330, 90.368135), + Pair(23.876254, 90.368319), + Pair(23.879849, 90.358013), + Pair(23.877771, 90.355232), + Pair(23.877571, 90.355215), + Pair(23.877571, 90.355215), + Pair(23.877302, 90.355341), + Pair(23.877071, 90.355551), + Pair(23.876948, 90.355846), + Pair(23.875349, 90.368030), + ) + + val routes = listOf( + Pair(23.877568, 90.359824), + Pair(23.876343, 90.367242), + Pair(23.876221, 90.367634), + Pair(23.875732, 90.368262), + Pair(23.875281, 90.368561), + Pair(23.874791, 90.368601), + Pair(23.867912, 90.367341), + Pair(23.867097, 90.367031), + Pair(23.866262, 90.366360), + Pair(23.865747, 90.366091), + Pair(23.860887, 90.365316), + Pair(23.853781, 90.364229), + Pair(23.846382, 90.363100), + Pair(23.844108, 90.363260), + Pair(23.844108, 90.363260), + Pair(23.842757, 90.363355), + Pair(23.840997, 90.363951), + Pair(23.840496, 90.364096), + Pair(23.839953, 90.364144), + Pair(23.839373, 90.364120), + Pair(23.838731, 90.363974), + Pair(23.837406, 90.363566), + Pair(23.829356, 90.363862), + Pair(23.827455, 90.364206), + Pair(23.822896, 90.364334), + Pair(23.819320, 90.365213), + Pair(23.812695, 90.366960), + Pair(23.806717, 90.368657), + Pair(23.798089, 90.372521), + Pair(23.784445, 90.378252), + Pair(23.776793, 90.380532), + Pair(23.770610, 90.382522), + Pair(23.765166, 90.383254), + Pair(23.762875, 90.383375), + Pair(23.761508, 90.383097), + Pair(23.760655, 90.382973), + Pair(23.760376, 90.382952), + Pair(23.760134, 90.383003), + Pair(23.759815, 90.383183), + Pair(23.759508, 90.383430), + Pair(23.759373, 90.383588), + Pair(23.759176, 90.383907), + Pair(23.759049, 90.384197), + Pair(23.758950, 90.384712), + Pair(23.758933, 90.385262), + Pair(23.759080, 90.388260), + Pair(23.759046, 90.388679), + Pair(23.758928, 90.389033), + Pair(23.758756, 90.389398), + Pair(23.758412, 90.389789), + Pair(23.758079, 90.390009), + Pair(23.753836, 90.391678), + Pair(23.753066, 90.391978), + Pair(23.751357, 90.392740), + Pair(23.748858, 90.393705), + Pair(23.744939, 90.395057), + Pair(23.742789, 90.395787), + Pair(23.741207, 90.396119), + Pair(23.740481, 90.396152), + Pair(23.738094, 90.395948), + Pair(23.736130, 90.395626), + Pair(23.734018, 90.395583), + Pair(23.733066, 90.395701), + Pair(23.732712, 90.395851), + Pair(23.732616, 90.395945), + Pair(23.731696, 90.396860), + Pair(23.728572, 90.399891), + Pair(23.728332, 90.400330), + Pair(23.728165, 90.400824), + Pair(23.728111, 90.401232), + Pair(23.728236, 90.403300), + Pair(23.728341, 90.403715), + Pair(23.728452, 90.403957), + Pair(23.728638, 90.404225), + Pair(23.729471, 90.405062), + Pair(23.729726, 90.405365), + Pair(23.729891, 90.405711), + Pair(23.730011, 90.406301), + Pair(23.730009, 90.407481), + Pair(23.730043, 90.408672), + Pair(23.730166, 90.410179), + Pair(23.730149, 90.412231), + Pair(23.730276, 90.414498), + Pair(23.730188, 90.415163), + Pair(23.730080, 90.415463), + Pair(23.727300, 90.420291), + Pair(23.726657, 90.421402), + Pair(23.726672, 90.421815), + Pair(23.727320, 90.422872), + Pair(23.727840, 90.424508), + Pair(23.728135, 90.425087), + Pair(23.728503, 90.425323), + Pair(23.732260, 90.425264), + ) + + val markers = listOf( + MarkerInfo("Dhaka Metro Rail Depot, Diabari", 23.877568, 90.359824), + MarkerInfo("Uttara North", 23.869147, 90.367491), + MarkerInfo("Uttara Center", 23.859583, 90.365067), + MarkerInfo("Uttara South", 23.845789, 90.363076), + MarkerInfo("Pallabi", 23.826163, 90.364206), + MarkerInfo("Mirpur 11", 23.819105, 90.365249), + MarkerInfo("Mirpur 10", 23.808359, 90.368214), + MarkerInfo("Kazipara", 23.799249, 90.371969), + MarkerInfo("Shewrapara", 23.790966, 90.375476), + MarkerInfo("Agargaon", 23.778439, 90.380049), + MarkerInfo("Bijoy Sarani", 23.766569, 90.383082), + MarkerInfo("Farmgate", 23.759056, 90.387059), + MarkerInfo("Karwan Bazar", 23.751312, 90.392715), + MarkerInfo("Shahbagh", 23.739303, 90.395976), + MarkerInfo("Dhaka University", 23.731802, 90.396646), + MarkerInfo("Bangladesh Secretariat", 23.730028, 90.407903), + MarkerInfo("Motijheel", 23.728068, 90.419082), + MarkerInfo("Kamalapur", 23.7319519, 90.4252219), + ) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/net/adhikary/mrtbuddy/di/Module.kt b/composeApp/src/commonMain/kotlin/net/adhikary/mrtbuddy/di/Module.kt index 9a2f761..63aabfd 100644 --- a/composeApp/src/commonMain/kotlin/net/adhikary/mrtbuddy/di/Module.kt +++ b/composeApp/src/commonMain/kotlin/net/adhikary/mrtbuddy/di/Module.kt @@ -12,6 +12,7 @@ import net.adhikary.mrtbuddy.ui.screens.home.MainScreenAction import net.adhikary.mrtbuddy.ui.screens.home.MainScreenState import net.adhikary.mrtbuddy.ui.screens.home.MainScreenViewModel import net.adhikary.mrtbuddy.ui.screens.more.MoreScreenViewModel +import net.adhikary.mrtbuddy.ui.screens.stationmap.StationMapViewModel import net.adhikary.mrtbuddy.ui.screens.transactionlist.TransactionListViewModel import org.koin.core.module.dsl.viewModel import org.koin.dsl.module @@ -70,5 +71,9 @@ val appModule = module { onAction(MainScreenAction.OnInit) } } + + factory { + StationMapViewModel() + } } diff --git a/composeApp/src/commonMain/kotlin/net/adhikary/mrtbuddy/nfc/service/StationService.kt b/composeApp/src/commonMain/kotlin/net/adhikary/mrtbuddy/nfc/service/StationService.kt index 7610213..ca53ca1 100644 --- a/composeApp/src/commonMain/kotlin/net/adhikary/mrtbuddy/nfc/service/StationService.kt +++ b/composeApp/src/commonMain/kotlin/net/adhikary/mrtbuddy/nfc/service/StationService.kt @@ -6,6 +6,7 @@ import mrtbuddy.composeapp.generated.resources.agargaon import mrtbuddy.composeapp.generated.resources.bangladeshSecretariat import mrtbuddy.composeapp.generated.resources.bijoySarani import mrtbuddy.composeapp.generated.resources.dhakaUniversity +import mrtbuddy.composeapp.generated.resources.diabari import mrtbuddy.composeapp.generated.resources.farmgate import mrtbuddy.composeapp.generated.resources.kamalapur import mrtbuddy.composeapp.generated.resources.karwanBazar @@ -66,6 +67,8 @@ object StationService { "Uttara South" -> stringResource(Res.string.uttaraSouth) "Uttara Center" -> stringResource(Res.string.uttaraCenter) "Uttara North" -> stringResource(Res.string.uttaraNorth) + "Diabari" -> stringResource(Res.string.diabari) + "Dhaka Metro Rail Depot, Diabari" -> stringResource(Res.string.diabari) else -> "" // Default to English if no match is found } } diff --git a/composeApp/src/commonMain/kotlin/net/adhikary/mrtbuddy/ui/components/InfoWindow.kt b/composeApp/src/commonMain/kotlin/net/adhikary/mrtbuddy/ui/components/InfoWindow.kt new file mode 100644 index 0000000..cec8953 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/net/adhikary/mrtbuddy/ui/components/InfoWindow.kt @@ -0,0 +1,118 @@ +package net.adhikary.mrtbuddy.ui.components + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import mrtbuddy.composeapp.generated.resources.Res +import mrtbuddy.composeapp.generated.resources.station +import net.adhikary.mrtbuddy.nfc.service.StationService +import org.jetbrains.compose.resources.stringResource + + +@Composable +fun InfoWindow( + title: String, + shouldAnimate: Boolean, + onAnimationDone: () -> Unit +) { + val surfaceContainer = MaterialTheme.colorScheme.surfaceContainer + var animVal by remember { mutableStateOf(if (shouldAnimate) 0f else 1f) } + + LaunchedEffect(true) { + if (shouldAnimate) { + Animatable(0f).animateTo( + targetValue = 1f, + animationSpec = tween(250) + ) { + animVal = value + } + onAnimationDone() + } + } + Box( + modifier = Modifier + .alpha(animVal) + .graphicsLayer { + scaleX = animVal + scaleY = animVal + transformOrigin = TransformOrigin(0.5f, 1f) + } + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.align(Alignment.TopCenter) + ) { + Box( + modifier = Modifier + .shadow( + elevation = 10.dp, + shape = RoundedCornerShape(8.dp), + clip = false + ) + .background( + color = surfaceContainer, + shape = RoundedCornerShape(8.dp) + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = if (title.contains("Diabari", ignoreCase = true)) { + StationService.translate(title) + } else { + "${StationService.translate(title)} ${stringResource(Res.string.station)}" + }, + fontSize = 14.sp, + textAlign = TextAlign.Center, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + + Canvas( + modifier = Modifier + .size(16.dp) + ) { + val path = Path().apply { + moveTo(0f, 0f) + lineTo(size.width, 0f) + lineTo(size.width / 2, size.height) + close() + } + drawPath( + path = path, + color = surfaceContainer, + ) + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/net/adhikary/mrtbuddy/ui/screens/home/MainScreen.kt b/composeApp/src/commonMain/kotlin/net/adhikary/mrtbuddy/ui/screens/home/MainScreen.kt index c65d3ee..b5a4dbc 100644 --- a/composeApp/src/commonMain/kotlin/net/adhikary/mrtbuddy/ui/screens/home/MainScreen.kt +++ b/composeApp/src/commonMain/kotlin/net/adhikary/mrtbuddy/ui/screens/home/MainScreen.kt @@ -34,6 +34,7 @@ import mrtbuddy.composeapp.generated.resources.fare import mrtbuddy.composeapp.generated.resources.historyTab import mrtbuddy.composeapp.generated.resources.more import mrtbuddy.composeapp.generated.resources.openSourceLicenses +import mrtbuddy.composeapp.generated.resources.stationMap import mrtbuddy.composeapp.generated.resources.transactions import net.adhikary.mrtbuddy.ui.components.AppsIcon import net.adhikary.mrtbuddy.ui.components.BalanceCard @@ -44,6 +45,7 @@ import net.adhikary.mrtbuddy.ui.components.TransactionHistoryList import net.adhikary.mrtbuddy.ui.screens.farecalculator.FareCalculatorScreen import net.adhikary.mrtbuddy.ui.screens.history.HistoryScreen import net.adhikary.mrtbuddy.ui.screens.licenses.OpenSourceLicensesScreen +import net.adhikary.mrtbuddy.ui.screens.stationmap.StationMapScreen import net.adhikary.mrtbuddy.ui.screens.transactionlist.TransactionListScreen import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource @@ -55,6 +57,7 @@ enum class Screen(val title: StringResource) { More(title = Res.string.more), History(title = Res.string.historyTab), TransactionList(title = Res.string.transactions), + StationMap(title = Res.string.stationMap), Licenses(title = Res.string.openSourceLicenses) } @@ -76,69 +79,71 @@ fun MainScreen( .fillMaxSize() .background(MaterialTheme.colorScheme.background), bottomBar = { - NavigationBar( - windowInsets = WindowInsets.navigationBars - ) { - NavigationBarItem( - icon = { CalculatorIcon() }, - label = { Text(stringResource(Res.string.fare)) }, - selected = currentScreen == Screen.Calculator, - onClick = { - navController.navigate(Screen.Calculator.name) { - popUpTo(navController.graph.findStartDestination().id) { - saveState = true - inclusive = false + if (currentScreen != Screen.StationMap) { + NavigationBar( + windowInsets = WindowInsets.navigationBars + ) { + NavigationBarItem( + icon = { CalculatorIcon() }, + label = { Text(stringResource(Res.string.fare)) }, + selected = currentScreen == Screen.Calculator, + onClick = { + navController.navigate(Screen.Calculator.name) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + inclusive = false + } + launchSingleTop = true + restoreState = true } - launchSingleTop = true - restoreState = true } - } - ) - NavigationBarItem( - icon = { CardIcon() }, - label = { Text(stringResource(Res.string.balance)) }, - selected = currentScreen == Screen.Home, - onClick = { - navController.navigate(Screen.Home.name) { - popUpTo(navController.graph.findStartDestination().id) { - saveState = true - inclusive = false + ) + NavigationBarItem( + icon = { CardIcon() }, + label = { Text(stringResource(Res.string.balance)) }, + selected = currentScreen == Screen.Home, + onClick = { + navController.navigate(Screen.Home.name) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + inclusive = false + } + launchSingleTop = true + restoreState = true } - launchSingleTop = true - restoreState = true } - } - ) - NavigationBarItem( - icon = { HistoryIcon() }, - label = { Text(stringResource(Res.string.historyTab)) }, - selected = currentScreen == Screen.History || currentScreen == Screen.TransactionList, - onClick = { - navController.navigate(Screen.History.name) { - popUpTo(navController.graph.findStartDestination().id) { - saveState = true - inclusive = false + ) + NavigationBarItem( + icon = { HistoryIcon() }, + label = { Text(stringResource(Res.string.historyTab)) }, + selected = currentScreen == Screen.History || currentScreen == Screen.TransactionList, + onClick = { + navController.navigate(Screen.History.name) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + inclusive = false + } + launchSingleTop = true + restoreState = true } - launchSingleTop = true - restoreState = true } - } - ) - NavigationBarItem( - icon = { AppsIcon() }, - label = { Text(stringResource(Res.string.more)) }, - selected = currentScreen == Screen.More, - onClick = { - navController.navigate(Screen.More.name) { - popUpTo(navController.graph.findStartDestination().id) { - saveState = true - inclusive = false + ) + NavigationBarItem( + icon = { AppsIcon() }, + label = { Text(stringResource(Res.string.more)) }, + selected = currentScreen == Screen.More, + onClick = { + navController.navigate(Screen.More.name) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + inclusive = false + } + launchSingleTop = true + restoreState = true } - launchSingleTop = true - restoreState = true } - } - ) + ) + } } } ) { paddingValues -> @@ -177,6 +182,9 @@ fun MainScreen( composable(route = Screen.More.name) { MoreScreen( + onNavigateToStationMap = { + navController.navigate(Screen.StationMap.name) + }, onNavigateToLicenses = { navController.navigate(Screen.Licenses.name) }, @@ -206,6 +214,14 @@ fun MainScreen( } } + composable(route = Screen.StationMap.name) { + StationMapScreen( + onBack = { + navController.navigateUp() + }, + ) + } + composable(route = Screen.Licenses.name) { OpenSourceLicensesScreen( onBack = { diff --git a/composeApp/src/commonMain/kotlin/net/adhikary/mrtbuddy/ui/screens/more/MoreScreen.kt b/composeApp/src/commonMain/kotlin/net/adhikary/mrtbuddy/ui/screens/more/MoreScreen.kt index 42d039a..d6bb66e 100644 --- a/composeApp/src/commonMain/kotlin/net/adhikary/mrtbuddy/ui/screens/more/MoreScreen.kt +++ b/composeApp/src/commonMain/kotlin/net/adhikary/mrtbuddy/ui/screens/more/MoreScreen.kt @@ -36,10 +36,13 @@ import mrtbuddy.composeapp.generated.resources.language import mrtbuddy.composeapp.generated.resources.license import mrtbuddy.composeapp.generated.resources.nonAffiliationDisclaimer import mrtbuddy.composeapp.generated.resources.openSourceLicenses +import mrtbuddy.composeapp.generated.resources.others import mrtbuddy.composeapp.generated.resources.policy import mrtbuddy.composeapp.generated.resources.privacyPolicy import mrtbuddy.composeapp.generated.resources.readOnlyDisclaimer import mrtbuddy.composeapp.generated.resources.settings +import mrtbuddy.composeapp.generated.resources.stationMap +import mrtbuddy.composeapp.generated.resources.station_map import net.adhikary.mrtbuddy.Language import net.adhikary.mrtbuddy.ui.screens.more.MoreScreenAction import net.adhikary.mrtbuddy.ui.screens.more.MoreScreenEvent @@ -50,6 +53,7 @@ import org.koin.compose.viewmodel.koinViewModel @Composable fun MoreScreen( + onNavigateToStationMap: () -> Unit, onNavigateToLicenses: () -> Unit, modifier: Modifier = Modifier, viewModel: MoreScreenViewModel = koinViewModel() @@ -67,6 +71,9 @@ fun MoreScreen( is MoreScreenEvent.Error -> { // Handle error event (e.g., show a Toast or Snackbar) } + is MoreScreenEvent.NavigateTooStationMap -> { + onNavigateToStationMap() + } is MoreScreenEvent.NavigateToLicenses -> { onNavigateToLicenses() } @@ -83,7 +90,7 @@ fun MoreScreen( verticalArrangement = Arrangement.Top ) { Column { - SectionHeader(text = stringResource(Res.string.settings)) + //SectionHeader(text = stringResource(Res.string.settings)) RoundedButton( text = stringResource(Res.string.autoSaveCardDetails), subtitle = stringResource(Res.string.autoSaveCardDetailsDescription), @@ -116,6 +123,15 @@ fun MoreScreen( } ) + SectionHeader(text = stringResource(Res.string.others)) + RoundedButton( + text = stringResource(Res.string.stationMap), + painter = painterResource(Res.drawable.station_map), + onClick = { + viewModel.onAction(MoreScreenAction.StationMap) + } + ) + SectionHeader(text = stringResource(Res.string.aboutHeader)) RoundedButton( text = stringResource(Res.string.privacyPolicy), diff --git a/composeApp/src/commonMain/kotlin/net/adhikary/mrtbuddy/ui/screens/more/MoreScreenAction.kt b/composeApp/src/commonMain/kotlin/net/adhikary/mrtbuddy/ui/screens/more/MoreScreenAction.kt index 4a7cf85..30074a7 100644 --- a/composeApp/src/commonMain/kotlin/net/adhikary/mrtbuddy/ui/screens/more/MoreScreenAction.kt +++ b/composeApp/src/commonMain/kotlin/net/adhikary/mrtbuddy/ui/screens/more/MoreScreenAction.kt @@ -1,6 +1,7 @@ package net.adhikary.mrtbuddy.ui.screens.more sealed interface MoreScreenAction { + object StationMap : MoreScreenAction object OnInit : MoreScreenAction data class SetAutoSave(val enabled: Boolean) : MoreScreenAction data class SetLanguage(val language: String) : MoreScreenAction diff --git a/composeApp/src/commonMain/kotlin/net/adhikary/mrtbuddy/ui/screens/more/MoreScreenEvent.kt b/composeApp/src/commonMain/kotlin/net/adhikary/mrtbuddy/ui/screens/more/MoreScreenEvent.kt index 540594a..f7fb3c9 100644 --- a/composeApp/src/commonMain/kotlin/net/adhikary/mrtbuddy/ui/screens/more/MoreScreenEvent.kt +++ b/composeApp/src/commonMain/kotlin/net/adhikary/mrtbuddy/ui/screens/more/MoreScreenEvent.kt @@ -2,5 +2,6 @@ package net.adhikary.mrtbuddy.ui.screens.more sealed interface MoreScreenEvent { data class Error(val message: String) : MoreScreenEvent + object NavigateTooStationMap : MoreScreenEvent object NavigateToLicenses : MoreScreenEvent } diff --git a/composeApp/src/commonMain/kotlin/net/adhikary/mrtbuddy/ui/screens/more/MoreScreenViewModel.kt b/composeApp/src/commonMain/kotlin/net/adhikary/mrtbuddy/ui/screens/more/MoreScreenViewModel.kt index 91cc97c..8e5b3f6 100644 --- a/composeApp/src/commonMain/kotlin/net/adhikary/mrtbuddy/ui/screens/more/MoreScreenViewModel.kt +++ b/composeApp/src/commonMain/kotlin/net/adhikary/mrtbuddy/ui/screens/more/MoreScreenViewModel.kt @@ -53,11 +53,13 @@ class MoreScreenViewModel( } } } + is MoreScreenAction.OpenLicenses -> { viewModelScope.launch { _events.send(MoreScreenEvent.NavigateToLicenses) } } + is MoreScreenAction.SetLanguage -> { viewModelScope.launch { try { @@ -69,6 +71,12 @@ class MoreScreenViewModel( } } } + + is MoreScreenAction.StationMap -> { + viewModelScope.launch { + _events.send(MoreScreenEvent.NavigateTooStationMap) + } + } } } } diff --git a/composeApp/src/commonMain/kotlin/net/adhikary/mrtbuddy/ui/screens/stationmap/StationMapScreen.kt b/composeApp/src/commonMain/kotlin/net/adhikary/mrtbuddy/ui/screens/stationmap/StationMapScreen.kt new file mode 100644 index 0000000..be1376c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/net/adhikary/mrtbuddy/ui/screens/stationmap/StationMapScreen.kt @@ -0,0 +1,59 @@ +package net.adhikary.mrtbuddy.ui.screens.stationmap + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.statusBars +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import mrtbuddy.composeapp.generated.resources.Res +import mrtbuddy.composeapp.generated.resources.stationMap +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import ovh.plrapps.mapcompose.ui.MapUI + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StationMapScreen( + modifier: Modifier = Modifier, + onBack: () -> Unit = {}, + viewModel: StationMapViewModel = koinViewModel(), +) { + Column(modifier.fillMaxSize()) { + TopAppBar( + title = { Text(stringResource(Res.string.stationMap)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer + ), + windowInsets = WindowInsets.statusBars + ) + + + LaunchedEffect(Unit) { + + } + + MapUI( + Modifier, + state = viewModel.state + ) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/net/adhikary/mrtbuddy/ui/screens/stationmap/StationMapViewModel.kt b/composeApp/src/commonMain/kotlin/net/adhikary/mrtbuddy/ui/screens/stationmap/StationMapViewModel.kt new file mode 100644 index 0000000..03d76f7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/net/adhikary/mrtbuddy/ui/screens/stationmap/StationMapViewModel.kt @@ -0,0 +1,158 @@ +package net.adhikary.mrtbuddy.ui.screens.stationmap + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import mrtbuddy.composeapp.generated.resources.Res +import mrtbuddy.composeapp.generated.resources.marker +import mrtbuddy.composeapp.generated.resources.marker_train +import net.adhikary.mrtbuddy.data.model.MarkerInfo +import net.adhikary.mrtbuddy.data.model.StationMapData +import net.adhikary.mrtbuddy.makeOsmTileStreamProvider +import net.adhikary.mrtbuddy.ui.components.InfoWindow +import org.jetbrains.compose.resources.painterResource +import ovh.plrapps.mapcompose.api.ExperimentalClusteringApi +import ovh.plrapps.mapcompose.api.addCallout +import ovh.plrapps.mapcompose.api.addLayer +import ovh.plrapps.mapcompose.api.addLazyLoader +import ovh.plrapps.mapcompose.api.addMarker +import ovh.plrapps.mapcompose.api.addPath +import ovh.plrapps.mapcompose.api.centerOnMarker +import ovh.plrapps.mapcompose.api.enableRotation +import ovh.plrapps.mapcompose.api.onMarkerClick +import ovh.plrapps.mapcompose.api.scale +import ovh.plrapps.mapcompose.api.shouldLoopScale +import ovh.plrapps.mapcompose.ui.layout.Forced +import ovh.plrapps.mapcompose.ui.state.MapState +import ovh.plrapps.mapcompose.ui.state.markers.model.RenderingStrategy +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.ln +import kotlin.math.pow +import kotlin.math.tan + +@OptIn(ExperimentalClusteringApi::class) +class StationMapViewModel : ViewModel() { + + private val viewModelScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + private val tileStreamProvider = makeOsmTileStreamProvider() + + private val maxLevel = 16 + private val minLevel = 12 + private val mapSize = mapSizeAtLevel(maxLevel, tileSize = 256) + + val state = MapState(levelCount = maxLevel + 1, mapSize, mapSize, workerCount = 16) { + minimumScaleMode(Forced((1 / 2.0.pow(maxLevel - minLevel)).toFloat())) + + val (centerLat, centerLon) = calculateBoundingBox(StationMapData.markers) + val (scrollX, scrollY) = latLonToMapCoordinates(centerLat, centerLon) + scroll(scrollX, scrollY) + }.apply { + addLayer(tileStreamProvider) + shouldLoopScale = true + enableRotation() + scale = 0.2f + + onMarkerClick { id, x, y -> + var shouldAnimate by mutableStateOf(true) + addCallout( + id, x, y, + absoluteOffset = DpOffset(0.dp, (-20).dp), + ) { + InfoWindow(title = id, shouldAnimate) { + shouldAnimate = false + } + } + + viewModelScope.launch { + centerOnMarker(id) + } + } + } + + init { + state.addLazyLoader("default") + + StationMapData.markers.mapIndexed { index, station -> + val (x, y) = latLonToMapCoordinates(station.lat, station.lon) + + state.addMarker( + station.name, x, y, + renderingStrategy = RenderingStrategy.LazyLoading(lazyLoaderId = "default") + ) { + Image( + painter = painterResource( + if (index == 0) Res.drawable.marker_train else Res.drawable.marker + ), + contentDescription = null, + modifier = Modifier.size(30.dp), + ) + } + } + + state.addPath( + id = "route", + color = Color(0xFF0E9D58), + ) { + for (line in StationMapData.routes) { + val (x, y) = latLonToMapCoordinates(line.first, line.second) + addPoint(x, y) + } + } + + state.addPath( + id = "polygon", + color = Color(0xFF0E9D58), + fillColor = Color(0xFF0E9D58).copy(alpha = .6f), + ) { + for (polygon in StationMapData.depot) { + val (x, y) = latLonToMapCoordinates(polygon.first, polygon.second) + addPoint(x, y) + } + } + } + + private fun calculateBoundingBox(stations: List): Pair { + val minLat = stations.minOf { it.lat } + val maxLat = stations.maxOf { it.lat } + val minLon = stations.minOf { it.lon } + val maxLon = stations.maxOf { it.lon } + + val centerLat = (minLat + maxLat) / 2 + val centerLon = (minLon + maxLon) / 2 + + return (centerLat to centerLon) + } + + // Converts latitude and longitude to map coordinates at a specific zoom level + private fun latLonToMapCoordinates(lat: Double, lon: Double): Pair { + val x = (lon + 180.0) / 360.0 + val y = 0.5 - ln(tan(PI * lat / 180.0) + 1 / cos(PI * lat / 180.0)) / (2 * PI) + + val pixelX = x * mapSize + val pixelY = y * mapSize + + // Normalize to [0, 1] + return pixelX / mapSize to pixelY / mapSize + } + + + /** + * wmts level are 0 based. + * At level 0, the map corresponds to just one tile. + */ + private fun mapSizeAtLevel(wmtsLevel: Int, tileSize: Int): Int { + return tileSize * 2.0.pow(wmtsLevel).toInt() + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/net/adhikary/mrtbuddy/utils/KtorClient.kt b/composeApp/src/commonMain/kotlin/net/adhikary/mrtbuddy/utils/KtorClient.kt new file mode 100644 index 0000000..525e067 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/net/adhikary/mrtbuddy/utils/KtorClient.kt @@ -0,0 +1,34 @@ +package net.adhikary.mrtbuddy.utils + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.prepareGet +import io.ktor.utils.io.ByteReadChannel +import io.ktor.utils.io.core.isEmpty +import io.ktor.utils.io.core.readBytes +import io.ktor.utils.io.readRemaining +import kotlinx.io.Buffer +import kotlinx.io.RawSource + + +expect fun getKtorClient(): HttpClient + +/** + * Utility method to get a [RawSource] from a Ktor HTTP client, using a GET method. + */ +suspend fun getStream(client: HttpClient, path: String): RawSource { + val buffer = Buffer() + client.prepareGet(path).execute { httpResponse -> + val channel: ByteReadChannel = httpResponse.body() + while (!channel.isClosedForRead) { + val packet = channel.readRemaining(DEFAULT_BUFFER_SIZE.toLong()) + while (!packet.isEmpty) { + val bytes = packet.readBytes() + buffer.write(bytes, 0, bytes.size) + } + } + } + return buffer +} + +private const val DEFAULT_BUFFER_SIZE = 8192 \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/net/adhikary/mrtbuddy/Osm.kt b/composeApp/src/iosMain/kotlin/net/adhikary/mrtbuddy/Osm.kt new file mode 100644 index 0000000..5b582a6 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/net/adhikary/mrtbuddy/Osm.kt @@ -0,0 +1,18 @@ +package net.adhikary.mrtbuddy + +import net.adhikary.mrtbuddy.utils.getKtorClient +import net.adhikary.mrtbuddy.utils.getStream +import ovh.plrapps.mapcompose.core.TileStreamProvider + +actual fun makeOsmTileStreamProvider(): TileStreamProvider { + // so there is no need for an asynchronous http client such as Ktor. + val httpClient = getKtorClient() + return TileStreamProvider { row, col, zoomLvl -> + try { + getStream(httpClient, "https://tile.openstreetmap.org/$zoomLvl/$col/$row.png") + } catch (e: Exception) { + e.printStackTrace() + null + } + } +} \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/net/adhikary/mrtbuddy/utils/KtorClient.ios.kt b/composeApp/src/iosMain/kotlin/net/adhikary/mrtbuddy/utils/KtorClient.ios.kt new file mode 100644 index 0000000..60942f8 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/net/adhikary/mrtbuddy/utils/KtorClient.ios.kt @@ -0,0 +1,14 @@ +package net.adhikary.mrtbuddy.utils + +import io.ktor.client.HttpClient +import io.ktor.client.engine.darwin.Darwin + +actual fun getKtorClient(): HttpClient { + return HttpClient(Darwin) { + engine { + configureRequest { + setAllowsCellularAccess(true) + } + } + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 02e7e93..3e502c7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "8.7.2" +agp = "8.6.1" android-compileSdk = "34" android-minSdk = "24" android-targetSdk = "34" @@ -28,6 +28,8 @@ multiplatformSettingsVersion = "1.2.0" navigationCompose = "2.8.0-alpha10" sqlite = "2.5.0-SNAPSHOT" napier = "2.7.1" +mapcomposeMp = "0.9.3" +ktor = "3.0.1" [libraries] compose-webview-multiplatform = { module = "io.github.kevinnzou:compose-webview-multiplatform", version.ref = "composeWebviewMultiplatform" } @@ -58,6 +60,10 @@ multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", vers navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigationCompose" } sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" } napier = { module = "io.github.aakira:napier", version.ref = "napier" } +mapcompose-mp = { module = "ovh.plrapps:mapcompose-mp", version.ref = "mapcomposeMp" } +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } +ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" }