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 new file mode 100644 index 00000000..e02a9989 --- /dev/null +++ b/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarOutput.kt @@ -0,0 +1,285 @@ +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.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.io.graphical.Headline +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) { + 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() + ) { + 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 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), + 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 = ctx.getString(R.string.skill_calendar_no_events_simple), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + 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 new file mode 100644 index 00000000..9bb00d34 --- /dev/null +++ b/app/src/main/kotlin/org/stypox/dicio/skills/calendar/CalendarSkill.kt @@ -0,0 +1,205 @@ +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.Instant +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.ZoneId + +class CalendarSkill( + correspondingSkillInfo: SkillInfo, + data: StandardRecognizerData +) : StandardRecognizerSkill(correspondingSkillInfo, data) { + + override suspend fun generateOutput(ctx: SkillContext, inputData: Calendar): SkillOutput { + 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()) { + 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 + 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 + } + } + + 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 8230af59..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 @@ -146,6 +147,28 @@ 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 + %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 new file mode 100644 index 00000000..9e8cb9bf --- /dev/null +++ b/app/src/main/sentences/en/calendar.yml @@ -0,0 +1,15 @@ +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.)? + +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 7aa23f72..70dc63ea 100644 --- a/app/src/main/sentences/skill_definitions.yml +++ b/app/src/main/sentences/skill_definitions.yml @@ -123,3 +123,19 @@ 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 + - id: query_events + captures: + - id: date_time + type: string