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