From d0b8cb4284e249c94252c78bc6af0d41efceac55 Mon Sep 17 00:00:00 2001 From: tylxr <102394635+tylxr59@users.noreply.github.com> Date: Tue, 16 Dec 2025 10:41:58 -0500 Subject: [PATCH 01/11] Initial implementation - still borked --- .../org/stypox/dicio/eval/SkillHandler.kt | 2 + .../dicio/skills/calendar/CalendarInfo.kt | 32 +++++ .../dicio/skills/calendar/CalendarOutput.kt | 127 ++++++++++++++++++ .../dicio/skills/calendar/CalendarSkill.kt | 114 ++++++++++++++++ app/src/main/res/values/strings.xml | 9 ++ app/src/main/sentences/en/calendar.yml | 7 + app/src/main/sentences/skill_definitions.yml | 12 ++ 7 files changed, 303 insertions(+) create mode 100644 app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarInfo.kt create mode 100644 app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarOutput.kt create mode 100644 app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarSkill.kt create mode 100644 app/src/main/sentences/en/calendar.yml diff --git a/app/src/main/kotlin/org/stypox/dicio/eval/SkillHandler.kt b/app/src/main/kotlin/org/stypox/dicio/eval/SkillHandler.kt index 5c7c7f885..a258b33a6 100644 --- a/app/src/main/kotlin/org/stypox/dicio/eval/SkillHandler.kt +++ b/app/src/main/kotlin/org/stypox/dicio/eval/SkillHandler.kt @@ -17,6 +17,7 @@ import org.stypox.dicio.di.SkillContextImpl import org.stypox.dicio.di.SkillContextInternal import org.stypox.dicio.settings.datastore.UserSettings import org.stypox.dicio.settings.datastore.UserSettingsModule +import org.stypox.dicio.skills.calendar.CalendarInfo import org.stypox.dicio.skills.calculator.CalculatorInfo import org.stypox.dicio.skills.current_time.CurrentTimeInfo import org.stypox.dicio.skills.fallback.text.TextFallbackInfo @@ -50,6 +51,7 @@ class SkillHandler @Inject constructor( OpenInfo, CalculatorInfo, NavigationInfo, + CalendarInfo, TelephoneInfo, TimerInfo, CurrentTimeInfo, diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarInfo.kt b/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarInfo.kt new file mode 100644 index 000000000..a824ed90d --- /dev/null +++ b/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarInfo.kt @@ -0,0 +1,32 @@ +package org.stypox.dicio.skills.calendar + +import android.content.Context +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Event +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import org.dicio.skill.skill.Skill +import org.dicio.skill.context.SkillContext +import org.dicio.skill.skill.SkillInfo +import org.stypox.dicio.R +import org.stypox.dicio.sentences.Sentences + +object CalendarInfo : SkillInfo("calendar") { + override fun name(context: Context) = + context.getString(R.string.skill_name_calendar) + + override fun sentenceExample(context: Context) = + context.getString(R.string.skill_sentence_example_calendar) + + @Composable + override fun icon() = + rememberVectorPainter(Icons.Default.Event) + + override fun isAvailable(ctx: SkillContext): Boolean { + return Sentences.Calendar[ctx.sentencesLanguage] != null + } + + override fun build(ctx: SkillContext): Skill<*> { + return CalendarSkill(CalendarInfo, Sentences.Calendar[ctx.sentencesLanguage]!!) + } +} diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarOutput.kt b/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarOutput.kt new file mode 100644 index 000000000..c6e6f43cf --- /dev/null +++ b/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarOutput.kt @@ -0,0 +1,127 @@ +package org.stypox.dicio.skills.calendar + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import org.dicio.skill.context.SkillContext +import org.dicio.skill.skill.SkillOutput +import org.stypox.dicio.R +import org.stypox.dicio.util.getString +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle + +sealed interface CalendarOutput : SkillOutput { + + data class Success( + private val title: String, + private val startDateTime: LocalDateTime, + private val durationMillis: Long + ) : CalendarOutput { + override fun getSpeechOutput(ctx: SkillContext): String { + val formattedDateTime = ctx.parserFormatter?.niceDateTime(startDateTime)?.get() + ?: startDateTime.format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)) + + val durationHours = durationMillis / (60 * 60 * 1000) + val durationMinutes = (durationMillis % (60 * 60 * 1000)) / (60 * 1000) + + val durationText = when { + durationHours > 0 && durationMinutes > 0 -> + ctx.getString(R.string.skill_calendar_duration_hours_minutes, durationHours, durationMinutes) + durationHours > 0 -> + ctx.getString(R.string.skill_calendar_duration_hours, durationHours) + durationMinutes > 0 -> + ctx.getString(R.string.skill_calendar_duration_minutes, durationMinutes) + else -> + ctx.getString(R.string.skill_calendar_duration_hours, 1) + } + + return ctx.getString(R.string.skill_calendar_success, title, formattedDateTime, durationText) + } + + @Composable + override fun GraphicalOutput(ctx: SkillContext) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = ctx.getString(R.string.skill_calendar_event_added), + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = startDateTime.format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + } + } + } + + data object NoTitle : CalendarOutput { + override fun getSpeechOutput(ctx: SkillContext): String = + ctx.getString(R.string.skill_calendar_no_title) + + @Composable + override fun GraphicalOutput(ctx: SkillContext) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = getSpeechOutput(ctx), + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.error + ) + } + } + } + + data object NoCalendarApp : CalendarOutput { + override fun getSpeechOutput(ctx: SkillContext): String = + ctx.getString(R.string.skill_calendar_no_app) + + @Composable + override fun GraphicalOutput(ctx: SkillContext) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = getSpeechOutput(ctx), + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.error + ) + } + } + } +} diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarSkill.kt b/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarSkill.kt new file mode 100644 index 000000000..ee599a41a --- /dev/null +++ b/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarSkill.kt @@ -0,0 +1,114 @@ +package org.stypox.dicio.skills.calendar + +import android.content.Intent +import android.provider.CalendarContract +import org.dicio.skill.context.SkillContext +import org.dicio.skill.skill.SkillInfo +import org.dicio.skill.skill.SkillOutput +import org.dicio.skill.standard.StandardRecognizerData +import org.dicio.skill.standard.StandardRecognizerSkill +import org.stypox.dicio.sentences.Sentences.Calendar +import java.time.LocalDateTime +import java.time.ZoneId + +class CalendarSkill( + correspondingSkillInfo: SkillInfo, + data: StandardRecognizerData +) : StandardRecognizerSkill(correspondingSkillInfo, data) { + + override suspend fun generateOutput(ctx: SkillContext, inputData: Calendar): SkillOutput { + val (title, dateTimeStr, durationInput) = when (inputData) { + is Calendar.CreateEvent -> Triple( + inputData.title, + inputData.dateTime, + inputData.duration + ) + } + + // Validate title + if (title.isNullOrBlank()) { + return CalendarOutput.NoTitle + } + + val npf = ctx.parserFormatter + var cleanTitle = title.trim() + var extractedDateTime: LocalDateTime? = null + + // First, try to parse date/time from the explicit dateTimeStr capture + if (!dateTimeStr.isNullOrBlank() && npf != null) { + extractedDateTime = npf.extractDateTime(dateTimeStr) + .now(LocalDateTime.now()) + .preferMonthBeforeDay(false) + .first + } + + // If no date/time found in explicit capture, check if the title contains date/time info + if (extractedDateTime == null && npf != null) { + val mixedResult = npf.extractDateTime(cleanTitle) + .now(LocalDateTime.now()) + .preferMonthBeforeDay(false) + .mixedWithText + + // Check if we found a date/time and extract the first one + var foundDateTime = false + val titleParts = mutableListOf() + + for (item in mixedResult) { + when (item) { + is LocalDateTime -> { + if (!foundDateTime) { + extractedDateTime = item + foundDateTime = true + } + // Don't add LocalDateTime to titleParts - we're removing it + } + is String -> titleParts.add(item) + } + } + + // Only update the title if we actually found a date/time to remove + if (foundDateTime) { + val reconstructedTitle = titleParts.joinToString("").trim() + if (reconstructedTitle.isNotBlank()) { + cleanTitle = reconstructedTitle + } + } + } + + // Default to current time if still no date/time found + val startDateTime = extractedDateTime ?: LocalDateTime.now() + + // Parse duration or default to 1 hour + val durationMillis: Long = if (durationInput != null && npf != null) { + val parsedDuration = npf.extractDuration(durationInput) + .first?.toJavaDuration() + parsedDuration?.toMillis() ?: (60 * 60 * 1000L) // default 1 hour + } else { + 60 * 60 * 1000L // default 1 hour (in milliseconds) + } + + // Calculate start and end times in milliseconds + val startMillis = startDateTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() + val endMillis = startMillis + durationMillis + + // Create calendar intent + val calendarIntent = Intent(Intent.ACTION_INSERT).apply { + data = CalendarContract.Events.CONTENT_URI + putExtra(CalendarContract.Events.TITLE, cleanTitle) + putExtra(CalendarContract.EXTRA_EVENT_BEGIN_TIME, startMillis) + putExtra(CalendarContract.EXTRA_EVENT_END_TIME, endMillis) + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + + // Check if there's an app that can handle the calendar intent + val packageManager = ctx.android.packageManager + val canHandleIntent = calendarIntent.resolveActivity(packageManager) != null + + return if (canHandleIntent) { + ctx.android.startActivity(calendarIntent) + CalendarOutput.Success(cleanTitle, startDateTime, durationMillis) + } else { + CalendarOutput.NoCalendarApp + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ea8db6663..c1621e7f1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -148,6 +148,15 @@ Navigate to Vancouver international airport Specify where you want to navigate to Navigating to %1$s + Calendar + Add dentist appointment tomorrow at 3pm + Adding %1$s to your calendar on %2$s for %3$s + Event added to calendar + Please specify an event title + No calendar app found. Please install a calendar app to add events + %1$d hour + %1$d minutes + %1$d hour and %2$d minutes Telephone Call Tom Timer diff --git a/app/src/main/sentences/en/calendar.yml b/app/src/main/sentences/en/calendar.yml new file mode 100644 index 000000000..9d1c8fbf1 --- /dev/null +++ b/app/src/main/sentences/en/calendar.yml @@ -0,0 +1,7 @@ +create_event: + - (add|create|schedule|make) .title. (to|on|in my? calendar)? ((on|at|for)? .date_time.)? (for .duration.)? + - (add|create|schedule|make) (a|an)? (calendar? event) (called|named .title.) ((on|at|for)? .date_time.)? (for .duration.)? + - schedule .title. ((on|at|for)? .date_time.)? (for .duration.)? + - (put|add) .title. (on|in my? calendar) ((on|at|for)? .date_time.)? (for .duration.)? + - remind me (about|of .title.) ((on|at|for)? .date_time.)? (for .duration.)? + - (create|make) (a|an)? event .title. ((on|at|for)? .date_time.)? (for .duration.)? diff --git a/app/src/main/sentences/skill_definitions.yml b/app/src/main/sentences/skill_definitions.yml index ed347e967..7ce2e708b 100644 --- a/app/src/main/sentences/skill_definitions.yml +++ b/app/src/main/sentences/skill_definitions.yml @@ -134,3 +134,15 @@ skills: type: string - id: target type: string + + - id: calendar + specificity: high + sentences: + - id: create_event + captures: + - id: title + type: string + - id: date_time + type: string + - id: duration + type: duration From 6c0a88bee7774311e3dfbf91628b6e09eecdddb8 Mon Sep 17 00:00:00 2001 From: tylxr <102394635+tylxr59@users.noreply.github.com> Date: Wed, 17 Dec 2025 12:00:12 -0500 Subject: [PATCH 02/11] Calendar sentence clean up and title capitalization --- .../stypox/dicio/skills/calendar/CalendarSkill.kt | 2 ++ app/src/main/sentences/en/calendar.yml | 13 +++++++------ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarSkill.kt b/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarSkill.kt index ee599a41a..112c4736e 100644 --- a/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarSkill.kt +++ b/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarSkill.kt @@ -32,6 +32,8 @@ class CalendarSkill( val npf = ctx.parserFormatter var cleanTitle = title.trim() + .split(" ") + .joinToString(" ") { word -> word.replaceFirstChar { it.uppercase() } } var extractedDateTime: LocalDateTime? = null // First, try to parse date/time from the explicit dateTimeStr capture diff --git a/app/src/main/sentences/en/calendar.yml b/app/src/main/sentences/en/calendar.yml index 9d1c8fbf1..3c02bc578 100644 --- a/app/src/main/sentences/en/calendar.yml +++ b/app/src/main/sentences/en/calendar.yml @@ -1,7 +1,8 @@ create_event: - - (add|create|schedule|make) .title. (to|on|in my? calendar)? ((on|at|for)? .date_time.)? (for .duration.)? - - (add|create|schedule|make) (a|an)? (calendar? event) (called|named .title.) ((on|at|for)? .date_time.)? (for .duration.)? - - schedule .title. ((on|at|for)? .date_time.)? (for .duration.)? - - (put|add) .title. (on|in my? calendar) ((on|at|for)? .date_time.)? (for .duration.)? - - remind me (about|of .title.) ((on|at|for)? .date_time.)? (for .duration.)? - - (create|make) (a|an)? event .title. ((on|at|for)? .date_time.)? (for .duration.)? + - (add|create|schedule|make|put) .title. (to|on|in my?)? calendar? ((on|at|for|starting)? .date_time.)? ((for|lasting)? .duration.)? + - (add|create|schedule|make) (a|an)? (calendar|new)? event (called|named|for)? .title. ((on|at|for|starting)? .date_time.)? ((for|lasting)? .duration.)? + - (set up|book) (a|an)? (meeting|appointment|event)? .title. ((on|at|for|starting)? .date_time.)? ((for|lasting)? .duration.)? + - remind me (about|of|to)? .title. ((on|at|for|starting)? .date_time.)? ((for|lasting)? .duration.)? + - (on|for) .date_time. (add|create|schedule|make) .title. (to|on)? calendar? + - (block|hold|reserve) .duration. (for)? .title. ((on|at|starting)? .date_time.)? + - .title. ((on|at)? .date_time.)? ((for)? .duration.)? \ No newline at end of file From 8cf2f267496054b6659327e36f3109ba92110091 Mon Sep 17 00:00:00 2001 From: tylxr <102394635+tylxr59@users.noreply.github.com> Date: Wed, 17 Dec 2025 12:03:21 -0500 Subject: [PATCH 03/11] Remove potentially problematic sentence --- app/src/main/sentences/en/calendar.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/main/sentences/en/calendar.yml b/app/src/main/sentences/en/calendar.yml index 3c02bc578..ff63a498a 100644 --- a/app/src/main/sentences/en/calendar.yml +++ b/app/src/main/sentences/en/calendar.yml @@ -4,5 +4,4 @@ create_event: - (set up|book) (a|an)? (meeting|appointment|event)? .title. ((on|at|for|starting)? .date_time.)? ((for|lasting)? .duration.)? - remind me (about|of|to)? .title. ((on|at|for|starting)? .date_time.)? ((for|lasting)? .duration.)? - (on|for) .date_time. (add|create|schedule|make) .title. (to|on)? calendar? - - (block|hold|reserve) .duration. (for)? .title. ((on|at|starting)? .date_time.)? - - .title. ((on|at)? .date_time.)? ((for)? .duration.)? \ No newline at end of file + - (block|hold|reserve) .duration. (for)? .title. ((on|at|starting)? .date_time.)? \ No newline at end of file From e814a9f4acbab6115089a8e91e0407c1f2b9340b Mon Sep 17 00:00:00 2001 From: tylxr <102394635+tylxr59@users.noreply.github.com> Date: Wed, 17 Dec 2025 14:42:13 -0500 Subject: [PATCH 04/11] Add reading from calendar --- app/src/main/AndroidManifest.xml | 3 + .../dicio/skills/calendar/CalendarEvent.kt | 11 + .../dicio/skills/calendar/CalendarInfo.kt | 6 +- .../dicio/skills/calendar/CalendarOutput.kt | 194 ++++++++++++++++-- .../dicio/skills/calendar/CalendarSkill.kt | 101 ++++++++- .../org/stypox/dicio/util/PermissionUtils.kt | 4 + app/src/main/res/values/strings.xml | 14 ++ app/src/main/sentences/en/calendar.yml | 10 +- app/src/main/sentences/skill_definitions.yml | 4 + 9 files changed, 321 insertions(+), 26 deletions(-) create mode 100644 app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarEvent.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c66e4da09..ad9d4e7f4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -27,6 +27,9 @@ + + + = listOf(PERMISSION_READ_CALENDAR) + override fun build(ctx: SkillContext): Skill<*> { return CalendarSkill(CalendarInfo, Sentences.Calendar[ctx.sentencesLanguage]!!) } diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarOutput.kt b/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarOutput.kt index c6e6f43cf..e02a99895 100644 --- a/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarOutput.kt +++ b/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarOutput.kt @@ -2,10 +2,15 @@ package org.stypox.dicio.skills.calendar import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -16,6 +21,7 @@ import androidx.compose.ui.unit.dp import org.dicio.skill.context.SkillContext import org.dicio.skill.skill.SkillOutput import org.stypox.dicio.R +import org.stypox.dicio.io.graphical.Headline import org.stypox.dicio.util.getString import java.time.LocalDateTime import java.time.format.DateTimeFormatter @@ -85,26 +91,159 @@ sealed interface CalendarOutput : SkillOutput { @Composable override fun GraphicalOutput(ctx: SkillContext) { + Headline(text = getSpeechOutput(ctx)) + } + } + + data class EventsList( + private val events: List, + private val queryDate: LocalDateTime + ) : CalendarOutput { + override fun getSpeechOutput(ctx: SkillContext): String { + if (events.isEmpty()) { + return NoEvents(queryDate).getSpeechOutput(ctx) + } + + val formattedDate = ctx.parserFormatter?.niceDate(queryDate.toLocalDate())?.get() + ?: queryDate.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)) + + val maxEventsToRead = 5 + val eventsToRead = if (events.size > maxEventsToRead) events.take(maxEventsToRead) else events + + val eventList = eventsToRead.joinToString(", ") { event -> + if (event.isAllDay) { + "${event.title} (${ctx.getString(R.string.skill_calendar_all_day)})" + } else { + val time = ctx.parserFormatter?.niceTime(event.startDateTime.toLocalTime())?.get() + ?: event.startDateTime.format(DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)) + "${event.title} ${ctx.getString(R.string.skill_calendar_event_at)} $time" + } + } + + val prefix = if (events.size > maxEventsToRead) { + ctx.getString(R.string.skill_calendar_on_date_you_have_count, formattedDate, events.size) + ". " + + ctx.getString(R.string.skill_calendar_here_are_first, maxEventsToRead) + ": " + } else if (events.size == 1) { + ctx.getString(R.string.skill_calendar_on_date_you_have_one_event, formattedDate) + ": " + } else { + ctx.getString(R.string.skill_calendar_on_date_you_have_count, formattedDate, events.size) + ": " + } + + return prefix + eventList + } + + @Composable + override fun GraphicalOutput(ctx: SkillContext) { + if (events.isEmpty()) { + NoEvents(queryDate).GraphicalOutput(ctx) + return + } + Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + modifier = Modifier.fillMaxWidth() ) { - Text( - text = getSpeechOutput(ctx), - style = MaterialTheme.typography.headlineSmall, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.error - ) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + val dateStr = queryDate.format( + DateTimeFormatter.ofPattern("MMMM d, yyyy", java.util.Locale.getDefault()) + ) + + Text( + text = dateStr, + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center + ) + + Text( + text = if (events.size == 1) + ctx.getString(R.string.skill_calendar_one_event_found) + else + ctx.getString(R.string.skill_calendar_events_found, events.size), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + // Show event summaries without dates + events.forEach { event -> + val displayText = if (event.isAllDay) { + "${event.title} (${ctx.getString(R.string.skill_calendar_all_day_capitalized)})" + } else { + val timeStr = event.startDateTime.format( + DateTimeFormatter.ofPattern("h:mma", java.util.Locale.getDefault()) + ).lowercase() + "${event.title} @ $timeStr" + } + + Text( + text = displayText, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + } + } + + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(events) { event -> + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column( + modifier = Modifier.padding(12.dp) + ) { + Text( + text = event.title, + style = MaterialTheme.typography.titleMedium + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = if (event.isAllDay) + ctx.getString(R.string.skill_calendar_all_day_capitalized) + else event.startDateTime.format( + DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + if (event.location != null) { + Text( + text = event.location, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } + } } } } - data object NoCalendarApp : CalendarOutput { - override fun getSpeechOutput(ctx: SkillContext): String = - ctx.getString(R.string.skill_calendar_no_app) + data class NoEvents( + private val queryDate: LocalDateTime + ) : CalendarOutput { + override fun getSpeechOutput(ctx: SkillContext): String { + val formattedDate = ctx.parserFormatter?.niceDate(queryDate.toLocalDate())?.get() + ?: queryDate.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)) + return ctx.getString(R.string.skill_calendar_no_events, formattedDate) + } @Composable override fun GraphicalOutput(ctx: SkillContext) { @@ -113,15 +252,34 @@ sealed interface CalendarOutput : SkillOutput { .fillMaxWidth() .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + verticalArrangement = Arrangement.spacedBy(8.dp) ) { + val dateStr = queryDate.format( + DateTimeFormatter.ofPattern("MMMM d, yyyy", java.util.Locale.getDefault()) + ) + + Text( + text = dateStr, + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center + ) Text( - text = getSpeechOutput(ctx), - style = MaterialTheme.typography.headlineSmall, + text = ctx.getString(R.string.skill_calendar_no_events_simple), + style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.error + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } } + + data object NoCalendarApp : CalendarOutput { + override fun getSpeechOutput(ctx: SkillContext): String = + ctx.getString(R.string.skill_calendar_no_app) + + @Composable + override fun GraphicalOutput(ctx: SkillContext) { + Headline(text = getSpeechOutput(ctx)) + } + } } diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarSkill.kt b/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarSkill.kt index 112c4736e..9bb00d347 100644 --- a/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarSkill.kt +++ b/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarSkill.kt @@ -8,7 +8,9 @@ import org.dicio.skill.skill.SkillOutput import org.dicio.skill.standard.StandardRecognizerData import org.dicio.skill.standard.StandardRecognizerSkill import org.stypox.dicio.sentences.Sentences.Calendar +import java.time.Instant import java.time.LocalDateTime +import java.time.LocalTime import java.time.ZoneId class CalendarSkill( @@ -17,13 +19,16 @@ class CalendarSkill( ) : StandardRecognizerSkill(correspondingSkillInfo, data) { override suspend fun generateOutput(ctx: SkillContext, inputData: Calendar): SkillOutput { - val (title, dateTimeStr, durationInput) = when (inputData) { - is Calendar.CreateEvent -> Triple( - inputData.title, - inputData.dateTime, - inputData.duration - ) + return when (inputData) { + is Calendar.CreateEvent -> createEvent(ctx, inputData) + is Calendar.QueryEvents -> queryEvents(ctx, inputData) } + } + + private fun createEvent(ctx: SkillContext, inputData: Calendar.CreateEvent): SkillOutput { + val title = inputData.title + val dateTimeStr = inputData.dateTime + val durationInput = inputData.duration // Validate title if (title.isNullOrBlank()) { @@ -113,4 +118,88 @@ class CalendarSkill( CalendarOutput.NoCalendarApp } } + + private fun queryEvents(ctx: SkillContext, inputData: Calendar.QueryEvents): SkillOutput { + val dateTimeStr = inputData.dateTime + val npf = ctx.parserFormatter + + // Parse the date/time or default to today + val parsedDateTime: LocalDateTime = if (!dateTimeStr.isNullOrBlank() && npf != null) { + npf.extractDateTime(dateTimeStr) + .now(LocalDateTime.now()) + .preferMonthBeforeDay(false) + .first ?: LocalDateTime.now() + } else { + LocalDateTime.now() + } + + // Set to start of day for the query (we only care about the date, not the time) + val startOfDay = parsedDateTime.toLocalDate().atStartOfDay() + val endOfDay = parsedDateTime.toLocalDate().atTime(LocalTime.MAX) + + // Convert to milliseconds for calendar query + val startMillis = startOfDay.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() + val endMillis = endOfDay.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() + + // Query calendar events using Instances table (handles recurring events properly) + val events = mutableListOf() + val contentResolver = ctx.android.contentResolver + + // Build the URI for querying instances in the time range + val instancesUri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply { + appendPath(startMillis.toString()) + appendPath(endMillis.toString()) + }.build() + + val projection = arrayOf( + CalendarContract.Instances.EVENT_ID, + CalendarContract.Instances.TITLE, + CalendarContract.Instances.BEGIN, + CalendarContract.Instances.END, + CalendarContract.Instances.EVENT_LOCATION, + CalendarContract.Instances.ALL_DAY + ) + + val sortOrder = "${CalendarContract.Instances.BEGIN} ASC" + + try { + contentResolver.query( + instancesUri, + projection, + null, // selection handled by URI + null, // selectionArgs handled by URI + sortOrder + )?.use { cursor -> + val titleIndex = cursor.getColumnIndex(CalendarContract.Instances.TITLE) + val startIndex = cursor.getColumnIndex(CalendarContract.Instances.BEGIN) + val endIndex = cursor.getColumnIndex(CalendarContract.Instances.END) + val locationIndex = cursor.getColumnIndex(CalendarContract.Instances.EVENT_LOCATION) + val allDayIndex = cursor.getColumnIndex(CalendarContract.Instances.ALL_DAY) + + while (cursor.moveToNext()) { + val title = if (titleIndex != -1) cursor.getString(titleIndex) ?: "Untitled" else "Untitled" + val startTimeMillis = if (startIndex != -1) cursor.getLong(startIndex) else continue + val endTimeMillis = if (endIndex != -1) cursor.getLong(endIndex) else startTimeMillis + (60 * 60 * 1000) + val location = if (locationIndex != -1) cursor.getString(locationIndex) else null + val isAllDay = if (allDayIndex != -1) cursor.getInt(allDayIndex) == 1 else false + + val startDateTime = LocalDateTime.ofInstant( + Instant.ofEpochMilli(startTimeMillis), + ZoneId.systemDefault() + ) + val endDateTime = LocalDateTime.ofInstant( + Instant.ofEpochMilli(endTimeMillis), + ZoneId.systemDefault() + ) + + events.add(CalendarEvent(title, startDateTime, endDateTime, location, isAllDay)) + } + } + } catch (e: Exception) { + // Handle permission or other errors gracefully + return CalendarOutput.NoEvents(startOfDay) + } + + return CalendarOutput.EventsList(events, startOfDay) + } } diff --git a/app/src/main/kotlin/org/stypox/dicio/util/PermissionUtils.kt b/app/src/main/kotlin/org/stypox/dicio/util/PermissionUtils.kt index 79617da90..32cd335c5 100644 --- a/app/src/main/kotlin/org/stypox/dicio/util/PermissionUtils.kt +++ b/app/src/main/kotlin/org/stypox/dicio/util/PermissionUtils.kt @@ -26,6 +26,10 @@ val PERMISSION_CALL_PHONE = Permission.NormalPermission( name = R.string.perm_call_phone, id = Manifest.permission.CALL_PHONE, ) +val PERMISSION_READ_CALENDAR = Permission.NormalPermission( + name = R.string.perm_read_calendar, + id = Manifest.permission.READ_CALENDAR, +) val PERMISSION_NOTIFICATION_LISTENER = Permission.SecurePermission( name = R.string.perm_notification_listener, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c1621e7f1..ce3f4ede5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -120,6 +120,7 @@ Do not play sound read your contacts directly call phone numbers + read your calendar %1$s — %2$s The skill \"%1$s\" needs these permissions to work: %2$s Could not evaluate your request @@ -157,6 +158,19 @@ %1$d hour %1$d minutes %1$d hour and %2$d minutes + %1$d events found + 1 event found + You have %2$d events on %1$s: %3$s + No events found on %1$s + No events found + all day + All day + at + On %1$s, you have %2$d events + On %1$s, you have 1 event + Here are the first %1$d + event + events Telephone Call Tom Timer diff --git a/app/src/main/sentences/en/calendar.yml b/app/src/main/sentences/en/calendar.yml index ff63a498a..9e8cb9bf9 100644 --- a/app/src/main/sentences/en/calendar.yml +++ b/app/src/main/sentences/en/calendar.yml @@ -4,4 +4,12 @@ create_event: - (set up|book) (a|an)? (meeting|appointment|event)? .title. ((on|at|for|starting)? .date_time.)? ((for|lasting)? .duration.)? - remind me (about|of|to)? .title. ((on|at|for|starting)? .date_time.)? ((for|lasting)? .duration.)? - (on|for) .date_time. (add|create|schedule|make) .title. (to|on)? calendar? - - (block|hold|reserve) .duration. (for)? .title. ((on|at|starting)? .date_time.)? \ No newline at end of file + - (block|hold|reserve) .duration. (for)? .title. ((on|at|starting)? .date_time.)? + +query_events: + - what (events|appointments|meetings|plans) (do i have|are (on|in) my? calendar|are scheduled) (today|tomorrow|.date_time.)? + - (what s|whats) on my? calendar (today|tomorrow|(for|on)? .date_time.)? + - (do i have|show me|list) (any|my)? (events|appointments|meetings|plans) (today|tomorrow|(for|on)? .date_time.)? + - (show|tell me|read) (me)? my? (calendar|schedule|agenda) ((for|on)? (today|tomorrow|.date_time.))? + - (am i|are we) (busy|free) (today|tomorrow|on .date_time.)? + - (what|anything) (is|s) (happening|scheduled|planned) (today|tomorrow|on .date_time.)? \ No newline at end of file diff --git a/app/src/main/sentences/skill_definitions.yml b/app/src/main/sentences/skill_definitions.yml index 7ce2e708b..d60ed7d9f 100644 --- a/app/src/main/sentences/skill_definitions.yml +++ b/app/src/main/sentences/skill_definitions.yml @@ -146,3 +146,7 @@ skills: type: string - id: duration type: duration + - id: query_events + captures: + - id: date_time + type: string From 7f37ffc2bed640d876619984b8a817b66549c773 Mon Sep 17 00:00:00 2001 From: Stypox Date: Thu, 26 Feb 2026 22:18:54 +0100 Subject: [PATCH 05/11] [Calendar] Completely restructure skill Make use of DateTime and Duration captures backed by dicio-numbers --- .../dicio/skills/calendar/CalendarEvent.kt | 66 +++- .../dicio/skills/calendar/CalendarOutput.kt | 357 ++++++++---------- .../dicio/skills/calendar/CalendarSkill.kt | 235 ++++-------- .../dicio/skills/calendar/CalendarUtil.kt | 55 +++ .../org/stypox/dicio/util/SkillContextExt.kt | 13 + app/src/main/res/values/strings.xml | 54 ++- app/src/main/sentences/en/calendar.yml | 21 +- app/src/main/sentences/skill_definitions.yml | 14 +- 8 files changed, 400 insertions(+), 415 deletions(-) create mode 100644 app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarUtil.kt diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarEvent.kt b/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarEvent.kt index 449ce1b49..a8bd86552 100644 --- a/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarEvent.kt +++ b/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarEvent.kt @@ -1,11 +1,67 @@ package org.stypox.dicio.skills.calendar +import java.time.LocalDate import java.time.LocalDateTime +import java.time.LocalTime +import org.dicio.skill.context.SkillContext +import org.stypox.dicio.R +import org.stypox.dicio.util.getString data class CalendarEvent( - val title: String, - val startDateTime: LocalDateTime, - val endDateTime: LocalDateTime, + val title: String?, + val begin: LocalDateTime?, + val end: LocalDateTime?, val location: String?, - val isAllDay: Boolean = false -) + private val isAllDay: Boolean, +) { + fun isAllDay(queryDate: LocalDate): Boolean { + if (isAllDay) { + return true + } + return begin?.isBefore(queryDate.atStartOfDay()) == true && + end?.isAfter(queryDate.atTime(LocalTime.MAX)) == true + } + + fun toSpeechString(ctx: SkillContext, queryDate: LocalDate): String { + val beginFormatted = begin?.toLocalTime() + ?.let { ctx.parserFormatter?.niceTime(it)?.get() } + + return if (title == null) { + if (location == null) { + if (isAllDay(queryDate)) { + ctx.getString(R.string.skill_calendar_unnamed_all_day) + } else if (beginFormatted == null) { + ctx.getString(R.string.skill_calendar_unnamed_unknown_time) + } else { + ctx.getString(R.string.skill_calendar_unnamed_begin, beginFormatted) + } + } else { + if (isAllDay(queryDate)) { + ctx.getString(R.string.skill_calendar_location_all_day, location) + } else if (beginFormatted == null) { + ctx.getString(R.string.skill_calendar_location_unknown_time, location) + } else { + ctx.getString(R.string.skill_calendar_location_begin, location, beginFormatted) + } + } + } else { + if (location == null) { + if (isAllDay(queryDate)) { + ctx.getString(R.string.skill_calendar_title_all_day, title) + } else if (beginFormatted == null) { + ctx.getString(R.string.skill_calendar_title_unknown_time, title) + } else { + ctx.getString(R.string.skill_calendar_title_begin, title, beginFormatted) + } + } else { + if (isAllDay(queryDate)) { + ctx.getString(R.string.skill_calendar_title_location_all_day, title, location) + } else if (beginFormatted == null) { + ctx.getString(R.string.skill_calendar_title_location_unknown_time, title, location) + } else { + ctx.getString(R.string.skill_calendar_title_location_begin, title, location, beginFormatted) + } + } + } + } +} diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarOutput.kt b/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarOutput.kt index e02a99895..a1eb1fa78 100644 --- a/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarOutput.kt +++ b/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarOutput.kt @@ -2,82 +2,101 @@ package org.stypox.dicio.skills.calendar import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import java.time.Duration +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.Locale import org.dicio.skill.context.SkillContext import org.dicio.skill.skill.SkillOutput import org.stypox.dicio.R -import org.stypox.dicio.io.graphical.Headline +import org.stypox.dicio.di.SkillContextImpl +import org.stypox.dicio.io.graphical.HeadlineSpeechSkillOutput +import org.stypox.dicio.util.StringUtils +import org.stypox.dicio.util.getPluralString import org.stypox.dicio.util.getString -import java.time.LocalDateTime -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle sealed interface CalendarOutput : SkillOutput { - data class Success( + data class Added( private val title: String, - private val startDateTime: LocalDateTime, - private val durationMillis: Long + private val begin: LocalDateTime, + private val end: LocalDateTime ) : CalendarOutput { override fun getSpeechOutput(ctx: SkillContext): String { - val formattedDateTime = ctx.parserFormatter?.niceDateTime(startDateTime)?.get() - ?: startDateTime.format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)) - - val durationHours = durationMillis / (60 * 60 * 1000) - val durationMinutes = (durationMillis % (60 * 60 * 1000)) / (60 * 1000) - - val durationText = when { - durationHours > 0 && durationMinutes > 0 -> - ctx.getString(R.string.skill_calendar_duration_hours_minutes, durationHours, durationMinutes) - durationHours > 0 -> - ctx.getString(R.string.skill_calendar_duration_hours, durationHours) - durationMinutes > 0 -> - ctx.getString(R.string.skill_calendar_duration_minutes, durationMinutes) - else -> - ctx.getString(R.string.skill_calendar_duration_hours, 1) + val duration = Duration.between(begin, end) + val beginText = ctx.parserFormatter!! + .niceDateTime(begin) + .get() + + return if (duration < Duration.ofHours(20)) { + val durationText = ctx.parserFormatter!! + .niceDuration(DicioNumbersDuration(duration)) + .speech(true) + .get() + ctx.getString(R.string.skill_calendar_adding_begin_duration, title, beginText, durationText) + } else { + val endText = ctx.parserFormatter!! + .niceDateTime(begin) + .get() + ctx.getString(R.string.skill_calendar_adding_begin_end, title, beginText, endText) } - - return ctx.getString(R.string.skill_calendar_success, title, formattedDateTime, durationText) } @Composable override fun GraphicalOutput(ctx: SkillContext) { + val dateRangeFormatted = remember { formatDateTimeRange(ctx, begin, end) } + val durationFormatted = remember { formatDuration(ctx, Duration.between(begin, end)) } + Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { Text( - text = ctx.getString(R.string.skill_calendar_event_added), - style = MaterialTheme.typography.headlineMedium, + text = ctx.getString(R.string.skill_calendar_app_was_instructed), + style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Normal), textAlign = TextAlign.Center ) - Spacer(modifier = Modifier.height(16.dp)) + + Spacer(modifier = Modifier.height(8.dp)) + Text( text = title, style = MaterialTheme.typography.titleLarge, textAlign = TextAlign.Center ) - Spacer(modifier = Modifier.height(8.dp)) + + Spacer(modifier = Modifier.height(3.dp)) + + Text( + text = dateRangeFormatted, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(2.dp)) + Text( - text = startDateTime.format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)), + text = stringResource(R.string.skill_calendar_duration, durationFormatted), style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center ) @@ -85,201 +104,131 @@ sealed interface CalendarOutput : SkillOutput { } } - data object NoTitle : CalendarOutput { + data object NoCalendarApp : CalendarOutput, HeadlineSpeechSkillOutput { override fun getSpeechOutput(ctx: SkillContext): String = - ctx.getString(R.string.skill_calendar_no_title) - - @Composable - override fun GraphicalOutput(ctx: SkillContext) { - Headline(text = getSpeechOutput(ctx)) - } + ctx.getString(R.string.skill_calendar_no_app) } data class EventsList( private val events: List, - private val queryDate: LocalDateTime + private val queryDate: LocalDate ) : CalendarOutput { override fun getSpeechOutput(ctx: SkillContext): String { - if (events.isEmpty()) { - return NoEvents(queryDate).getSpeechOutput(ctx) - } - - val formattedDate = ctx.parserFormatter?.niceDate(queryDate.toLocalDate())?.get() - ?: queryDate.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)) - - val maxEventsToRead = 5 - val eventsToRead = if (events.size > maxEventsToRead) events.take(maxEventsToRead) else events - - val eventList = eventsToRead.joinToString(", ") { event -> - if (event.isAllDay) { - "${event.title} (${ctx.getString(R.string.skill_calendar_all_day)})" - } else { - val time = ctx.parserFormatter?.niceTime(event.startDateTime.toLocalTime())?.get() - ?: event.startDateTime.format(DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)) - "${event.title} ${ctx.getString(R.string.skill_calendar_event_at)} $time" - } - } - - val prefix = if (events.size > maxEventsToRead) { - ctx.getString(R.string.skill_calendar_on_date_you_have_count, formattedDate, events.size) + ". " + - ctx.getString(R.string.skill_calendar_here_are_first, maxEventsToRead) + ": " - } else if (events.size == 1) { - ctx.getString(R.string.skill_calendar_on_date_you_have_one_event, formattedDate) + ": " + val formattedEvents = events + .take(MAX_EVENTS_TO_SPEAK) + .joinToString(", ") { event -> event.toSpeechString(ctx, queryDate) } + + return if (events.size <= MAX_EVENTS_TO_SPEAK) { + ctx.getPluralString( + resId = R.plurals.skill_calendar_on_date_you_have_count, + resIdIfZero = R.string.skill_calendar_on_date_you_have_count_zero, + quantity = events.size, + formattedEvents, + ) } else { - ctx.getString(R.string.skill_calendar_on_date_you_have_count, formattedDate, events.size) + ": " - } - - return prefix + eventList - } - - @Composable - override fun GraphicalOutput(ctx: SkillContext) { - if (events.isEmpty()) { - NoEvents(queryDate).GraphicalOutput(ctx) - return - } - - Column( - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - val dateStr = queryDate.format( - DateTimeFormatter.ofPattern("MMMM d, yyyy", java.util.Locale.getDefault()) - ) - - Text( - text = dateStr, - style = MaterialTheme.typography.titleLarge, - textAlign = TextAlign.Center - ) - - Text( - text = if (events.size == 1) - ctx.getString(R.string.skill_calendar_one_event_found) - else - ctx.getString(R.string.skill_calendar_events_found, events.size), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - // Show event summaries without dates - events.forEach { event -> - val displayText = if (event.isAllDay) { - "${event.title} (${ctx.getString(R.string.skill_calendar_all_day_capitalized)})" - } else { - val timeStr = event.startDateTime.format( - DateTimeFormatter.ofPattern("h:mma", java.util.Locale.getDefault()) - ).lowercase() - "${event.title} @ $timeStr" - } - - Text( - text = displayText, - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center - ) - } - } - - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .weight(1f) - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - items(events) { event -> - Card( - modifier = Modifier.fillMaxWidth(), - elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) - ) { - Column( - modifier = Modifier.padding(12.dp) - ) { - Text( - text = event.title, - style = MaterialTheme.typography.titleMedium - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = if (event.isAllDay) - ctx.getString(R.string.skill_calendar_all_day_capitalized) - else event.startDateTime.format( - DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) - ), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - if (event.location != null) { - Text( - text = event.location, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - } - } - } + ctx.getPluralString( + resId = R.plurals.skill_calendar_on_date_you_have_count_limited, + resIdIfZero = R.string.skill_calendar_on_date_you_have_count_zero, + quantity = events.size, + MAX_EVENTS_TO_SPEAK, + formattedEvents + ) } } - } - - data class NoEvents( - private val queryDate: LocalDateTime - ) : CalendarOutput { - override fun getSpeechOutput(ctx: SkillContext): String { - val formattedDate = ctx.parserFormatter?.niceDate(queryDate.toLocalDate())?.get() - ?: queryDate.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)) - return ctx.getString(R.string.skill_calendar_no_events, formattedDate) - } @Composable override fun GraphicalOutput(ctx: SkillContext) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) ) { - val dateStr = queryDate.format( - DateTimeFormatter.ofPattern("MMMM d, yyyy", java.util.Locale.getDefault()) - ) - Text( - text = dateStr, + text = queryDate.format( + DateTimeFormatter.ofPattern("MMMM d, yyyy", Locale.getDefault()) + ), style = MaterialTheme.typography.titleLarge, - textAlign = TextAlign.Center + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), ) + + Spacer(modifier = Modifier.height(4.dp)) + Text( - text = ctx.getString(R.string.skill_calendar_no_events_simple), + text = ctx.getPluralString( + resId = R.plurals.skill_calendar_events, + quantity = events.size, + resIdIfZero = R.string.skill_calendar_events_zero + ), style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurfaceVariant + modifier = Modifier.fillMaxWidth(), ) + + Spacer(modifier = Modifier.height(4.dp)) + + for (event in events) { + EventCard( + ctx = ctx, + event = event, + queryDate = queryDate, + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp) + ) + } } } - } - data object NoCalendarApp : CalendarOutput { - override fun getSpeechOutput(ctx: SkillContext): String = - ctx.getString(R.string.skill_calendar_no_app) - - @Composable - override fun GraphicalOutput(ctx: SkillContext) { - Headline(text = getSpeechOutput(ctx)) + companion object { + const val MAX_EVENTS_TO_SPEAK = 5 } } } + +@Composable +private fun EventCard( + ctx: SkillContext, + event: CalendarEvent, + queryDate: LocalDate, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier, + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Text( + text = StringUtils.joinNonBlank(event.title, event.location) + .takeIf(String::isNotEmpty) + ?: ctx.getString(R.string.skill_calendar_no_name), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(start = 11.dp, top = 8.dp, end = 11.dp, bottom = 2.dp) + ) + Text( + text = when { + event.isAllDay(queryDate) -> ctx.getString(R.string.skill_calendar_all_day) + event.begin != null && event.end != null -> formatDateTimeRange(ctx, event.begin, event.end) + event.end != null -> formatDateTime(event.end) + event.begin != null -> formatDateTime(event.begin) + else -> ctx.getString(R.string.skill_calendar_unknown_time) + }, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(start = 11.dp, end = 11.dp, bottom = 8.dp) + ) + } +} + +@Preview +@Composable +private fun EventCardPreview() { + EventCard( + ctx = SkillContextImpl.newForPreviews(LocalContext.current), + event = CalendarEvent( + title = "Meet with John", + begin = LocalDateTime.of(2026, 2, 26, 18, 0), + end = LocalDateTime.of(2026, 2, 26, 21, 0), + location = "Online", + isAllDay = false + ), + queryDate = LocalDate.of(2026, 2, 26), + ) +} diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarSkill.kt b/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarSkill.kt index 9bb00d347..0266d894f 100644 --- a/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarSkill.kt +++ b/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarSkill.kt @@ -1,17 +1,23 @@ package org.stypox.dicio.skills.calendar +import android.content.ActivityNotFoundException import android.content.Intent import android.provider.CalendarContract +import android.provider.CalendarContract.Instances as CCI +import android.util.Log +import java.time.Duration import org.dicio.skill.context.SkillContext import org.dicio.skill.skill.SkillInfo import org.dicio.skill.skill.SkillOutput import org.dicio.skill.standard.StandardRecognizerData import org.dicio.skill.standard.StandardRecognizerSkill import org.stypox.dicio.sentences.Sentences.Calendar -import java.time.Instant +import java.time.LocalDate import java.time.LocalDateTime import java.time.LocalTime -import java.time.ZoneId +import org.stypox.dicio.R +import org.stypox.dicio.util.getString + class CalendarSkill( correspondingSkillInfo: SkillInfo, @@ -20,186 +26,83 @@ class CalendarSkill( override suspend fun generateOutput(ctx: SkillContext, inputData: Calendar): SkillOutput { return when (inputData) { - is Calendar.CreateEvent -> createEvent(ctx, inputData) - is Calendar.QueryEvents -> queryEvents(ctx, inputData) + is Calendar.Create -> createEvent(ctx, inputData) + is Calendar.Query -> queryEvents(ctx, inputData) } } - private fun createEvent(ctx: SkillContext, inputData: Calendar.CreateEvent): SkillOutput { + private fun createEvent(ctx: SkillContext, inputData: Calendar.Create): SkillOutput { + // obtain capturing group data (note that we either have .end. or .duration., never both) val title = inputData.title - val dateTimeStr = inputData.dateTime - val durationInput = inputData.duration - - // Validate title - if (title.isNullOrBlank()) { - return CalendarOutput.NoTitle - } - - val npf = ctx.parserFormatter - var cleanTitle = title.trim() - .split(" ") - .joinToString(" ") { word -> word.replaceFirstChar { it.uppercase() } } - var extractedDateTime: LocalDateTime? = null - - // First, try to parse date/time from the explicit dateTimeStr capture - if (!dateTimeStr.isNullOrBlank() && npf != null) { - extractedDateTime = npf.extractDateTime(dateTimeStr) - .now(LocalDateTime.now()) - .preferMonthBeforeDay(false) - .first - } - - // If no date/time found in explicit capture, check if the title contains date/time info - if (extractedDateTime == null && npf != null) { - val mixedResult = npf.extractDateTime(cleanTitle) - .now(LocalDateTime.now()) - .preferMonthBeforeDay(false) - .mixedWithText - - // Check if we found a date/time and extract the first one - var foundDateTime = false - val titleParts = mutableListOf() - - for (item in mixedResult) { - when (item) { - is LocalDateTime -> { - if (!foundDateTime) { - extractedDateTime = item - foundDateTime = true - } - // Don't add LocalDateTime to titleParts - we're removing it - } - is String -> titleParts.add(item) - } - } - - // Only update the title if we actually found a date/time to remove - if (foundDateTime) { - val reconstructedTitle = titleParts.joinToString("").trim() - if (reconstructedTitle.isNotBlank()) { - cleanTitle = reconstructedTitle - } - } - } - - // Default to current time if still no date/time found - val startDateTime = extractedDateTime ?: LocalDateTime.now() - - // Parse duration or default to 1 hour - val durationMillis: Long = if (durationInput != null && npf != null) { - val parsedDuration = npf.extractDuration(durationInput) - .first?.toJavaDuration() - parsedDuration?.toMillis() ?: (60 * 60 * 1000L) // default 1 hour - } else { - 60 * 60 * 1000L // default 1 hour (in milliseconds) - } - - // Calculate start and end times in milliseconds - val startMillis = startDateTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() - val endMillis = startMillis + durationMillis - - // Create calendar intent + ?.trim() + ?.replaceFirstChar { it.titlecase(ctx.locale) } + ?: ctx.getString(R.string.skill_calendar_no_name) + val begin = inputData.begin + ?: LocalDateTime.now() + val end = inputData.end + ?: begin.plus(inputData.duration?.toJavaDuration() ?: Duration.ofHours(1)) + + // create calendar intent val calendarIntent = Intent(Intent.ACTION_INSERT).apply { - data = CalendarContract.Events.CONTENT_URI - putExtra(CalendarContract.Events.TITLE, cleanTitle) - putExtra(CalendarContract.EXTRA_EVENT_BEGIN_TIME, startMillis) - putExtra(CalendarContract.EXTRA_EVENT_END_TIME, endMillis) flags = Intent.FLAG_ACTIVITY_NEW_TASK + data = CalendarContract.Events.CONTENT_URI + putExtra(CalendarContract.Events.TITLE, title) + putExtra(CalendarContract.EXTRA_EVENT_BEGIN_TIME, begin.toEpochMilli()) + putExtra(CalendarContract.EXTRA_EVENT_END_TIME, end.toEpochMilli()) } - // Check if there's an app that can handle the calendar intent - val packageManager = ctx.android.packageManager - val canHandleIntent = calendarIntent.resolveActivity(packageManager) != null - - return if (canHandleIntent) { + // start activity + return try { ctx.android.startActivity(calendarIntent) - CalendarOutput.Success(cleanTitle, startDateTime, durationMillis) - } else { + CalendarOutput.Added(title, begin, end) + } catch (e: ActivityNotFoundException) { + Log.e(TAG, "Could not start calendar activity", e) CalendarOutput.NoCalendarApp } } - private fun queryEvents(ctx: SkillContext, inputData: Calendar.QueryEvents): SkillOutput { - val dateTimeStr = inputData.dateTime - val npf = ctx.parserFormatter - - // Parse the date/time or default to today - val parsedDateTime: LocalDateTime = if (!dateTimeStr.isNullOrBlank() && npf != null) { - npf.extractDateTime(dateTimeStr) - .now(LocalDateTime.now()) - .preferMonthBeforeDay(false) - .first ?: LocalDateTime.now() - } else { - LocalDateTime.now() - } - - // Set to start of day for the query (we only care about the date, not the time) - val startOfDay = parsedDateTime.toLocalDate().atStartOfDay() - val endOfDay = parsedDateTime.toLocalDate().atTime(LocalTime.MAX) - - // Convert to milliseconds for calendar query - val startMillis = startOfDay.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() - val endMillis = endOfDay.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() - - // Query calendar events using Instances table (handles recurring events properly) - val events = mutableListOf() - val contentResolver = ctx.android.contentResolver - - // Build the URI for querying instances in the time range - val instancesUri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply { - appendPath(startMillis.toString()) - appendPath(endMillis.toString()) - }.build() - - val projection = arrayOf( - CalendarContract.Instances.EVENT_ID, - CalendarContract.Instances.TITLE, - CalendarContract.Instances.BEGIN, - CalendarContract.Instances.END, - CalendarContract.Instances.EVENT_LOCATION, - CalendarContract.Instances.ALL_DAY - ) - - val sortOrder = "${CalendarContract.Instances.BEGIN} ASC" - - try { - contentResolver.query( - instancesUri, - projection, - null, // selection handled by URI - null, // selectionArgs handled by URI - sortOrder - )?.use { cursor -> - val titleIndex = cursor.getColumnIndex(CalendarContract.Instances.TITLE) - val startIndex = cursor.getColumnIndex(CalendarContract.Instances.BEGIN) - val endIndex = cursor.getColumnIndex(CalendarContract.Instances.END) - val locationIndex = cursor.getColumnIndex(CalendarContract.Instances.EVENT_LOCATION) - val allDayIndex = cursor.getColumnIndex(CalendarContract.Instances.ALL_DAY) - - while (cursor.moveToNext()) { - val title = if (titleIndex != -1) cursor.getString(titleIndex) ?: "Untitled" else "Untitled" - val startTimeMillis = if (startIndex != -1) cursor.getLong(startIndex) else continue - val endTimeMillis = if (endIndex != -1) cursor.getLong(endIndex) else startTimeMillis + (60 * 60 * 1000) - val location = if (locationIndex != -1) cursor.getString(locationIndex) else null - val isAllDay = if (allDayIndex != -1) cursor.getInt(allDayIndex) == 1 else false - - val startDateTime = LocalDateTime.ofInstant( - Instant.ofEpochMilli(startTimeMillis), - ZoneId.systemDefault() + private fun queryEvents(ctx: SkillContext, inputData: Calendar.Query): SkillOutput { + // we only care about the date, not the time + val date = inputData.`when`?.toLocalDate() ?: LocalDate.now() + val events = ArrayList() + + ctx.android.contentResolver.query( + // query all events from the beginning to the end of the day + CCI.CONTENT_URI.buildUpon() + .appendPath(date.atStartOfDay().toEpochMilli().toString()) + .appendPath(date.atTime(LocalTime.MAX).toEpochMilli().toString()) + .build(), + // we want to read these fields + arrayOf(CCI.TITLE, CCI.BEGIN, CCI.END, CCI.EVENT_LOCATION, CCI.ALL_DAY), + null, // selection handled by URI + null, // selectionArgs handled by URI + "${CCI.BEGIN} ASC" // order them by begin + )?.use { cursor -> + // use ...OrThrow() because all fields surely exist as we requested them in query() + val titleIndex = cursor.getColumnIndexOrThrow(CCI.TITLE) + val beginIndex = cursor.getColumnIndexOrThrow(CCI.BEGIN) + val endIndex = cursor.getColumnIndexOrThrow(CCI.END) + val locationIndex = cursor.getColumnIndexOrThrow(CCI.EVENT_LOCATION) + val allDayIndex = cursor.getColumnIndexOrThrow(CCI.ALL_DAY) + + // move through all rows returned by the query and read the fields + while (cursor.moveToNext()) { + events.add( + CalendarEvent( + title = cursor.getNonBlankStringOrNull(titleIndex), + begin = cursor.getDateTimeOrNull(beginIndex), + end = cursor.getDateTimeOrNull(endIndex), + location = cursor.getNonBlankStringOrNull(locationIndex), + isAllDay = cursor.getBooleanOrFalse(allDayIndex), ) - val endDateTime = LocalDateTime.ofInstant( - Instant.ofEpochMilli(endTimeMillis), - ZoneId.systemDefault() - ) - - events.add(CalendarEvent(title, startDateTime, endDateTime, location, isAllDay)) - } + ) } - } catch (e: Exception) { - // Handle permission or other errors gracefully - return CalendarOutput.NoEvents(startOfDay) } - return CalendarOutput.EventsList(events, startOfDay) + return CalendarOutput.EventsList(events, date) + } + + companion object { + private const val TAG: String = "CalendarSkill" } } diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarUtil.kt b/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarUtil.kt new file mode 100644 index 000000000..a6665d041 --- /dev/null +++ b/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarUtil.kt @@ -0,0 +1,55 @@ +package org.stypox.dicio.skills.calendar + +import android.database.Cursor +import android.text.format.DateUtils +import java.time.Duration +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import org.dicio.skill.context.SkillContext + +typealias DicioNumbersDuration = org.dicio.numbers.unit.Duration + +internal fun LocalDateTime.toEpochMilli(): Long { + return atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() +} + +internal fun formatDateTimeRange(ctx: SkillContext, begin: LocalDateTime, end: LocalDateTime): String { + return DateUtils.formatDateRange( + ctx.android, + begin.toEpochMilli(), + end.toEpochMilli(), + DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_SHOW_WEEKDAY or + DateUtils.FORMAT_SHOW_YEAR or DateUtils.FORMAT_SHOW_DATE or + DateUtils.FORMAT_ABBREV_ALL + ) +} + +internal fun formatDuration(ctx: SkillContext, duration: Duration): String { + return ctx.parserFormatter + ?.niceDuration(DicioNumbersDuration(duration)) + ?.speech(false) + ?.get() + ?: DateUtils.formatElapsedTime(duration.toSeconds()) +} + +internal fun formatDateTime(date: LocalDateTime): String { + return date.format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG)) +} + +// some simple helper functions to work with Cursor... +internal fun Cursor.getNonBlankStringOrNull(index: Int): String? { + return index.takeUnless(this::isNull)?.let(this::getString)?.takeUnless(String::isBlank) +} + +internal fun Cursor.getDateTimeOrNull(index: Int): LocalDateTime? { + val millis = index.takeUnless(this::isNull)?.let(this::getLong) ?: return null + return LocalDateTime.ofInstant(Instant.ofEpochMilli(millis), ZoneOffset.UTC) +} + +internal fun Cursor.getBooleanOrFalse(index: Int): Boolean { + return index.takeUnless(this::isNull)?.let(this::getInt) == 1 +} diff --git a/app/src/main/kotlin/org/stypox/dicio/util/SkillContextExt.kt b/app/src/main/kotlin/org/stypox/dicio/util/SkillContextExt.kt index 99efd1064..849521b3c 100644 --- a/app/src/main/kotlin/org/stypox/dicio/util/SkillContextExt.kt +++ b/app/src/main/kotlin/org/stypox/dicio/util/SkillContextExt.kt @@ -1,5 +1,6 @@ package org.stypox.dicio.util +import androidx.annotation.PluralsRes import androidx.annotation.StringRes import org.dicio.skill.context.SkillContext @@ -10,3 +11,15 @@ fun SkillContext.getString(@StringRes resId: Int): String { fun SkillContext.getString(@StringRes resId: Int, vararg formatArgs: Any?): String { return this.android.getString(resId, *formatArgs) } + +fun SkillContext.getPluralString( + @PluralsRes resId: Int, + @StringRes resIdIfZero: Int, + quantity: Int, + vararg otherFormatArgs: Any?, +): String { + if (quantity == 0 && resIdIfZero != 0) { + return this.android.getString(resIdIfZero, quantity, *otherFormatArgs) + } + return this.android.resources.getQuantityString(resId, quantity, quantity, *otherFormatArgs) +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ce3f4ede5..b002b1527 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -151,26 +151,40 @@ Navigating to %1$s Calendar Add dentist appointment tomorrow at 3pm - Adding %1$s to your calendar on %2$s for %3$s - Event added to calendar - Please specify an event title - No calendar app found. Please install a calendar app to add events - %1$d hour - %1$d minutes - %1$d hour and %2$d minutes - %1$d events found - 1 event found - You have %2$d events on %1$s: %3$s - No events found on %1$s - No events found - all day - All day - at - On %1$s, you have %2$d events - On %1$s, you have 1 event - Here are the first %1$d - event - events + Adding %1$s on %2$s lasting %3$s + Adding %1$s from %2$s to %3$s + Your calendar app was instructed to add this event: + Duration: %1$s + No calendar app found, please install a calendar app to add events + %1$s for all day + %1$s in %2$s for all day + in %1$s for all day + unnamed event for all day + %1$s at %2$s + %1$s in %2$s at %3$s + in %1$s at %2$s + unnamed event at %1$s + %1$s + %1$s in %2$s + in %1$s + unnamed event + You have nothing scheduled + + You have %1$d event: %2$s + You have %1$d events: %2$s + + + You have %1$d event; the first %2$d are: %3$s + You have %1$d events; the first %2$d are: %3$s + + No events found + + %1$d event + %1$d events + + All day + No name + Unknown time Telephone Call Tom Timer diff --git a/app/src/main/sentences/en/calendar.yml b/app/src/main/sentences/en/calendar.yml index 9e8cb9bf9..f11145862 100644 --- a/app/src/main/sentences/en/calendar.yml +++ b/app/src/main/sentences/en/calendar.yml @@ -1,15 +1,8 @@ -create_event: - - (add|create|schedule|make|put) .title. (to|on|in my?)? calendar? ((on|at|for|starting)? .date_time.)? ((for|lasting)? .duration.)? - - (add|create|schedule|make) (a|an)? (calendar|new)? event (called|named|for)? .title. ((on|at|for|starting)? .date_time.)? ((for|lasting)? .duration.)? - - (set up|book) (a|an)? (meeting|appointment|event)? .title. ((on|at|for|starting)? .date_time.)? ((for|lasting)? .duration.)? - - remind me (about|of|to)? .title. ((on|at|for|starting)? .date_time.)? ((for|lasting)? .duration.)? - - (on|for) .date_time. (add|create|schedule|make) .title. (to|on)? calendar? - - (block|hold|reserve) .duration. (for)? .title. ((on|at|starting)? .date_time.)? +create: + - (add|create|schedule|make|put|(arrange for?)|book|setup|(set up) ((a|an|the event|appointment|meeting called|named|(with the name)|for?)? .title. to|on|in my|the? calendar|agenda|schedule|timetable)|(a|an|the event|appointment|meeting called|named|(with the name)|for? .title.))|(schedule|(reserve a slot for)|(remind me about|of|to?) .title.) (starting|beginning|(that begin) on|at|for|from? the?)|(on|at|for|from the?) .begin. and? (for|lasting|(with duration)|(that last)? .duration.)|(that is .duration. long)|(finishing|(that finish)|ending|(that end)|(lasting|(that lasts)? until)|to .end.) + - block|hold|reserve|book .duration. about|of|to|for? .title. (starting|beginning|(that begin) on|at|for|from? the?)|(on|at|for|from the?) .begin. -query_events: - - what (events|appointments|meetings|plans) (do i have|are (on|in) my? calendar|are scheduled) (today|tomorrow|.date_time.)? - - (what s|whats) on my? calendar (today|tomorrow|(for|on)? .date_time.)? - - (do i have|show me|list) (any|my)? (events|appointments|meetings|plans) (today|tomorrow|(for|on)? .date_time.)? - - (show|tell me|read) (me)? my? (calendar|schedule|agenda) ((for|on)? (today|tomorrow|.date_time.))? - - (am i|are we) (busy|free) (today|tomorrow|on .date_time.)? - - (what|anything) (is|s) (happening|scheduled|planned) (today|tomorrow|on .date_time.)? \ No newline at end of file +query: + - (what event|appointment|meeting|plan? (do i have)|(are|is there?))|((do i have)|(are|is there?)|(show|tell|list|read me?)? any|my|the|an? event|appointment|meeting|plan (i have)?) (on|in|inside my|the? calendar|agenda|schedule|timetable)|scheduled|arranged|happening|planned? (on|at|for? the? .when.)? (on|in|inside my|the? calendar|agenda|schedule|timetable)? + - (show|tell|list|read me?)|((do i have)|(is there?) anything on|in|inside) the|my? calendar|agenda|schedule|timetable (on|at|for? the? .when.)? + - ((am i)|(are we) busy|free|occupied)|(do i have free? time) (on|at|for? the? .when.)? diff --git a/app/src/main/sentences/skill_definitions.yml b/app/src/main/sentences/skill_definitions.yml index d60ed7d9f..7d1b12e8b 100644 --- a/app/src/main/sentences/skill_definitions.yml +++ b/app/src/main/sentences/skill_definitions.yml @@ -138,15 +138,17 @@ skills: - id: calendar specificity: high sentences: - - id: create_event + - id: create captures: - id: title type: string - - id: date_time - type: string + - id: begin + type: date_time + - id: end + type: date_time - id: duration type: duration - - id: query_events + - id: query captures: - - id: date_time - type: string + - id: when + type: date_time From a9a791622606b60a96d9e38e7e0599a0733668ff Mon Sep 17 00:00:00 2001 From: Stypox Date: Thu, 26 Feb 2026 22:34:36 +0100 Subject: [PATCH 06/11] [Calendar] Allow to click on events --- .../dicio/skills/calendar/CalendarEvent.kt | 1 + .../dicio/skills/calendar/CalendarOutput.kt | 17 ++++++++++++++++- .../dicio/skills/calendar/CalendarSkill.kt | 4 +++- .../dicio/skills/calendar/CalendarUtil.kt | 6 +++++- 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarEvent.kt b/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarEvent.kt index a8bd86552..c95daea98 100644 --- a/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarEvent.kt +++ b/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarEvent.kt @@ -8,6 +8,7 @@ import org.stypox.dicio.R import org.stypox.dicio.util.getString data class CalendarEvent( + val id: Long?, val title: String?, val begin: LocalDateTime?, val end: LocalDateTime?, diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarOutput.kt b/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarOutput.kt index a1eb1fa78..db16b39a9 100644 --- a/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarOutput.kt +++ b/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarOutput.kt @@ -1,5 +1,8 @@ package org.stypox.dicio.skills.calendar +import android.content.ContentUris +import android.content.Intent +import android.provider.CalendarContract import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -194,6 +197,17 @@ private fun EventCard( ) { Card( modifier = modifier, + onClick = { + if (event.id == null) { + return@Card + } + // open the full event description in the calendar app + val intent = Intent(Intent.ACTION_VIEW).apply { + data = ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, event.id!!) + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + ctx.android.startActivity(intent) + }, elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) ) { Text( @@ -223,11 +237,12 @@ private fun EventCardPreview() { EventCard( ctx = SkillContextImpl.newForPreviews(LocalContext.current), event = CalendarEvent( + id = null, title = "Meet with John", begin = LocalDateTime.of(2026, 2, 26, 18, 0), end = LocalDateTime.of(2026, 2, 26, 21, 0), location = "Online", - isAllDay = false + isAllDay = false, ), queryDate = LocalDate.of(2026, 2, 26), ) diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarSkill.kt b/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarSkill.kt index 0266d894f..a1ea619c5 100644 --- a/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarSkill.kt +++ b/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarSkill.kt @@ -73,12 +73,13 @@ class CalendarSkill( .appendPath(date.atTime(LocalTime.MAX).toEpochMilli().toString()) .build(), // we want to read these fields - arrayOf(CCI.TITLE, CCI.BEGIN, CCI.END, CCI.EVENT_LOCATION, CCI.ALL_DAY), + arrayOf(CCI.EVENT_ID, CCI.TITLE, CCI.BEGIN, CCI.END, CCI.EVENT_LOCATION, CCI.ALL_DAY), null, // selection handled by URI null, // selectionArgs handled by URI "${CCI.BEGIN} ASC" // order them by begin )?.use { cursor -> // use ...OrThrow() because all fields surely exist as we requested them in query() + val eventIdIndex = cursor.getColumnIndexOrThrow(CCI.EVENT_ID) val titleIndex = cursor.getColumnIndexOrThrow(CCI.TITLE) val beginIndex = cursor.getColumnIndexOrThrow(CCI.BEGIN) val endIndex = cursor.getColumnIndexOrThrow(CCI.END) @@ -89,6 +90,7 @@ class CalendarSkill( while (cursor.moveToNext()) { events.add( CalendarEvent( + id = cursor.getLongOrNull(eventIdIndex), title = cursor.getNonBlankStringOrNull(titleIndex), begin = cursor.getDateTimeOrNull(beginIndex), end = cursor.getDateTimeOrNull(endIndex), diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarUtil.kt b/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarUtil.kt index a6665d041..763cd7284 100644 --- a/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarUtil.kt +++ b/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarUtil.kt @@ -45,8 +45,12 @@ internal fun Cursor.getNonBlankStringOrNull(index: Int): String? { return index.takeUnless(this::isNull)?.let(this::getString)?.takeUnless(String::isBlank) } +internal fun Cursor.getLongOrNull(index: Int): Long? { + return index.takeUnless(this::isNull)?.let(this::getLong) +} + internal fun Cursor.getDateTimeOrNull(index: Int): LocalDateTime? { - val millis = index.takeUnless(this::isNull)?.let(this::getLong) ?: return null + val millis = getLongOrNull(index) ?: return null return LocalDateTime.ofInstant(Instant.ofEpochMilli(millis), ZoneOffset.UTC) } From 405cad63abd7540aaf8cb32abf1354cb845b7fb0 Mon Sep 17 00:00:00 2001 From: Stypox Date: Thu, 26 Feb 2026 22:37:46 +0100 Subject: [PATCH 07/11] [Calendar] Swap begin and end if invalid range --- .../org/stypox/dicio/skills/calendar/CalendarSkill.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarSkill.kt b/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarSkill.kt index a1ea619c5..1cbe1c6f7 100644 --- a/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarSkill.kt +++ b/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarSkill.kt @@ -37,10 +37,15 @@ class CalendarSkill( ?.trim() ?.replaceFirstChar { it.titlecase(ctx.locale) } ?: ctx.getString(R.string.skill_calendar_no_name) - val begin = inputData.begin + var begin = inputData.begin ?: LocalDateTime.now() - val end = inputData.end + var end = inputData.end ?: begin.plus(inputData.duration?.toJavaDuration() ?: Duration.ofHours(1)) + if (begin.isAfter(end)) { + val tmpBegin = begin + begin = end + end = tmpBegin + } // create calendar intent val calendarIntent = Intent(Intent.ACTION_INSERT).apply { From 619e2e7ebc96572db1e85b08f25cbf0c7f38609a Mon Sep 17 00:00:00 2001 From: Stypox Date: Thu, 26 Feb 2026 22:38:36 +0100 Subject: [PATCH 08/11] [Calendar] Default to zero-length event if end is missing --- .../kotlin/org/stypox/dicio/skills/calendar/CalendarSkill.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarSkill.kt b/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarSkill.kt index 1cbe1c6f7..6c529e2ad 100644 --- a/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarSkill.kt +++ b/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarSkill.kt @@ -40,7 +40,7 @@ class CalendarSkill( var begin = inputData.begin ?: LocalDateTime.now() var end = inputData.end - ?: begin.plus(inputData.duration?.toJavaDuration() ?: Duration.ofHours(1)) + ?: begin.plus(inputData.duration?.toJavaDuration() ?: Duration.ZERO) if (begin.isAfter(end)) { val tmpBegin = begin begin = end From 8d98df6d1314e97e64362bdcc596e0bd5f2c29b0 Mon Sep 17 00:00:00 2001 From: Stypox Date: Thu, 26 Feb 2026 23:57:22 +0100 Subject: [PATCH 09/11] [Calendar] Do not show duration if zero --- .../dicio/skills/calendar/CalendarOutput.kt | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarOutput.kt b/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarOutput.kt index db16b39a9..365691a3e 100644 --- a/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarOutput.kt +++ b/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarOutput.kt @@ -37,6 +37,7 @@ import org.stypox.dicio.util.StringUtils import org.stypox.dicio.util.getPluralString import org.stypox.dicio.util.getString +// TODO remind me about whatever tomorrow at nine is misinterpreted sealed interface CalendarOutput : SkillOutput { data class Added( @@ -67,7 +68,8 @@ sealed interface CalendarOutput : SkillOutput { @Composable override fun GraphicalOutput(ctx: SkillContext) { val dateRangeFormatted = remember { formatDateTimeRange(ctx, begin, end) } - val durationFormatted = remember { formatDuration(ctx, Duration.between(begin, end)) } + val duration = remember { Duration.between(begin, end) } + val durationFormatted = remember(duration) { formatDuration(ctx, duration) } Column( modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), @@ -96,13 +98,15 @@ sealed interface CalendarOutput : SkillOutput { textAlign = TextAlign.Center ) - Spacer(modifier = Modifier.height(2.dp)) + if (duration >= Duration.ofSeconds(1)) { + Spacer(modifier = Modifier.height(2.dp)) - Text( - text = stringResource(R.string.skill_calendar_duration, durationFormatted), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center - ) + Text( + text = stringResource(R.string.skill_calendar_duration, durationFormatted), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + } } } } From 32c2257b6846c7867a87585648bfc2ebaeca257c Mon Sep 17 00:00:00 2001 From: Stypox Date: Fri, 27 Feb 2026 00:05:10 +0100 Subject: [PATCH 10/11] [Calendar] Fix begin repeated twice --- .../kotlin/org/stypox/dicio/skills/calendar/CalendarOutput.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarOutput.kt b/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarOutput.kt index 365691a3e..99e03a62f 100644 --- a/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarOutput.kt +++ b/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarOutput.kt @@ -59,7 +59,7 @@ sealed interface CalendarOutput : SkillOutput { ctx.getString(R.string.skill_calendar_adding_begin_duration, title, beginText, durationText) } else { val endText = ctx.parserFormatter!! - .niceDateTime(begin) + .niceDateTime(end) .get() ctx.getString(R.string.skill_calendar_adding_begin_end, title, beginText, endText) } From 647d7f7c9c16d900c157eb1b5c02f7a9c5314007 Mon Sep 17 00:00:00 2001 From: Stypox Date: Fri, 27 Feb 2026 00:05:46 +0100 Subject: [PATCH 11/11] [Calendar] Add italian sentences --- app/src/main/sentences/en/calendar.yml | 8 ++++---- app/src/main/sentences/it/calendar.yml | 8 ++++++++ 2 files changed, 12 insertions(+), 4 deletions(-) create mode 100644 app/src/main/sentences/it/calendar.yml diff --git a/app/src/main/sentences/en/calendar.yml b/app/src/main/sentences/en/calendar.yml index f11145862..733597d96 100644 --- a/app/src/main/sentences/en/calendar.yml +++ b/app/src/main/sentences/en/calendar.yml @@ -1,8 +1,8 @@ create: - - (add|create|schedule|make|put|(arrange for?)|book|setup|(set up) ((a|an|the event|appointment|meeting called|named|(with the name)|for?)? .title. to|on|in my|the? calendar|agenda|schedule|timetable)|(a|an|the event|appointment|meeting called|named|(with the name)|for? .title.))|(schedule|(reserve a slot for)|(remind me about|of|to?) .title.) (starting|beginning|(that begin) on|at|for|from? the?)|(on|at|for|from the?) .begin. and? (for|lasting|(with duration)|(that last)? .duration.)|(that is .duration. long)|(finishing|(that finish)|ending|(that end)|(lasting|(that lasts)? until)|to .end.) + - (add|create|schedule|make|put|(arrange for?)|book|setup|(set up) ((a|an|the event|appointment|meeting called|named|(with the name)|for?)? .title. to|on|in my|the? calendar|agenda|schedule|timetable)|(a|an|the event|appointment|meeting called|named|(with the name)|for? .title.))|(schedule|(reserve a time? slot for)|(remind me about|of|to?) .title.) (starting|beginning|(that begin) on|at|for|from? the?)|(on|at|for|from the?)? .begin. and? (for|lasting|(with duration)|(that last)? .duration.)|(that is .duration. long)|(finishing|(that finish)|ending|(that end)|(lasting|(that lasts)? until)|to .end.)? - block|hold|reserve|book .duration. about|of|to|for? .title. (starting|beginning|(that begin) on|at|for|from? the?)|(on|at|for|from the?) .begin. query: - - (what event|appointment|meeting|plan? (do i have)|(are|is there?))|((do i have)|(are|is there?)|(show|tell|list|read me?)? any|my|the|an? event|appointment|meeting|plan (i have)?) (on|in|inside my|the? calendar|agenda|schedule|timetable)|scheduled|arranged|happening|planned? (on|at|for? the? .when.)? (on|in|inside my|the? calendar|agenda|schedule|timetable)? - - (show|tell|list|read me?)|((do i have)|(is there?) anything on|in|inside) the|my? calendar|agenda|schedule|timetable (on|at|for? the? .when.)? - - ((am i)|(are we) busy|free|occupied)|(do i have free? time) (on|at|for? the? .when.)? + - (what event|appointment|meeting|plan? (do|will|did i have)|(are|is|was|(will be) there?))|((do|will|did i have)|(are|is|was|(will be) there?)|(show|tell|list|read me?)? any|my|the|an? event|appointment|meeting|plan (i have)?) (on|in|inside my|the? calendar|agenda|schedule|timetable)|scheduled|arranged|happening|planned? (on|at|for? the? .when.)? (on|in|inside my|the? calendar|agenda|schedule|timetable)? + - (show|tell|list|read me?)|((do|will|did i have)|(is|was there? be?) anything on|in|inside) the|my? calendar|agenda|schedule|timetable (on|at|for? the? .when.)? + - ((am i)|(was i)|(will i be) busy|free|occupied)|(do|will|did i have free? time) (on|at|for? the? .when.)? diff --git a/app/src/main/sentences/it/calendar.yml b/app/src/main/sentences/it/calendar.yml new file mode 100644 index 000000000..54ff292a6 --- /dev/null +++ b/app/src/main/sentences/it/calendar.yml @@ -0,0 +1,8 @@ +create: + - (aggiungi|crea|pianifica|imposta|metti|fissa|programma|calendarizza ((un|uno|una|il|lo|la|l|i|gli|le event|appuntamento|incontro|meeting chiamat|denominat|(con il nome)|(di nome)|di|del|(per|con un|uno|una|il|lo|la|l|i|gli|le?)?)? .title. a|su|ne mi? calendari|agend|diari)|(un|uno|una|il|lo|la|l|i|gli|le evento|appuntamento|incontro|meeting chiamat|denominat|(con il nome)|(di nome)|di|del|(per|con un|uno|una|il|lo|la|l|i|gli|le?)? .title.))|((riserva|blocca|tieni un|uno|una buco|spazio|(fascia oraria) per)|(ricorda di?) .title.) (che? comincia|inizia|part a?)|(a partire da)|a? .begin. e? ((di durata?)|(che dura)|(con una durata di)|lung .duration.)|((((che dura|arriva)? fino)|(che? finisc)|(con fine|finale) a?)|a .end.) + - riserva|blocca|tieni (un|uno|una buco|spazio|(fascia oraria) di)? .duration. di|del|(per|con il|lo|la|l|i|gli|le?) .title. (che? comincia|inizia|part a?)|(a partire da)|a? .begin. + +query: + - (che|qual event|appuntament|incontr|meeting|pian|programm (ho avuto?)|avro|avevo|(c (sono|e stat)|sara|era))|(mostra|di|leggi|(ho avuto?)|avro|avevo|(c (sono|e stat)|sara|era) un|uno|una|il|lo|la|l|i|gli|le|de event|appuntament|incontr|meeting|pian|programm) programmat|(in programma)? (su|ne mi? calendari|agend|diari)? ((per (il giorno?)?)|il|a .when.)? (su|ne mi? calendari|agend|diari)? + - mostra|di|leggi|((ho avuto?)|avro|avevo|(c (sono|e stat)|sara|era) qualcosa) su|ne mi? calendari|agend|diari ((per (il giorno?)?)|il|a .when.)? + - (sono|saro|ero occupato|preso|libero)|(ho|avevo|avro tempo libero|(a disposizione)?)((per? (il giorno?)?)|il|a .when.)?