nordiccalendar

A simple Material 3 native calendar app fitting my personal requirements
git clone git://archive.git.mtrnord.blog/MTRNord/nordiccalendar.git
Log | Files | Refs | README | LICENSE

commit bc7e437b1abc0f3e4fb6be197e0721a68b3df2a6
parent debed0496a59a9ba77c89b1d795ae8d27a870449
Author: MTRNord <MTRNord@users.noreply.github.com>
Date:   Tue, 29 Jul 2025 16:35:43 +0200

Initial map setup

Diffstat:
Mapp/build.gradle.kts | 7+++++++
Mapp/src/main/AndroidManifest.xml | 1+
Mapp/src/main/java/space/midnightthoughts/nordiccalendar/screens/EventDetailsView.kt | 184+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mbuild.gradle.kts | 1+
Mgradle/libs.versions.toml | 9+++++++++
5 files changed, 174 insertions(+), 28 deletions(-)

diff --git a/app/build.gradle.kts b/app/build.gradle.kts @@ -4,6 +4,7 @@ plugins { alias(libs.plugins.kotlin.compose) alias(libs.plugins.ksp) alias(libs.plugins.hilt.android) + alias(libs.plugins.kotlin.serialization) } android { @@ -70,6 +71,12 @@ dependencies { implementation(libs.hilt.android) ksp(libs.hilt.android.compiler) implementation(libs.hilt.navigation.compose) + implementation(libs.maplibre.composeMaterial3) + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.serialization.kotlinx.json) + implementation(libs.ktor.client.logging) + implementation(libs.ktor.client.android) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ <uses-permission android:name="android.permission.READ_CALENDAR" /> <uses-permission android:name="android.permission.WRITE_CALENDAR" /> + <uses-permission android:name="android.permission.INTERNET" /> <application android:name=".NordicCalendarApp" diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/screens/EventDetailsView.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/screens/EventDetailsView.kt @@ -1,5 +1,7 @@ package space.midnightthoughts.nordiccalendar.screens +import android.location.Geocoder +import android.util.Log import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -21,8 +23,12 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +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.graphics.Color @@ -34,6 +40,14 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavBackStackEntry import androidx.navigation.NavHostController +import dev.sargunv.maplibrecompose.compose.MaplibreMap +import dev.sargunv.maplibrecompose.compose.layer.SymbolLayer +import dev.sargunv.maplibrecompose.compose.source.rememberGeoJsonSource +import dev.sargunv.maplibrecompose.core.BaseStyle +import dev.sargunv.maplibrecompose.core.source.GeoJsonData +import io.github.dellisd.spatialk.geojson.Feature +import io.github.dellisd.spatialk.geojson.Point +import io.github.dellisd.spatialk.geojson.Position import sh.calvin.autolinktext.rememberAutoLinkText import space.midnightthoughts.nordiccalendar.R import space.midnightthoughts.nordiccalendar.components.AppScaffold @@ -43,6 +57,7 @@ import java.time.ZoneId import java.time.format.DateTimeFormatter import java.util.Calendar import java.util.Date +import java.util.Locale @Composable fun EventDetailsView( @@ -78,6 +93,54 @@ fun EventDetailsView( val scrollState = rememberScrollState() + val locationText = event.value?.location?.trim() + var geocodeResult by remember(locationText) { + mutableStateOf<Pair<Boolean, android.location.Address?>>( + false to null + ) + } + val context = LocalContext.current + val geocoderAvailable = remember { Geocoder.isPresent() } + Log.d("EventDetailsView", "Geocoder available: $geocoderAvailable") + LaunchedEffect(locationText, geocoderAvailable) { + if (!locationText.isNullOrEmpty() && geocoderAvailable) { + try { + val geocoder = Geocoder(context, Locale.getDefault()) + if (android.os.Build.VERSION.SDK_INT >= 33) { + geocoder.getFromLocationName( + locationText, + 1, + object : Geocoder.GeocodeListener { + override fun onGeocode(addresses: List<android.location.Address>) { + geocodeResult = if (addresses.isNotEmpty()) { + true to addresses[0] + } else { + false to null + } + } + + override fun onError(errorMessage: String?) { + geocodeResult = false to null + } + } + ) + } else { + @Suppress("DEPRECATION") + val results = geocoder.getFromLocationName(locationText, 1) + geocodeResult = if (!results.isNullOrEmpty()) { + true to results[0] + } else { + false to null + } + } + } catch (_: Exception) { + geocodeResult = false to null + } + } else { + geocodeResult = false to null + } + } + AppScaffold( title = event.value?.title ?: stringResource( @@ -145,11 +208,7 @@ fun EventDetailsView( fontWeight = FontWeight.Bold ) Spacer(modifier = Modifier.width(48.dp)) - Row( - modifier = Modifier.fillMaxSize(), - verticalAlignment = Alignment.Top, - horizontalArrangement = Arrangement.Start - ) { + Row { if (event.value?.calendar?.color != null) { // Round color dot Box( @@ -209,27 +268,66 @@ fun EventDetailsView( color = MaterialTheme.colorScheme.outlineVariant ) Spacer(modifier = Modifier.height(16.dp)) - // TODO: map if location is a valid address - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.Top, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = stringResource(R.string.location), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold - ) - Spacer(modifier = Modifier.width(24.dp)) - Text( - text = if (event.value?.location?.trim().isNullOrEmpty()) { - stringResource(R.string.no_location_available) - } else { - event.value?.location ?: "" - }, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - ) + if (!locationText.isNullOrEmpty()) { + if (geocodeResult.first && geocodeResult.second != null) { + val address = geocodeResult.second!! + val position = Position( + address.longitude, + address.latitude + ) + Column { + Text( + text = stringResource(R.string.location), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(8.dp)) + LocationMap(coordinate = position) + } + } else { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.location), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.width(24.dp)) + Text( + text = AnnotatedString.rememberAutoLinkText( + if (event.value?.location?.trim().isNullOrEmpty()) { + stringResource(R.string.no_location_available) + } else { + event.value?.location ?: "" + } + ), + + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } else { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.location), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.width(24.dp)) + Text( + text = stringResource(R.string.no_location_available), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } } // TODO: Invitees and attendees and alerts @@ -278,4 +376,35 @@ fun EventDetailsView( } } } -} -\ No newline at end of file +} + +@Composable +fun LocationMap(coordinate: Position) { + MaplibreMap( + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + baseStyle = BaseStyle.Uri("https://tiles.openfreemap.org/styles/liberty") + ) { + MapContent(coordinate = coordinate) + } +} + +@Composable +fun MapContent( + coordinate: Position, +) { + val marker = rememberGeoJsonSource( + GeoJsonData.Features( + Feature(Point(coordinates = coordinate)) + ) + ) + + SymbolLayer( + id = "marker", + source = marker, + //iconImage = "marker_icon", // Ensure you have a marker icon in your resources + //iconSize = 1.0f, + ) + +} diff --git a/build.gradle.kts b/build.gradle.kts @@ -5,4 +5,5 @@ plugins { alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.hilt.android) apply false alias(libs.plugins.ksp) apply false + alias(libs.plugins.kotlin.serialization) apply false } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml @@ -15,6 +15,8 @@ autolinktext = "2.0.2" hilt = "2.57" ksp = "2.2.0-2.0.2" hiltNavigationCompose = "1.2.0" +maplibreCompose = "0.10.4" +ktorVersion = "3.2.2" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -38,6 +40,12 @@ autolinktext = { module = "sh.calvin.autolinktext:autolinktext", version.ref = " hilt-android-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" } +maplibre-composeMaterial3 = { module = "dev.sargunv.maplibre-compose:maplibre-compose-material3", version.ref = "maplibreCompose" } +ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktorVersion" } +ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktorVersion" } +ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktorVersion" } +ktor-client-logging = { group = "io.ktor", name = "ktor-client-logging", version.ref = "ktorVersion" } +ktor-client-android = { group = "io.ktor", name = "ktor-client-android", version.ref = "ktorVersion" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } @@ -45,3 +53,4 @@ kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }