From 3beaea70c876878296a5440ea5554bd382ced4be 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 1/4] 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 5c276aeb..e80703d0 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 @@ -48,6 +49,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 00000000..a824ed90 --- /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 00000000..c6e6f43c --- /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 00000000..ee599a41 --- /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 8230af59..48f04a78 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -146,6 +146,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 00000000..9d1c8fbf --- /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 7aa23f72..a706fa1f 100644 --- a/app/src/main/sentences/skill_definitions.yml +++ b/app/src/main/sentences/skill_definitions.yml @@ -123,3 +123,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 4f4128d848bbe72c130c1f2c7b9c402d09cc74b6 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 2/4] 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 ee599a41..112c4736 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 9d1c8fbf..3c02bc57 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 6a1949ed90bc83273f5b73e2d77ec953619e08c1 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 3/4] 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 3c02bc57..ff63a498 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 b7bf286f143be800c4bc32431c408b214ada2cf2 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 4/4] 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 6355382b..c87b6173 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -26,6 +26,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 c6e6f43c..e02a9989 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 112c4736..9bb00d34 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 f06456cd..ff5bdab1 100644 --- a/app/src/main/kotlin/org/stypox/dicio/util/PermissionUtils.kt +++ b/app/src/main/kotlin/org/stypox/dicio/util/PermissionUtils.kt @@ -27,6 +27,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, +) /** * @param context the Android context diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 48f04a78..e55ea35a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -118,6 +118,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 @@ -155,6 +156,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 ff63a498..9e8cb9bf 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 a706fa1f..70dc63ea 100644 --- a/app/src/main/sentences/skill_definitions.yml +++ b/app/src/main/sentences/skill_definitions.yml @@ -135,3 +135,7 @@ skills: type: string - id: duration type: duration + - id: query_events + captures: + - id: date_time + type: string