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 debed0496a59a9ba77c89b1d795ae8d27a870449
parent 80302eb67ec2ae535f377c4a1916f5f401708970
Author: MTRNord <MTRNord@users.noreply.github.com>
Date:   Tue, 29 Jul 2025 15:44:57 +0200

Improve and translate the EventDetailsView

Diffstat:
Mapp/src/main/java/space/midnightthoughts/nordiccalendar/NordicCalendarApp.kt | 15+++++++++++++++
Mapp/src/main/java/space/midnightthoughts/nordiccalendar/screens/CalendarScreen.kt | 106++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Mapp/src/main/java/space/midnightthoughts/nordiccalendar/screens/EventDetailsView.kt | 212+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mapp/src/main/java/space/midnightthoughts/nordiccalendar/util/CalendarData.kt | 33+++++++++++++++++++++++++++------
Mapp/src/main/res/values-en/strings.xml | 12++++++++++++
Mapp/src/main/res/values/strings.xml | 12++++++++++++
6 files changed, 330 insertions(+), 60 deletions(-)

diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/NordicCalendarApp.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/NordicCalendarApp.kt @@ -1,7 +1,22 @@ package space.midnightthoughts.nordiccalendar import android.app.Application +import android.app.LocaleManager +import android.content.Context +import android.os.Build +import android.os.LocaleList import dagger.hilt.android.HiltAndroidApp +import java.util.Locale @HiltAndroidApp class NordicCalendarApp : Application() + +fun getCurrentAppLocale(context: Context): Locale { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val localeManager = context.getSystemService(LocaleManager::class.java) + val appLocales: LocaleList = localeManager.applicationLocales + if (!appLocales.isEmpty) appLocales[0] else Locale.getDefault() + } else { + Locale.getDefault() + } +} diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/screens/CalendarScreen.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/screens/CalendarScreen.kt @@ -3,8 +3,11 @@ package space.midnightthoughts.nordiccalendar.screens import android.annotation.SuppressLint import android.util.Log import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -47,6 +50,9 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow @@ -58,12 +64,13 @@ import androidx.navigation.NavController import kotlinx.coroutines.delay import kotlinx.coroutines.launch import space.midnightthoughts.nordiccalendar.R +import space.midnightthoughts.nordiccalendar.getCurrentAppLocale import space.midnightthoughts.nordiccalendar.util.Event import space.midnightthoughts.nordiccalendar.viewmodels.CalendarViewModel -import java.text.SimpleDateFormat +import java.time.ZoneId +import java.time.format.DateTimeFormatter import java.util.Calendar import java.util.Date -import java.util.Locale @SuppressLint("UnusedBoxWithConstraintsScope") @@ -180,7 +187,8 @@ fun DayView( set(Calendar.SECOND, 0) set(Calendar.MILLISECOND, 0) }.timeInMillis - val hourFormat = SimpleDateFormat("HH:mm", Locale.getDefault()) + val appLocale = getCurrentAppLocale(LocalContext.current) + val hourFormat = DateTimeFormatter.ofPattern("HH:mm", appLocale) val scrollState = rememberScrollState() val coroutineScope = rememberCoroutineScope() var now by remember { mutableLongStateOf(System.currentTimeMillis()) } @@ -217,7 +225,8 @@ fun DayView( .padding(horizontal = 8.dp, vertical = 4.dp) ) { Text( - hourFormat.format(Date(dayStart + hour * 60 * 60 * 1000)), + Date(dayStart + hour * 60 * 60 * 1000).toInstant() + .atZone(ZoneId.systemDefault()).toLocalDateTime().format(hourFormat), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(timeColumnWidth) ) @@ -260,7 +269,6 @@ fun DayView( EventCard( event = event, isCompact = isCompact, - hourFormat = hourFormat, onClick = { navController.navigate("eventDetails/${event.eventId}") }, modifier = Modifier .width(with(density) { columnWidthPx.toDp() }) @@ -278,13 +286,23 @@ fun DayView( .offset { IntOffset(0, nowOffsetY.toInt()) }, verticalAlignment = Alignment.CenterVertically ) { - Text( - stringResource(R.string.now), - color = lineColor, - style = MaterialTheme.typography.labelSmall, - modifier = Modifier.padding(start = 16.dp, end = 24.dp) - ) - androidx.compose.foundation.Canvas( + Box( + modifier = Modifier + .padding(start = 4.dp) + .background( + color = lineColor, + shape = MaterialTheme.shapes.small + ) + .padding(horizontal = 12.dp, vertical = 4.dp) + ) { + Text( + Date(now).toInstant() + .atZone(ZoneId.systemDefault()).toLocalDateTime().format(hourFormat), + color = MaterialTheme.colorScheme.onError, + style = MaterialTheme.typography.labelSmall + ) + } + Canvas( modifier = Modifier .fillMaxWidth() .height(lineThickness) @@ -292,7 +310,7 @@ fun DayView( ) { drawLine( color = lineColor, - start = androidx.compose.ui.geometry.Offset( + start = Offset( 0f, size.height / 2 ), @@ -301,7 +319,7 @@ fun DayView( size.height / 2 ), strokeWidth = size.height, - cap = androidx.compose.ui.graphics.StrokeCap.Round + cap = StrokeCap.Round ) } } @@ -341,19 +359,28 @@ fun DateRangeHeader( val endMillis = remember(calendarViewModel) { calendarViewModel.endMillis }.collectAsState(initial = System.currentTimeMillis()) - val dateFormat = when (selectedTab) { - 0, 1 -> SimpleDateFormat("dd.MM.yyyy", Locale.getDefault()) - 2 -> SimpleDateFormat("dd.MM.yyyy", Locale.getDefault()) - else -> SimpleDateFormat("dd.MM.yyyy", Locale.getDefault()) + val appLocale = getCurrentAppLocale(LocalContext.current) + val dateFormat = DateTimeFormatter.ofPattern("dd.MM.yyyy", appLocale) + + val startDate = remember(startMillis.value) { + Date(startMillis.value) + } + val endDate = remember(endMillis.value) { + Date(endMillis.value) } + val rangeText = when (selectedTab) { - 0, 1 -> dateFormat.format(Date(startMillis.value)) + " – " + dateFormat.format( - Date( - endMillis.value - ) - ) + 0, 1 -> "${ + startDate.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime() + .format(dateFormat) + } – ${ + endDate.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime().format(dateFormat) + }" + + 2 -> startDate.toInstant() + .atZone(ZoneId.systemDefault()).toLocalDate() + .format(dateFormat) - 2 -> dateFormat.format(Date(startMillis.value)) else -> "" } val isToday = when (selectedTab) { @@ -419,10 +446,18 @@ fun DateRangeHeader( fun EventCard( event: Event, isCompact: Boolean, - hourFormat: SimpleDateFormat, modifier: Modifier = Modifier, onClick: (() -> Unit)? = null ) { + val appLocale = getCurrentAppLocale(LocalContext.current) + val hourFormat = DateTimeFormatter.ofPattern("HH:mm", appLocale) + val startDate = remember(event.startTime) { + Date(event.startTime) + } + val endDate = remember(event.endTime) { + Date(event.endTime) + } + Card( modifier = modifier .defaultMinSize(minHeight = 24.dp) @@ -451,7 +486,13 @@ fun EventCard( if (!isCompact) { Spacer(modifier = Modifier.height(4.dp)) Text( - hourFormat.format(Date(event.startTime)) + " - " + hourFormat.format(Date(event.endTime)), + "${ + startDate.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime() + .format(hourFormat) + } - ${ + endDate.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime() + .format(hourFormat) + }", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary ) @@ -481,12 +522,21 @@ fun EventCardPreview() { calendarId = 1L, location = "Beispielort", allDay = false, - organizer = "Beispielorganisator" + organizer = "Beispielorganisator", + calendar = space.midnightthoughts.nordiccalendar.util.Calendar( + id = 1L, + name = "Beispielkalender", + color = 0xFF6200EE, // Beispiel-Farbe + selected = true, + accountName = "Beispielkonto", + accountType = "com.example", + syncEvents = true, + visible = true + ) ) EventCard( event = sampleEvent, isCompact = false, - hourFormat = SimpleDateFormat("HH:mm", Locale.getDefault()) ) } 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,11 +1,19 @@ package space.midnightthoughts.nordiccalendar.screens +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +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.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material3.DividerDefaults @@ -15,9 +23,13 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavBackStackEntry @@ -25,9 +37,12 @@ import androidx.navigation.NavHostController 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.EventDetailsViewModel -import java.text.SimpleDateFormat -import java.util.Locale +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Calendar +import java.util.Date @Composable fun EventDetailsView( @@ -38,7 +53,28 @@ fun EventDetailsView( val event = remember(viewModel) { viewModel.event }.collectAsState(initial = null) - val dateTimeFormat = SimpleDateFormat("dd.MM.yyyy HH:mm", Locale.getDefault()) + val startDate = remember(event.value?.startTime) { + Date(event.value?.startTime ?: 0L) + } + val endDate = remember(event.value?.endTime) { + Date(event.value?.endTime ?: 0L) + } + val startCalendar = remember(startDate) { + val calendar = Calendar.getInstance() + calendar.time = startDate + calendar + } + val endCalendar = remember(endDate) { + val calendar = Calendar.getInstance() + calendar.time = endDate + calendar + } + + val appLocale = getCurrentAppLocale( + LocalContext.current + ) + val dateFormat = DateTimeFormatter.ofPattern("EEEE, dd.MM.yyyy", appLocale) + val timeFormat = DateTimeFormatter.ofPattern("HH:mm", appLocale) val scrollState = rememberScrollState() @@ -55,56 +91,178 @@ fun EventDetailsView( Column( modifier = Modifier .verticalScroll(scrollState) - .padding(24.dp) + .padding(16.dp) .fillMaxSize(), ) { if (event.value != null) { Text( - dateTimeFormat.format( - java.util.Date( - event.value?.startTime ?: 0L + // Check if the event is starting and ending on the same day or not + text = if (startCalendar.get(Calendar.DAY_OF_YEAR) == endCalendar.get( + Calendar.DAY_OF_YEAR ) - ) + " - " + dateTimeFormat.format( - java.util.Date(event.value?.endTime ?: 0L) - ), + && startCalendar.get(Calendar.YEAR) == endCalendar.get(Calendar.YEAR) + ) { + startDate.toInstant().atZone(ZoneId.systemDefault()).toLocalDate() + .format(dateFormat) + } else { + stringResource( + R.string.event_date_range, + startDate.toInstant().atZone(ZoneId.systemDefault()).toLocalDate() + .format(dateFormat), + endDate.toInstant().atZone(ZoneId.systemDefault()).toLocalDate() + .format(dateFormat) + ) + }, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.primary, ) - if (!event.value?.location.isNullOrBlank()) { - Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource( + R.string.event_time_range, + startDate.toInstant().atZone(ZoneId.systemDefault()).toLocalTime() + .format(timeFormat), + endDate.toInstant().atZone(ZoneId.systemDefault()).toLocalTime() + .format(timeFormat) + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider( + Modifier, + DividerDefaults.Thickness, + color = MaterialTheme.colorScheme.outlineVariant + ) + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.SpaceBetween + ) { Text( - text = AnnotatedString.rememberAutoLinkText( - event.value?.location ?: "" - ), - style = MaterialTheme.typography.labelLarge.copy(color = MaterialTheme.colorScheme.onSurface), - modifier = Modifier.padding(bottom = 4.dp) + text = stringResource(R.string.calendar_label), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold ) + Spacer(modifier = Modifier.width(48.dp)) + Row( + modifier = Modifier.fillMaxSize(), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.Start + ) { + 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) { + Color(event.value?.calendar?.color ?: 0) + } else { + MaterialTheme.colorScheme.primary + }, + shape = CircleShape + ) + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text( + text = event.value?.calendar?.name ?: "", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } } - if (!event.value?.organizer.isNullOrBlank()) { - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider( + Modifier, + DividerDefaults.Thickness, + color = MaterialTheme.colorScheme.outlineVariant + ) + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.organizer), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.width(24.dp)) Text( - event.value?.organizer ?: "", - style = MaterialTheme.typography.labelLarge, + text = if (event.value?.organizer?.trim().isNullOrEmpty()) { + stringResource(R.string.no_organizer_available) + } else { + event.value?.organizer ?: "" + }, + style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, ) } - Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.height(16.dp)) HorizontalDivider( Modifier, DividerDefaults.Thickness, color = MaterialTheme.colorScheme.outlineVariant ) - if (!event.value?.description.isNullOrBlank()) { - Spacer(modifier = Modifier.height(24.dp)) + 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, + ) + } + // TODO: Invitees and attendees and alerts + Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider( + Modifier, + DividerDefaults.Thickness, + color = MaterialTheme.colorScheme.outlineVariant + ) + Spacer(modifier = Modifier.height(16.dp)) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource(R.string.description), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(8.dp)) Text( text = AnnotatedString.rememberAutoLinkText( - event.value?.description ?: "" + if (event.value?.description?.trim().isNullOrEmpty()) { + stringResource(R.string.no_description_available) + } else { + event.value?.description ?: "" + } ), style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurface), ) } + } else { Text( stringResource( @@ -113,7 +271,9 @@ fun EventDetailsView( ) ) Spacer(modifier = Modifier.height(24.dp)) - Text("Event nicht gefunden oder wird geladen...") + Text( + stringResource(R.string.event_not_found), + ) } } } 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,12 +9,11 @@ import android.util.Log data class Calendar( val id: Long, val name: String, - val color: Int, + val color: Long, val accountName: String, val accountType: String, val syncEvents: Boolean, val visible: Boolean, - // TODO: Store this somewhere so it is kept across app restarts val selected: Boolean = true ) @@ -29,7 +28,9 @@ data class Event( val location: String?, val allDay: Boolean, val organizer: String?, - val attendees: List<String> = emptyList() + val attendees: List<String> = emptyList(), + // Reference to the calendar this event belongs to + val calendar: Calendar ) // Helper for the android calendar provider/CalendarContract @@ -72,7 +73,7 @@ class CalendarData { val calId = cur.getLong(PROJECTION_ID_INDEX) val displayName = cur.getString(PROJECTION_DISPLAY_NAME_INDEX) val name = cur.getString(PROJECTION_NAME_INDEX) - val color = cur.getInt(PROJECTION_CALENDAR_COLOR_INDEX) + val color = cur.getLong(PROJECTION_CALENDAR_COLOR_INDEX) val visible = cur.getInt(PROJECTION_VISIBLE_INDEX) val syncEvents = cur.getInt(PROJECTION_SYNC_EVENTS_INDEX) val accountName = cur.getString(PROJECTION_ACCOUNT_NAME_INDEX) @@ -173,7 +174,17 @@ class CalendarData { calendarId = calId, location = location, allDay = allDay, - organizer = organizer + organizer = organizer, + calendar = getCalendars(contentResolver).firstOrNull { it.id == calId } + ?: Calendar( + id = calId, + name = "Unknown Calendar", + color = 0xFF000000, // Default black color + accountName = "Unknown Account", + accountType = "Unknown Type", + syncEvents = false, + visible = false + ) // Fallback if calendar not found ) ) } @@ -277,7 +288,17 @@ class CalendarData { calendarId = cur.getLong(6), location = cur.getString(7), allDay = cur.getInt(8) == 1, - organizer = cur.getString(9) + organizer = cur.getString(9), + calendar = getCalendars(contentResolver).firstOrNull { it.id == cur.getLong(6) } + ?: Calendar( + id = cur.getLong(6), + name = "Unknown Calendar", + color = 0xFF000000, // Default black color + accountName = "Unknown Account", + accountType = "Unknown Type", + syncEvents = false, + visible = false + ) // Fallback if calendar not found ) } else null cur?.close() diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml @@ -26,5 +26,17 @@ <string name="tab_week">Week</string> <string name="tab_day">Day</string> <string name="now">Now</string> + <string name="calendar_label">Calendar:</string> + <string name="organizer">Organizer:</string> + <string name="no_organizer_available">No organizer available</string> + <string name="attendees">Attendees:</string> + <string name="no_attendees_available">No attendees available</string> + <string name="location">Location:</string> + <string name="no_location_available">No location specified</string> + <string name="description">Description:</string> + <string name="no_description_available">No description available</string> + <string name="event_not_found">Event not found or loading…</string> + <string name="event_time_range">From %1$s to %2$s</string> + <string name="event_date_range">%1$s until %2$s</string> </resources> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml @@ -26,5 +26,17 @@ <string name="tab_week">Woche</string> <string name="tab_day">Tag</string> <string name="now">Jetzt</string> + <string name="calendar_label">Kalender:</string> + <string name="organizer">Organisator:</string> + <string name="no_organizer_available">Kein Organisator gefunden</string> + <string name="attendees">Teilnehmer:</string> + <string name="no_attendees_available">Keine Teilnehmer gefunden</string> + <string name="location">Ort:</string> + <string name="no_location_available">Kein Ort angegeben</string> + <string name="description">Beschreibung:</string> + <string name="no_description_available">Keine Beschreibung verfügbar</string> + <string name="event_not_found">Event nicht gefunden oder wird geladen…</string> + <string name="event_time_range">Von %1$s bis %2$s</string> + <string name="event_date_range">%1$s bis %2$s</string> </resources>