diff --git a/CLAUDE.md b/CLAUDE.md index e8861644..a8576f37 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,7 +55,7 @@ per-variant entry (`ListScreenJournals/Notes/Tasks.kt`). - **Repositories:** interface in `core/domain/repository/`, implementation in `core/data/repository/`. - **Database:** SQLDelight (`shared/src/commonMain/sqldelight/…`). Schema changes need a - new numbered migration (`13.sqm` next) alongside the `.sq` edit — never edit an + new numbered migration (`14.sqm` next) alongside the `.sq` edit — never edit an existing `.sqm`. - **Strings:** source strings only in `shared/src/commonMain/composeResources/values/strings.xml`. Never hand-edit diff --git a/gradle.properties b/gradle.properties index de3d049d..5a5d25a2 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,7 @@ #Kotlin kotlin.code.style=official -kotlin.daemon.jvmargs=-Xmx3072M +# The JS and Wasm test executables are compiled in this daemon and ran out of memory at 3 GB on CI. +kotlin.daemon.jvmargs=-Xmx6144M #Gradle org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8 diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index d8afb58b..522c63c4 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -157,6 +157,8 @@ Not supported in this collection URL Add URL + Location + Add location Add Link file by URL The file stays on its server - Spectacled only stores the link and downloads it when opened. diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/claude/ClaudeResponseDto.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/claude/ClaudeResponseDto.kt index 62d12f02..601446fb 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/claude/ClaudeResponseDto.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/claude/ClaudeResponseDto.kt @@ -28,6 +28,7 @@ data class ClaudeResponseDto( description = data.description ?: icalEntry.description, dtStart = if(icalEntry.isTask() || icalEntry.isJournal()) parseIcsDateTime(data.dtstart) else null, due = if(icalEntry.isTask()) parseIcsDateTime(data.due) else null, + location = data.location ?: icalEntry.location, categories = data.categories ?: icalEntry.categories ) } catch (e: Exception) { @@ -52,7 +53,7 @@ data class ClaudeIcalDataDto( @SerialName("description") val description: String? = null, @SerialName("dtstart") val dtstart: String? = null, @SerialName("due") val due: String? = null, - @SerialName("location") val location: String? = null, // Note: IcalEntry currently doesn't have a location field + @SerialName("location") val location: String? = null, @SerialName("categories") val categories: List? = null ) diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/ics/KnownIcsPropertyName.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/ics/KnownIcsPropertyName.kt index 3a5aaad8..906889f8 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/ics/KnownIcsPropertyName.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/ics/KnownIcsPropertyName.kt @@ -19,5 +19,6 @@ enum class KnownIcsPropertyName(val propertyName: String) { PERCENT_COMPLETE("PERCENT-COMPLETE"), RELATED_TO("RELATED-TO"), URL("URL"), + LOCATION("LOCATION"), ATTACH("ATTACH") } diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/repository/IcalEntryRepositoryImpl.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/repository/IcalEntryRepositoryImpl.kt index c9f48795..01174c38 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/repository/IcalEntryRepositoryImpl.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/repository/IcalEntryRepositoryImpl.kt @@ -203,7 +203,8 @@ class IcalEntryRepositoryImpl( calendarComponent = icalEntryDto.calendarComponent, parentUid = icalEntryDto.parentUid, relType = icalEntryDto.relType, - url = icalEntryDto.url + url = icalEntryDto.url, + location = icalEntryDto.location ) // insert, but if the UID exists, it will be ignored db.icalentry_dtoQueries.insertIcalEntry( @@ -234,7 +235,8 @@ class IcalEntryRepositoryImpl( calendarComponent = icalEntryDto.calendarComponent, parentUid = icalEntryDto.parentUid, relType = icalEntryDto.relType, - url = icalEntryDto.url + url = icalEntryDto.url, + location = icalEntryDto.location ) } diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/domain/IcalEntry.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/domain/IcalEntry.kt index 272df030..3dd3baa5 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/domain/IcalEntry.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/domain/IcalEntry.kt @@ -83,7 +83,8 @@ data class IcalEntry( val calendarComponent: CalendarComponent, val parentUid: String? = null, val relType: String? = null, - val url: Url? = null + val url: Url? = null, + val location: String? = null ) { companion object { diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/mapper/dto/IcalEntryMapper.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/mapper/dto/IcalEntryMapper.kt index c3b0e729..ca8a3000 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/mapper/dto/IcalEntryMapper.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/mapper/dto/IcalEntryMapper.kt @@ -62,7 +62,8 @@ fun IcalEntryDto.toDomain(attachments: List = emptyList()): IcalEntr calendarComponent = CalendarComponent.entries.find { it.name == this.calendarComponent } ?: CalendarComponent.VJOURNAL, parentUid = this.parentUid, relType = this.relType, - url = this.url?.let { Url(it) } + url = this.url?.let { Url(it) }, + location = this.location ) } @@ -105,6 +106,7 @@ fun IcalEntry.toDto(): IcalEntryDto { calendarComponent = this.calendarComponent.name, parentUid = this.parentUid, relType = this.relType, - url = this.url?.toString() + url = this.url?.toString(), + location = this.location?.ifEmpty { null } ) } diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcalEntryIcsParser.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcalEntryIcsParser.kt index cdb384ae..dfd4de18 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcalEntryIcsParser.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcalEntryIcsParser.kt @@ -358,7 +358,8 @@ fun parseIcalEntryBlock( calendarComponent = calendarComponent, parentUid = parentUid, relType = relType, - url = url + url = url, + location = knownProps[KnownIcsPropertyName.LOCATION.propertyName]?.firstOrNull()?.value?.ifEmpty { null } ) } diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcalEntryIcsSerializer.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcalEntryIcsSerializer.kt index c092ee35..3def334f 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcalEntryIcsSerializer.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcalEntryIcsSerializer.kt @@ -163,6 +163,7 @@ fun serializeVJournal(icalEntry: IcalEntry, fileManager: FileManager? = null): S } icalEntry.summary?.let { lines += "${KnownIcsPropertyName.SUMMARY.propertyName}:${escapeIcsValue(it)}" } icalEntry.description?.let { lines += "${KnownIcsPropertyName.DESCRIPTION.propertyName}:${escapeIcsValue(it)}" } + icalEntry.location?.let { lines += "${KnownIcsPropertyName.LOCATION.propertyName}:${escapeIcsValue(it)}" } icalEntry.color?.let { lines += "${KnownIcsPropertyName.COLOR.propertyName}:${it.toArgb()}" } icalEntry.status?.let { lines += "${KnownIcsPropertyName.STATUS.propertyName}:${it.rfcName}" } icalEntry.classification?.let { lines += "${KnownIcsPropertyName.CLASSIFICATION.propertyName}:${it.name}" } diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsAction.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsAction.kt index ee5ef312..46c960d2 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsAction.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsAction.kt @@ -21,6 +21,7 @@ sealed interface DetailsAction { data class OnUpdateSubtaskProgress(val percent: Long, val subtaskIcalEntryId: Long): DetailsAction data class OnAddSubtask(val summary: String): DetailsAction data class OnUpdateUrl(val url: Url?): DetailsAction + data class OnUpdateLocation(val location: String?): DetailsAction data class OnAddAttachment(val fileName: String, val bytes: ByteArray, val mimeType: String?): DetailsAction data class OnAddUrlAttachment(val url: Url): DetailsAction data class OnOpenAttachment(val attachmentUid: String): DetailsAction diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsScreen.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsScreen.kt index 903ac0d5..9f7a3c1d 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsScreen.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsScreen.kt @@ -85,6 +85,7 @@ import at.techbee.spectacled.screens.core.presentation.components.WavyHorizontal import at.techbee.spectacled.screens.details.presentation.components.AttachmentCard import at.techbee.spectacled.screens.details.presentation.components.DateTimeCard import at.techbee.spectacled.screens.details.presentation.components.FormattingBar +import at.techbee.spectacled.screens.details.presentation.components.LocationCard import at.techbee.spectacled.screens.details.presentation.components.UrlCard import at.techbee.spectacled.screens.list.presentation.components.MetaInfoCard import at.techbee.spectacled.screens.list.presentation.components.TaskListItem @@ -388,6 +389,18 @@ fun DetailsScreen( } } + AnimatedVisibility(state.icalEntry.location != null) { + Column(modifier = Modifier.fillMaxWidth()) { + + LocationCard( + location = state.icalEntry.location?:"", + allowEditing = state.allowEditing(), + onClick = onAction, + modifier = Modifier.padding(vertical = 8.dp) + ) + } + } + AnimatedVisibility(state.icalEntry.attachments.isNotEmpty()) { Column( verticalArrangement = Arrangement.spacedBy(2.dp), diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsScreenRoot.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsScreenRoot.kt index 83ad9fee..8b5355f7 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsScreenRoot.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsScreenRoot.kt @@ -19,6 +19,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.Label import androidx.compose.material.icons.outlined.AddBox import androidx.compose.material.icons.outlined.AddLink +import androidx.compose.material.icons.outlined.AddLocation import androidx.compose.material.icons.outlined.AddTask import androidx.compose.material.icons.outlined.Attachment import androidx.compose.material.icons.outlined.DatasetLinked @@ -79,6 +80,7 @@ import at.techbee.spectacled.screens.details.presentation.components.DeleteIcalE import at.techbee.spectacled.screens.details.presentation.components.DetailsMoreBottomSheet import at.techbee.spectacled.screens.details.presentation.components.DetailsTopBar import at.techbee.spectacled.screens.details.presentation.components.DrawingCanvasBottomSheet +import at.techbee.spectacled.screens.details.presentation.components.EditLocationBottomSheet import at.techbee.spectacled.screens.details.presentation.components.EditUrlBottomSheet import at.techbee.spectacled.screens.details.presentation.components.JournalStatusPickerBottomSheet import at.techbee.spectacled.screens.details.presentation.components.MoveIcalEntryDialog @@ -91,6 +93,7 @@ import spectacled.shared.generated.resources.Res import spectacled.shared.generated.resources.add_attachment import spectacled.shared.generated.resources.add_drawing import spectacled.shared.generated.resources.add_from_gallery +import spectacled.shared.generated.resources.add_location import spectacled.shared.generated.resources.add_photo import spectacled.shared.generated.resources.add_subtask import spectacled.shared.generated.resources.add_url @@ -253,6 +256,12 @@ fun DetailsScreenRoot( onUrlEdited = { detailsViewModel.onAction(DetailsAction.OnUpdateUrl(it)) }, onDismiss = { detailsViewModel.onAction(DetailsAction.OnShowSheetOrDialog(null)) } ) + DetailsSheetOrDialog.EDIT_LOCATION -> + EditLocationBottomSheet( + initialLocation = detailsState.icalEntry.location, + onLocationEdited = { detailsViewModel.onAction(DetailsAction.OnUpdateLocation(it)) }, + onDismiss = { detailsViewModel.onAction(DetailsAction.OnShowSheetOrDialog(null)) } + ) DetailsSheetOrDialog.ADD_ATTACHMENT_URL -> AddUrlAttachmentBottomSheet( onUrlConfirmed = { detailsViewModel.onAction(DetailsAction.OnAddUrlAttachment(it)) }, @@ -464,6 +473,18 @@ fun DetailsScreenRoot( }, ) + if(detailsState.icalEntry.isTask()) { + DropdownMenuItem( + text = { Text(stringResource(Res.string.add_location)) }, + leadingIcon = { Icon(Icons.Outlined.AddLocation, stringResource(Res.string.add_location)) }, + enabled = detailsState.icalEntry.location == null, + onClick = { + detailsViewModel.onAction(DetailsAction.OnShowSheetOrDialog(DetailsSheetOrDialog.EDIT_LOCATION)) + addMoreExpanded = false + }, + ) + } + DropdownMenuItem( text = { Column { diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsState.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsState.kt index c3ecb28c..6b61c4ce 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsState.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsState.kt @@ -55,7 +55,7 @@ data class DetailsState @OptIn(ExperimentalTime::class) constructor( } enum class DetailsSheetOrDialog { - DELETE, MOVE, MORE, COLOR_SELECTOR, CATEGORY_SELECTOR, JOURNAL_STATUS_PICKER, TASK_STATUS_PICKER, ADD_SUBTASKS, EDIT_URL, ADD_ATTACHMENT_URL + DELETE, MOVE, MORE, COLOR_SELECTOR, CATEGORY_SELECTOR, JOURNAL_STATUS_PICKER, TASK_STATUS_PICKER, ADD_SUBTASKS, EDIT_URL, EDIT_LOCATION, ADD_ATTACHMENT_URL } // Action to perform right after the details screen opens diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsViewModel.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsViewModel.kt index 889c551d..9343b27c 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsViewModel.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsViewModel.kt @@ -315,6 +315,7 @@ class DetailsViewModel( is DetailsAction.OnUpdateCategories -> onUpdateCategories(action.addCategory, action.removeCategory) is DetailsAction.OnUpdateColor -> onUpdateColor(action.color) is DetailsAction.OnUpdateUrl -> onUpdateUrl(action.url) + is DetailsAction.OnUpdateLocation -> onUpdateLocation(action.location) is DetailsAction.OnUpdateDescription -> onUpdateDescription(action.description) is DetailsAction.OnUpdateSummary -> onUpdateSummary(action.summary) DetailsAction.OnDelete -> { @@ -532,6 +533,22 @@ class DetailsViewModel( } } + private fun onUpdateLocation(newLocation: String?) { + + if(!state.value.allowEditing()) + return + + _state.update { + it.copy( + icalEntry = it.icalEntry.copy( + location = newLocation, + lastModified = IcsDateTime.now(), + syncState = it.icalEntry.syncState.afterLocalEdit() + ) + ) + } + } + @OptIn(ExperimentalTime::class) private fun onUpdateDtStart(newDtStart: IcsDateTime?) { diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/components/EditLocationBottomSheet.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/components/EditLocationBottomSheet.kt new file mode 100644 index 00000000..2a4dcb4b --- /dev/null +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/components/EditLocationBottomSheet.kt @@ -0,0 +1,157 @@ +package at.techbee.spectacled.screens.details.presentation.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Clear +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +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.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.tooling.preview.Preview +import at.techbee.spectacled.SpectacledVariant +import at.techbee.spectacled.screens.core.presentation.components.BottomSheetWithMenu +import at.techbee.spectacled.theme.AppTheme +import kotlinx.coroutines.delay +import org.jetbrains.compose.resources.stringResource +import spectacled.shared.generated.resources.Res +import spectacled.shared.generated.resources.delete +import spectacled.shared.generated.resources.done +import spectacled.shared.generated.resources.location +import kotlin.time.Duration.Companion.milliseconds + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EditLocationBottomSheet( + initialLocation: String?, + onLocationEdited: (String?) -> Unit, + onDismiss: () -> Unit + ) { + + var textFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf( + TextFieldValue( + text = initialLocation ?: "", + selection = TextRange(initialLocation?.length ?: 0) + ) + ) + } + val keyboardController = LocalSoftwareKeyboardController.current + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + delay(300.milliseconds) + focusRequester.requestFocus() + } + + BottomSheetWithMenu( + onDismiss = { onDismiss() }, + headline = stringResource(Res.string.location), + menuActionRight = { + TextButton( + onClick = { + onDismiss() + }, + ) { + Text(stringResource(Res.string.done)) + } + }, + menuActionLeft = { + TextButton( + onClick = { + textFieldValue = TextFieldValue("") + onLocationEdited(null) + onDismiss() + } + ) { + Text(stringResource(Res.string.delete)) + } + } + ) { + + TextField( + value = textFieldValue, + onValueChange = { + textFieldValue = it + onLocationEdited(it.text.ifBlank { null }) + }, + placeholder = { Text(stringResource(Res.string.location)) }, + trailingIcon = { + IconButton( + onClick = { + if (textFieldValue.text.isNotBlank()) { + onLocationEdited(null) + textFieldValue = TextFieldValue("") + } + keyboardController?.hide() + }, + enabled = textFieldValue.text.isNotBlank(), + content = { Icon(Icons.Outlined.Clear, stringResource(Res.string.delete)) } + ) + }, + singleLine = true, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Words, + keyboardType = KeyboardType.PostalAddress, + autoCorrectEnabled = false, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { onDismiss() } + ), + shape = MaterialTheme.shapes.small, + modifier = Modifier.fillMaxWidth().focusRequester(focusRequester) + ) + + } +} + + +@Preview +@Composable +private fun EditLocationBottomSheet_Preview() { + AppTheme(spectacledVariant = SpectacledVariant.JOURNALS) { + Scaffold { + EditLocationBottomSheet( + initialLocation = "Am Stadtpark 3a/4/c, 1020 Wien", + onLocationEdited = {}, + onDismiss = { } + ) + } + } +} + +@Preview +@Composable +private fun EditLocationBottomSheet_empty_Preview() { + AppTheme(spectacledVariant = SpectacledVariant.JOURNALS) { + Scaffold { + EditLocationBottomSheet( + initialLocation = null, + onLocationEdited = {}, + onDismiss = { } + ) + } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/components/LocationCard.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/components/LocationCard.kt new file mode 100644 index 00000000..eb07081d --- /dev/null +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/components/LocationCard.kt @@ -0,0 +1,118 @@ +package at.techbee.spectacled.screens.details.presentation.components + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.OpenInNew +import androidx.compose.material.icons.outlined.PinDrop +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import at.techbee.spectacled.SpectacledVariant +import at.techbee.spectacled.screens.core.Platforms +import at.techbee.spectacled.screens.core.getPlatform +import at.techbee.spectacled.screens.details.presentation.DetailsAction +import at.techbee.spectacled.screens.details.presentation.DetailsSheetOrDialog +import at.techbee.spectacled.theme.AppTheme +import io.github.aakira.napier.Napier +import io.ktor.http.encodeURLParameter +import org.jetbrains.compose.resources.stringResource +import spectacled.shared.generated.resources.Res +import spectacled.shared.generated.resources.open_in_browser + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LocationCard( + location: String, + allowEditing: Boolean, + onClick: (DetailsAction) -> Unit, + modifier: Modifier = Modifier +) { + + val uriHandler = LocalUriHandler.current + + Card( + onClick = { + if(allowEditing) + onClick(DetailsAction.OnShowSheetOrDialog(DetailsSheetOrDialog.EDIT_LOCATION)) + }, + elevation = CardDefaults.cardElevation(0.dp), + colors = CardDefaults.cardColors(containerColor = Color.Transparent, disabledContainerColor = Color.Transparent), + modifier = modifier + ) { + + Row(verticalAlignment = Alignment.CenterVertically) { + + IconButton( + onClick = {}, + enabled = false + ) { + Icon(Icons.Outlined.PinDrop, null) + } + + Text( + text = location, + modifier = Modifier.weight(1f) + ) + + IconButton( + onClick = { + try { + uriHandler.openUri(mapsUriFor(location)) + } catch (e: Exception) { + Napier.w(e.stackTraceToString()) + } + } + ) { + Icon(Icons.AutoMirrored.Outlined.OpenInNew, stringResource(Res.string.open_in_browser)) + } + } + } +} + +fun mapsUriFor(location: String, platform: Platforms = getPlatform().platform): String { + val query = location.encodeURLParameter() + return when (platform) { + Platforms.ANDROID -> "geo:0,0?q=$query" + Platforms.IOS -> "https://maps.apple.com/?q=$query" + Platforms.DESKTOP, Platforms.WASM -> "https://www.openstreetmap.org/search?query=$query" + } +} + + +@Preview +@Composable +private fun LocationCard_Preview() { + AppTheme(spectacledVariant = SpectacledVariant.TASKS) { + LocationCard( + location = "Am Stadtpark 123/4c, 1030 Wien", + allowEditing = true, + onClick = {}, + modifier = Modifier.padding(8.dp) + ) + } +} + +@Preview +@Composable +private fun LocationCard_readonly_Preview() { + AppTheme(spectacledVariant = SpectacledVariant.TASKS) { + LocationCard( + location = "Am Stadtpark 123/4c, 1030 Wien", + allowEditing = false, + onClick = {}, + modifier = Modifier.padding(8.dp) + ) + } +} + diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/components/UrlCard.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/components/UrlCard.kt index 51ac9d4f..3036275b 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/components/UrlCard.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/components/UrlCard.kt @@ -3,7 +3,7 @@ package at.techbee.spectacled.screens.details.presentation.components import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.automirrored.outlined.OpenInNew import androidx.compose.material.icons.outlined.Link import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -28,6 +28,7 @@ import io.ktor.http.Url import org.jetbrains.compose.resources.stringResource import spectacled.shared.generated.resources.Res import spectacled.shared.generated.resources.edit +import spectacled.shared.generated.resources.open_in_browser @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -42,12 +43,9 @@ fun UrlCard( Card( onClick = { - try { - uriHandler.openUri(url.toString()) - } catch (e: Exception) { - Napier.w(e.stackTraceToString()) - } - }, + if(allowEditing) + onClick(DetailsAction.OnShowSheetOrDialog(DetailsSheetOrDialog.EDIT_URL)) + }, elevation = CardDefaults.cardElevation(0.dp), colors = CardDefaults.cardColors(containerColor = Color.Transparent, disabledContainerColor = Color.Transparent), modifier = modifier @@ -69,14 +67,16 @@ fun UrlCard( modifier = Modifier.weight(1f) ) - if(allowEditing) { - IconButton( - onClick = { - onClick(DetailsAction.OnShowSheetOrDialog(DetailsSheetOrDialog.EDIT_URL)) + IconButton( + onClick = { + try { + uriHandler.openUri(url.toString()) + } catch (e: Exception) { + Napier.w(e.stackTraceToString()) } - ) { - Icon(Icons.Outlined.Edit, stringResource(Res.string.edit)) } + ) { + Icon(Icons.AutoMirrored.Outlined.OpenInNew, stringResource(Res.string.open_in_browser)) } } } diff --git a/shared/src/commonMain/sqldelight/at/techbee/spectacled/sqldelight/13.sqm b/shared/src/commonMain/sqldelight/at/techbee/spectacled/sqldelight/13.sqm new file mode 100644 index 00000000..ce5a1032 --- /dev/null +++ b/shared/src/commonMain/sqldelight/at/techbee/spectacled/sqldelight/13.sqm @@ -0,0 +1 @@ +ALTER TABLE IcalEntryDto ADD COLUMN location TEXT; diff --git a/shared/src/commonMain/sqldelight/at/techbee/spectacled/sqldelight/icalentry_dto.sq b/shared/src/commonMain/sqldelight/at/techbee/spectacled/sqldelight/icalentry_dto.sq index 9c59f1e7..e7c27fd7 100644 --- a/shared/src/commonMain/sqldelight/at/techbee/spectacled/sqldelight/icalentry_dto.sq +++ b/shared/src/commonMain/sqldelight/at/techbee/spectacled/sqldelight/icalentry_dto.sq @@ -29,6 +29,7 @@ CREATE TABLE IcalEntryDto ( parentUid TEXT, relType TEXT, url TEXT, + location TEXT, UNIQUE(calendarId, uid) ); @@ -100,7 +101,8 @@ INSERT OR IGNORE INTO IcalEntryDto ( calendarComponent, parentUid, relType, - url + url, + location ) VALUES ( :calendarId, @@ -130,7 +132,8 @@ VALUES ( :calendarComponent, :parentUid, :relType, - :url + :url, + :location ); updateIcalEntry: @@ -164,7 +167,8 @@ SET calendarComponent = :calendarComponent, parentUid = :parentUid, relType = :relType, - url = :url + url = :url, + location = :location WHERE calendarId = :calendarId AND uid = :uid; deleteTrashed: diff --git a/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/mapper/dto/IcalEntryMapperTest.kt b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/mapper/dto/IcalEntryMapperTest.kt new file mode 100644 index 00000000..44b9d9d4 --- /dev/null +++ b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/mapper/dto/IcalEntryMapperTest.kt @@ -0,0 +1,24 @@ +package at.techbee.spectacled.screens.core.mapper.dto + +import at.techbee.spectacled.screens.core.domain.CalendarComponent +import at.techbee.spectacled.screens.core.domain.IcalEntry +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class IcalEntryMapperTest { + + @Test + fun locationSurvivesDtoRoundTrip() { + val entry = IcalEntry(uid = "loc-uid", location = "Stadtpark", calendarComponent = CalendarComponent.VTODO) + + assertEquals("Stadtpark", entry.toDto().toDomain().location) + } + + @Test + fun emptyLocationIsStoredAsNull() { + val entry = IcalEntry(uid = "loc-uid", location = "", calendarComponent = CalendarComponent.VTODO) + + assertNull(entry.toDto().location) + } +} diff --git a/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcalEntryRoundTripTest.kt b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcalEntryRoundTripTest.kt index 30193c65..49e2e587 100644 --- a/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcalEntryRoundTripTest.kt +++ b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/mapper/ics/IcalEntryRoundTripTest.kt @@ -92,6 +92,7 @@ class IcalEntryRoundTripTest { status = Status.IN_PROCESS, percentComplete = 50L, priority = 1L, + location = "Stadtpark, Bench 3; near the pond", dtstamp = utcDateTime, created = utcDateTime, lastModified = utcDateTime, @@ -106,6 +107,30 @@ class IcalEntryRoundTripTest { assertEquals(Status.IN_PROCESS, parsed.status) assertEquals(50L, parsed.percentComplete) assertEquals(1L, parsed.priority) + assertEquals(original.location, parsed.location) + assertTrue(parsed.extraProperties.none { it.name == "LOCATION" }) + } + + @Test + fun thunderbirdTaskLocationIsParsed() { + val ics = """ + BEGIN:VCALENDAR + PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN + VERSION:2.0 + BEGIN:VTODO + UID:tb-task-1 + DTSTAMP:20260711T101530Z + SUMMARY:Buy groceries + LOCATION:Main Street 12\, Vienna + END:VTODO + END:VCALENDAR + """.trimIndent().replace("\n", "\r\n") + + val parsed = parseIcalEntries(ics).single() + + assertEquals("Main Street 12, Vienna", parsed.location) + assertTrue(parsed.extraProperties.none { it.name == "LOCATION" }) + assertTrue(serializeVCalendar(parsed).contains("LOCATION:Main Street 12\\, Vienna")) } @Test