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 e81bce5744ed9b3155e17a58a1d665015bc70b52
parent bc7e437b1abc0f3e4fb6be197e0721a68b3df2a6
Author: MTRNord <MTRNord@users.noreply.github.com>
Date:   Tue, 29 Jul 2025 17:23:50 +0200

Improve the map and calendar naming

Diffstat:
Mapp/src/main/java/space/midnightthoughts/nordiccalendar/screens/CalendarScreen.kt | 3++-
Mapp/src/main/java/space/midnightthoughts/nordiccalendar/screens/EventDetailsView.kt | 137++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Mapp/src/main/java/space/midnightthoughts/nordiccalendar/util/CalendarData.kt | 12++++++++----
Mapp/src/main/java/space/midnightthoughts/nordiccalendar/viewmodels/CalendarViewModel.kt | 2+-
Mapp/src/main/java/space/midnightthoughts/nordiccalendar/viewmodels/EventDetailsViewModel.kt | 101++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mgradle.properties | 9++++++---
6 files changed, 190 insertions(+), 74 deletions(-)

diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/screens/CalendarScreen.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/screens/CalendarScreen.kt @@ -531,7 +531,8 @@ fun EventCardPreview() { accountName = "Beispielkonto", accountType = "com.example", syncEvents = true, - visible = true + visible = true, + displayName = "Beispielkalender", ) ) EventCard( 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,8 +1,7 @@ package space.midnightthoughts.nordiccalendar.screens -import android.location.Geocoder -import android.util.Log import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -16,6 +15,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material3.DividerDefaults @@ -26,13 +26,13 @@ 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.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight @@ -42,8 +42,10 @@ 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.rememberCameraState import dev.sargunv.maplibrecompose.compose.source.rememberGeoJsonSource import dev.sargunv.maplibrecompose.core.BaseStyle +import dev.sargunv.maplibrecompose.core.CameraPosition import dev.sargunv.maplibrecompose.core.source.GeoJsonData import io.github.dellisd.spatialk.geojson.Feature import io.github.dellisd.spatialk.geojson.Point @@ -52,12 +54,12 @@ import sh.calvin.autolinktext.rememberAutoLinkText import space.midnightthoughts.nordiccalendar.R import space.midnightthoughts.nordiccalendar.components.AppScaffold import space.midnightthoughts.nordiccalendar.getCurrentAppLocale +import space.midnightthoughts.nordiccalendar.viewmodels.BoundingBox import space.midnightthoughts.nordiccalendar.viewmodels.EventDetailsViewModel import java.time.ZoneId import java.time.format.DateTimeFormatter import java.util.Calendar import java.util.Date -import java.util.Locale @Composable fun EventDetailsView( @@ -93,51 +95,16 @@ 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 - } - } + val boundingBox = remember(viewModel) { + viewModel.locationBoundingBox + }.collectAsState() - 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 + val locationText = event.value?.location?.trim() + val locationPosition by viewModel.locationPosition.collectAsState() + val appLocaleString = appLocale.language + LaunchedEffect(locationText, appLocaleString) { + if (!locationText.isNullOrEmpty()) { + viewModel.resolveLocation(locationText, appLocaleString) } } @@ -208,12 +175,13 @@ fun EventDetailsView( fontWeight = FontWeight.Bold ) Spacer(modifier = Modifier.width(48.dp)) - Row { + Row( + verticalAlignment = Alignment.CenterVertically + ) { if (event.value?.calendar?.color != null) { // Round color dot Box( modifier = Modifier - .padding(top = 4.dp) .size(16.dp) .background( color = if (event.value?.calendar?.color != null) { @@ -227,7 +195,7 @@ fun EventDetailsView( Spacer(modifier = Modifier.width(8.dp)) } Text( - text = event.value?.calendar?.name ?: "", + text = event.value?.calendar?.displayName ?: "", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, ) @@ -269,12 +237,7 @@ fun EventDetailsView( ) Spacer(modifier = Modifier.height(16.dp)) if (!locationText.isNullOrEmpty()) { - if (geocodeResult.first && geocodeResult.second != null) { - val address = geocodeResult.second!! - val position = Position( - address.longitude, - address.latitude - ) + if (locationPosition != null) { Column { Text( text = stringResource(R.string.location), @@ -282,7 +245,10 @@ fun EventDetailsView( fontWeight = FontWeight.Bold ) Spacer(modifier = Modifier.height(8.dp)) - LocationMap(coordinate = position) + LocationMap( + coordinate = locationPosition!!, + boundingBox = boundingBox.value + ) } } else { Row( @@ -379,14 +345,57 @@ fun EventDetailsView( } @Composable -fun LocationMap(coordinate: Position) { - MaplibreMap( +fun LocationMap(coordinate: Position, boundingBox: BoundingBox?) { + val shape = RoundedCornerShape(16.dp) + val uriHandler = LocalUriHandler.current + val camera = if (boundingBox != null) { + val centerLat = (boundingBox.north + boundingBox.south) / 2.0 + val centerLon = (boundingBox.east + boundingBox.west) / 2.0 + val center = Position(centerLon, centerLat) + val latDiff = boundingBox.north - boundingBox.south + val lonDiff = boundingBox.east - boundingBox.west + val zoom = when { + latDiff < 0.002 && lonDiff < 0.002 -> 17.0 + latDiff < 0.01 && lonDiff < 0.01 -> 15.0 + latDiff < 0.05 && lonDiff < 0.05 -> 14.0 + else -> 13.0 + } + rememberCameraState( + firstPosition = CameraPosition( + target = center, + zoom = zoom + ) + ) + } else { + rememberCameraState( + firstPosition = CameraPosition( + target = coordinate, + zoom = 13.0 + ) + ) + } + Box( modifier = Modifier .fillMaxWidth() - .height(200.dp), - baseStyle = BaseStyle.Uri("https://tiles.openfreemap.org/styles/liberty") + .height(200.dp) + .clip(shape) + .border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape) + .background(MaterialTheme.colorScheme.surfaceVariant) ) { - MapContent(coordinate = coordinate) + MaplibreMap( + modifier = Modifier.matchParentSize(), + baseStyle = BaseStyle.Uri("https://tiles.openfreemap.org/styles/liberty"), + cameraState = camera, + onMapClick = { pos, _ -> + // Open preferred routing app + val uri = + "geo:${coordinate.latitude},${coordinate.longitude}?q=${coordinate.latitude},${coordinate.longitude}" + uriHandler.openUri(uri) + dev.sargunv.maplibrecompose.compose.ClickResult.Consume + }, + ) { + MapContent(coordinate = coordinate) + } } } diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/util/CalendarData.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/util/CalendarData.kt @@ -9,6 +9,7 @@ import android.util.Log data class Calendar( val id: Long, val name: String, + val displayName: String, val color: Long, val accountName: String, val accountType: String, @@ -81,12 +82,13 @@ class CalendarData { calendars.add( Calendar( id = calId, - name = name ?: displayName, + name = displayName ?: name, color = color, accountName = accountName ?: "", accountType = accountType ?: "", syncEvents = syncEvents == 1, - visible = visible == 1 + visible = visible == 1, + displayName = displayName ?: "No Display Name", ) ) } @@ -183,7 +185,8 @@ class CalendarData { accountName = "Unknown Account", accountType = "Unknown Type", syncEvents = false, - visible = false + visible = false, + displayName = "Unknown Calendar" // Fallback if calendar not found ) // Fallback if calendar not found ) ) @@ -297,7 +300,8 @@ class CalendarData { accountName = "Unknown Account", accountType = "Unknown Type", syncEvents = false, - visible = false + visible = false, + displayName = "Unknown Calendar" // Fallback if calendar not found ) // Fallback if calendar not found ) } else null diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/viewmodels/CalendarViewModel.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/viewmodels/CalendarViewModel.kt @@ -15,7 +15,7 @@ import javax.inject.Inject @HiltViewModel class CalendarViewModel @Inject constructor( - @ApplicationContext private val context: Context, + @param:ApplicationContext private val context: Context, private val repository: CalendarRepository ) : ViewModel() { val events = repository.eventsFlow.stateIn( diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/viewmodels/EventDetailsViewModel.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/viewmodels/EventDetailsViewModel.kt @@ -1,28 +1,85 @@ package space.midnightthoughts.nordiccalendar.viewmodels import android.content.Context +import android.util.Log import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import io.github.dellisd.spatialk.geojson.Position +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.android.Android +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.logging.LogLevel +import io.ktor.client.plugins.logging.Logger +import io.ktor.client.plugins.logging.Logging +import io.ktor.client.request.get +import io.ktor.serialization.kotlinx.json.json import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json import space.midnightthoughts.nordiccalendar.data.CalendarRepository import space.midnightthoughts.nordiccalendar.util.Event import javax.inject.Inject +@Serializable +data class NominatimResult( + val lat: String, + val lon: String, + @SerialName("display_name") val displayName: String? = null, + @SerialName("boundingbox") val boundingBox: List<String>? = null +) + +data class BoundingBox(val south: Double, val north: Double, val west: Double, val east: Double) + @HiltViewModel class EventDetailsViewModel @Inject constructor( - @ApplicationContext private val context: Context, + @param:ApplicationContext private val context: Context, private val repository: CalendarRepository, savedStateHandle: SavedStateHandle ) : ViewModel() { private val _event = MutableStateFlow<Event?>(null) val event: StateFlow<Event?> = _event.asStateFlow() + private val _locationPosition = MutableStateFlow<Position?>(null) + val locationPosition: StateFlow<Position?> = _locationPosition.asStateFlow() + + private val _locationBoundingBox = MutableStateFlow<BoundingBox?>(null) + val locationBoundingBox: StateFlow<BoundingBox?> = _locationBoundingBox.asStateFlow() + + private val ktorClient = HttpClient(Android) { + install(ContentNegotiation) { + json( + Json { + ignoreUnknownKeys = true + isLenient = true + prettyPrint = true + explicitNulls = false + } + ) + } + install(HttpTimeout) { + requestTimeoutMillis = 15000L + connectTimeoutMillis = 10000L + socketTimeoutMillis = 15000L + } + install(Logging) { + logger = object : Logger { + override fun log(message: String) { + android.util.Log.d("Ktor", message) + } + } + level = LogLevel.INFO + } + } + init { val eventId = savedStateHandle.get<String>("eventId")?.toLongOrNull() if (eventId != null) { @@ -36,4 +93,46 @@ class EventDetailsViewModel @Inject constructor( _event.value = event } } + + fun resolveLocation(address: String, locale: String = "de") { + viewModelScope.launch { + try { + val url = "https://nominatim.openstreetmap.org/search" + val response: List<NominatimResult> = ktorClient.get(url) { + url { + parameters.append("q", address) + parameters.append("format", "json") + parameters.append("addressdetails", "1") + parameters.append("limit", "1") + parameters.append("accept-language", locale) + } + headers.append("User-Agent", "nordic-calendar-app") + }.body() + if (response.isNotEmpty()) { + val pos = Position(response[0].lon.toDouble(), response[0].lat.toDouble()) + _locationPosition.value = pos + val bbox = response[0].boundingBox + if (bbox != null && bbox.size == 4) { + // boundingbox: [south, north, west, east] as strings + _locationBoundingBox.value = BoundingBox( + south = bbox[0].toDouble(), + north = bbox[1].toDouble(), + west = bbox[2].toDouble(), + east = bbox[3].toDouble() + ) + } else { + _locationBoundingBox.value = null + } + } else { + _locationPosition.value = null + _locationBoundingBox.value = null + Log.w("EventDetailsViewModel", "No location found for address: $address") + } + } catch (e: Exception) { + _locationPosition.value = null + _locationBoundingBox.value = null + Log.e("EventDetailsViewModel", "Error resolving location: ${e.message}") + } + } + } } diff --git a/gradle.properties b/gradle.properties @@ -6,7 +6,7 @@ # http://www.gradle.org/docs/current/userguide/build_environment.html # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. -org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +org.gradle.jvmargs=-Xmx6g -Dfile.encoding=UTF-8 -XX:MaxMetaspaceSize=1g # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. For more details, visit # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects @@ -20,4 +20,7 @@ kotlin.code.style=official # Enables namespacing of each library's R class so that its R class includes only the # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library -android.nonTransitiveRClass=true -\ No newline at end of file +android.nonTransitiveRClass=true +org.gradle.configuration-cache=true +# Use this flag carefully, in case some of the plugins are not fully compatible. +org.gradle.configuration-cache.problems=warn +\ No newline at end of file