-
-
Notifications
You must be signed in to change notification settings - Fork 139
[Reminder] Add reminder skill with Tasks.org integration #391
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,108 @@ | ||
| package org.stypox.dicio.skills.reminder | ||
|
|
||
| import android.content.Context | ||
| import androidx.compose.foundation.layout.Column | ||
| import androidx.compose.material.icons.Icons | ||
| import androidx.compose.material.icons.filled.Notifications | ||
| import androidx.compose.runtime.Composable | ||
| import androidx.compose.runtime.collectAsState | ||
| import androidx.compose.runtime.getValue | ||
| import androidx.compose.runtime.rememberCoroutineScope | ||
| import androidx.compose.ui.graphics.vector.rememberVectorPainter | ||
| import androidx.compose.ui.platform.LocalContext | ||
| import androidx.compose.ui.res.stringResource | ||
| import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler | ||
| import androidx.datastore.dataStore | ||
| import kotlinx.coroutines.launch | ||
| import org.dicio.skill.context.SkillContext | ||
| import org.dicio.skill.skill.Skill | ||
| import org.dicio.skill.skill.SkillInfo | ||
| import org.stypox.dicio.R | ||
| import org.stypox.dicio.sentences.Sentences | ||
| import org.stypox.dicio.settings.ui.BooleanSetting | ||
| import org.stypox.dicio.settings.ui.ListSetting | ||
|
|
||
| object ReminderInfo : SkillInfo("reminder") { | ||
| override fun name(context: Context) = | ||
| context.getString(R.string.skill_name_reminder) | ||
|
|
||
| override fun sentenceExample(context: Context) = | ||
| context.getString(R.string.skill_sentence_example_reminder) | ||
|
|
||
| @Composable | ||
| override fun icon() = | ||
| rememberVectorPainter(Icons.Default.Notifications) | ||
|
|
||
| override fun isAvailable(ctx: SkillContext): Boolean { | ||
| return Sentences.Reminder[ctx.sentencesLanguage] != null | ||
| } | ||
|
|
||
| override fun build(ctx: SkillContext): Skill<*> { | ||
| return ReminderSkill(ReminderInfo, Sentences.Reminder[ctx.sentencesLanguage]!!) | ||
| } | ||
|
|
||
| internal val Context.reminderDataStore by dataStore( | ||
| fileName = "skill_settings_reminder.pb", | ||
| serializer = SkillSettingsReminderSerializer, | ||
| corruptionHandler = ReplaceFileCorruptionHandler { | ||
| SkillSettingsReminderSerializer.defaultValue | ||
| }, | ||
| ) | ||
|
|
||
| override val renderSettings: @Composable () -> Unit get() = @Composable { | ||
| val dataStore = LocalContext.current.reminderDataStore | ||
| val data by dataStore.data.collectAsState(SkillSettingsReminderSerializer.defaultValue) | ||
| val scope = rememberCoroutineScope() | ||
|
|
||
| Column { | ||
| ListSetting( | ||
| title = stringResource(R.string.pref_reminder_default_priority), | ||
| possibleValues = listOf( | ||
| ListSetting.Value( | ||
| value = ReminderPriority.REMINDER_PRIORITY_HIGH, | ||
| name = stringResource(R.string.pref_reminder_priority_high), | ||
| ), | ||
| ListSetting.Value( | ||
| value = ReminderPriority.REMINDER_PRIORITY_MEDIUM, | ||
| name = stringResource(R.string.pref_reminder_priority_medium), | ||
| ), | ||
| ListSetting.Value( | ||
| value = ReminderPriority.REMINDER_PRIORITY_LOW, | ||
| name = stringResource(R.string.pref_reminder_priority_low), | ||
| ), | ||
| ListSetting.Value( | ||
| value = ReminderPriority.REMINDER_PRIORITY_NONE, | ||
| name = stringResource(R.string.pref_reminder_priority_none), | ||
| ), | ||
| ), | ||
| ).Render( | ||
| value = when (val priority = data.defaultPriority) { | ||
| ReminderPriority.UNRECOGNIZED -> ReminderPriority.REMINDER_PRIORITY_MEDIUM | ||
| else -> priority | ||
| }, | ||
| onValueChange = { priority -> | ||
| scope.launch { | ||
| dataStore.updateData { | ||
| it.toBuilder().setDefaultPriority(priority).build() | ||
| } | ||
| } | ||
| }, | ||
| ) | ||
|
|
||
| BooleanSetting( | ||
| title = stringResource(R.string.pref_reminder_save_voice_input), | ||
| descriptionOff = stringResource(R.string.pref_reminder_save_voice_input_off), | ||
| descriptionOn = stringResource(R.string.pref_reminder_save_voice_input_on), | ||
| ).Render( | ||
| value = data.saveVoiceInput, | ||
| onValueChange = { save -> | ||
| scope.launch { | ||
| dataStore.updateData { | ||
| it.toBuilder().setSaveVoiceInput(save).build() | ||
| } | ||
| } | ||
| }, | ||
| ) | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| package org.stypox.dicio.skills.reminder | ||
|
|
||
| import android.content.Intent | ||
| import android.net.Uri | ||
| import androidx.compose.foundation.layout.Column | ||
| import androidx.compose.foundation.layout.Spacer | ||
| import androidx.compose.foundation.layout.height | ||
| import androidx.compose.material3.MaterialTheme | ||
| import androidx.compose.material3.Text | ||
| import androidx.compose.material3.TextButton | ||
| import androidx.compose.runtime.Composable | ||
| import androidx.compose.ui.Modifier | ||
| 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.io.graphical.HeadlineSpeechSkillOutput | ||
| import org.stypox.dicio.util.getString | ||
| import java.time.LocalDateTime | ||
| import java.time.format.DateTimeFormatter | ||
| import java.time.format.FormatStyle | ||
|
|
||
| sealed interface ReminderOutput : SkillOutput { | ||
|
|
||
| class Created( | ||
| private val title: String, | ||
| private val dateTime: LocalDateTime?, | ||
| ) : ReminderOutput, HeadlineSpeechSkillOutput { | ||
| override fun getSpeechOutput(ctx: SkillContext): String { | ||
| if (title.isBlank()) { | ||
| return ctx.getString(R.string.skill_reminder_no_task) | ||
| } | ||
| return if (dateTime != null) { | ||
| val formatted = dateTime.format( | ||
| DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT) | ||
| ) | ||
| ctx.getString(R.string.skill_reminder_created_with_date, title, formatted) | ||
| } else { | ||
| ctx.getString(R.string.skill_reminder_created, title) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| data object TasksNotInstalled : ReminderOutput { | ||
| override fun getSpeechOutput(ctx: SkillContext): String = | ||
| ctx.getString(R.string.skill_reminder_tasks_not_installed) | ||
|
|
||
| @Composable | ||
| override fun GraphicalOutput(ctx: SkillContext) { | ||
| Column { | ||
| Headline(text = getSpeechOutput(ctx)) | ||
| Spacer(modifier = Modifier.height(8.dp)) | ||
| TextButton(onClick = { | ||
| val intent = Intent( | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use |
||
| Intent.ACTION_VIEW, | ||
| Uri.parse("market://details?id=org.tasks") | ||
| ).apply { | ||
| flags = Intent.FLAG_ACTIVITY_NEW_TASK | ||
| } | ||
| try { | ||
| ctx.android.startActivity(intent) | ||
| } catch (_: Exception) { | ||
| // Play Store not available, try browser | ||
| val webIntent = Intent( | ||
| Intent.ACTION_VIEW, | ||
| Uri.parse("https://play.google.com/store/apps/details?id=org.tasks") | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please use https://f-droid.org/packages/org.tasks/ instead |
||
| ).apply { | ||
| flags = Intent.FLAG_ACTIVITY_NEW_TASK | ||
| } | ||
| ctx.android.startActivity(webIntent) | ||
| } | ||
| }) { | ||
| Text( | ||
| text = ctx.getString(R.string.skill_reminder_install_tasks), | ||
| style = MaterialTheme.typography.labelLarge, | ||
| ) | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,139 @@ | ||
| package org.stypox.dicio.skills.reminder | ||
|
|
||
| import android.content.Intent | ||
| import android.os.Bundle | ||
| import kotlinx.coroutines.flow.first | ||
| 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.Reminder | ||
| import org.stypox.dicio.skills.reminder.ReminderInfo.reminderDataStore | ||
| import java.time.LocalDateTime | ||
| import java.time.format.DateTimeFormatter | ||
|
|
||
| class ReminderSkill( | ||
| correspondingSkillInfo: SkillInfo, | ||
| data: StandardRecognizerData<Reminder>, | ||
| ) : StandardRecognizerSkill<Reminder>(correspondingSkillInfo, data) { | ||
|
|
||
| override suspend fun generateOutput( | ||
| ctx: SkillContext, | ||
| inputData: Reminder, | ||
| ): SkillOutput { | ||
| val rawTask = when (inputData) { | ||
| is Reminder.Create -> inputData.task?.trim() ?: "" | ||
| } | ||
|
|
||
| if (rawTask.isBlank()) { | ||
| return ReminderOutput.Created(title = "", dateTime = null) | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Create another class specifically for this |
||
| } | ||
|
|
||
| // Check if Tasks.org is installed | ||
| if (!isTasksOrgInstalled(ctx)) { | ||
| return ReminderOutput.TasksNotInstalled | ||
| } | ||
|
|
||
| // Use extractDateTime with mixedWithText to split the captured text | ||
| // into the task description (text parts) and the date/time | ||
| val parserFormatter = ctx.parserFormatter | ||
| var taskTitle = rawTask | ||
| var dateTime: LocalDateTime? = null | ||
|
|
||
| if (parserFormatter != null) { | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Make use of the new date time matching introduced in d701d86 |
||
| val parts = parserFormatter.extractDateTime(rawTask).mixedWithText | ||
| val textParts = StringBuilder() | ||
| for (part in parts) { | ||
| if (part is LocalDateTime) { | ||
| dateTime = part | ||
| } else if (part is String) { | ||
| textParts.append(part) | ||
| } | ||
| } | ||
| val extracted = textParts.toString().trim() | ||
| if (extracted.isNotBlank()) { | ||
| taskTitle = extracted | ||
| } | ||
| } | ||
|
|
||
| // Read settings | ||
| val prefs = ctx.android.reminderDataStore.data.first() | ||
| val priority = toTasksOrgPriority(prefs.defaultPriority) | ||
| val description = if (prefs.saveVoiceInput) rawTask else null | ||
|
|
||
| // Send broadcast to Tasks.org | ||
| sendTaskBroadcast(ctx, taskTitle, dateTime, priority, description) | ||
|
|
||
| return ReminderOutput.Created(title = taskTitle, dateTime = dateTime) | ||
| } | ||
|
|
||
| private fun isTasksOrgInstalled(ctx: SkillContext): Boolean { | ||
| return try { | ||
| ctx.android.packageManager.getPackageInfo(TASKS_ORG_PACKAGE, 0) | ||
| true | ||
| } catch (_: Exception) { | ||
| false | ||
| } | ||
| } | ||
|
|
||
| private fun toTasksOrgPriority(priority: ReminderPriority): String { | ||
| return when (priority) { | ||
| ReminderPriority.REMINDER_PRIORITY_HIGH -> "0" | ||
| ReminderPriority.REMINDER_PRIORITY_MEDIUM -> "1" | ||
| ReminderPriority.REMINDER_PRIORITY_LOW -> "2" | ||
| ReminderPriority.REMINDER_PRIORITY_NONE -> "3" | ||
| ReminderPriority.UNRECOGNIZED -> "1" | ||
| } | ||
| } | ||
|
|
||
| private fun sendTaskBroadcast( | ||
| ctx: SkillContext, | ||
| title: String, | ||
| dateTime: LocalDateTime?, | ||
| priority: String, | ||
| description: String?, | ||
| ) { | ||
| val taskBundle = Bundle().apply { | ||
| putString(EXTRA_TITLE, title) | ||
| if (dateTime != null) { | ||
| putString(EXTRA_DUE_DATE, dateTime.toLocalDate() | ||
| .format(DateTimeFormatter.ISO_LOCAL_DATE)) | ||
| putString(EXTRA_DUE_TIME, dateTime.toLocalTime() | ||
| .format(DateTimeFormatter.ISO_LOCAL_TIME)) | ||
| } | ||
| putString(EXTRA_PRIORITY, priority) | ||
| if (description != null) { | ||
| putString(EXTRA_DESCRIPTION, description) | ||
| } | ||
| putInt(EXTRA_VERSION_CODE, 1) | ||
| } | ||
|
|
||
| val intent = Intent(ACTION_FIRE_SETTING).apply { | ||
| setPackage(TASKS_ORG_PACKAGE) | ||
| putExtra(EXTRA_BUNDLE, taskBundle) | ||
| } | ||
|
|
||
| ctx.android.sendBroadcast(intent) | ||
| } | ||
|
|
||
| companion object { | ||
| private const val TASKS_ORG_PACKAGE = "org.tasks" | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Mmmh, isn't there a built in Android API to achieve something like this? Could you do some research (you, not AI)? |
||
| private const val ACTION_FIRE_SETTING = | ||
| "com.twofortyfouram.locale.intent.action.FIRE_SETTING" | ||
| private const val EXTRA_BUNDLE = | ||
| "com.twofortyfouram.locale.intent.extra.BUNDLE" | ||
| private const val EXTRA_TITLE = | ||
| "org.tasks.locale.create.STRING_TITLE" | ||
| private const val EXTRA_DUE_DATE = | ||
| "org.tasks.locale.create.STRING_DUE_DATE" | ||
| private const val EXTRA_DUE_TIME = | ||
| "org.tasks.locale.create.STRING_DUE_TIME" | ||
| private const val EXTRA_PRIORITY = | ||
| "org.tasks.locale.create.STRING_PRIORITY" | ||
| private const val EXTRA_DESCRIPTION = | ||
| "org.tasks.locale.create.STRING_DESCRIPTION" | ||
| private const val EXTRA_VERSION_CODE = | ||
| "org.tasks.locale.create.INT_VERSION_CODE" | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| package org.stypox.dicio.skills.reminder | ||
|
|
||
| import androidx.datastore.core.CorruptionException | ||
| import androidx.datastore.core.Serializer | ||
| import com.google.protobuf.InvalidProtocolBufferException | ||
| import java.io.InputStream | ||
| import java.io.OutputStream | ||
|
|
||
| object SkillSettingsReminderSerializer : Serializer<SkillSettingsReminder> { | ||
| override val defaultValue: SkillSettingsReminder = SkillSettingsReminder.getDefaultInstance() | ||
|
|
||
| override suspend fun readFrom(input: InputStream): SkillSettingsReminder { | ||
| try { | ||
| return SkillSettingsReminder.parseFrom(input) | ||
| } catch (exception: InvalidProtocolBufferException) { | ||
| throw CorruptionException("Cannot read proto", exception) | ||
| } | ||
| } | ||
|
|
||
| override suspend fun writeTo(t: SkillSettingsReminder, output: OutputStream) { | ||
| t.writeTo(output) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| syntax = "proto3"; | ||
|
|
||
| option java_package = "org.stypox.dicio.skills.reminder"; | ||
| option java_multiple_files = true; | ||
|
|
||
| enum ReminderPriority { | ||
| // Medium is value 0 so it becomes the proto3 default | ||
| REMINDER_PRIORITY_MEDIUM = 0; | ||
| REMINDER_PRIORITY_HIGH = 1; | ||
| REMINDER_PRIORITY_LOW = 2; | ||
| REMINDER_PRIORITY_NONE = 3; | ||
| } | ||
|
|
||
| message SkillSettingsReminder { | ||
| ReminderPriority default_priority = 1; | ||
| bool save_voice_input = 2; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also make this unavailable if the Tasks.org app is not installed. Move the check over from the other file.