From 825b041b1abfb5b5df2d889541c6d66288c31376 Mon Sep 17 00:00:00 2001 From: "Cel A. Skeggs" Date: Sat, 15 Feb 2025 22:48:08 -0800 Subject: [PATCH] Add initial version of checklist skill --- app/build.gradle.kts | 13 +- .../org/stypox/dicio/error/UserAction.kt | 4 +- .../org/stypox/dicio/eval/SkillHandler.kt | 2 + .../dicio/settings/MainSettingsScreen.kt | 26 +- .../dicio/skills/checklist/ChecklistInfo.kt | 45 ++ .../checklist/ChecklistSettingsScreen.kt | 591 ++++++++++++++++++ .../checklist/ChecklistSettingsViewModel.kt | 91 +++ .../dicio/skills/checklist/ChecklistSkill.kt | 402 ++++++++++++ .../skills/checklist/ChecklistSkillOutput.kt | 19 + .../checklist/ChecklistYesNoSkillOutput.kt | 41 ++ .../SkillSettingsChecklistSerializer.kt | 23 + .../org/stypox/dicio/ui/nav/Navigation.kt | 7 +- .../kotlin/org/stypox/dicio/ui/nav/Routes.kt | 3 + .../main/proto/skill_settings_checklist.proto | 42 ++ app/src/main/res/values/strings.xml | 57 ++ app/src/main/sentences/en/checklist.yml | 31 + app/src/main/sentences/skill_definitions.yml | 23 + gradle/libs.versions.toml | 5 +- 18 files changed, 1407 insertions(+), 18 deletions(-) create mode 100644 app/src/main/kotlin/org/stypox/dicio/skills/checklist/ChecklistInfo.kt create mode 100644 app/src/main/kotlin/org/stypox/dicio/skills/checklist/ChecklistSettingsScreen.kt create mode 100644 app/src/main/kotlin/org/stypox/dicio/skills/checklist/ChecklistSettingsViewModel.kt create mode 100644 app/src/main/kotlin/org/stypox/dicio/skills/checklist/ChecklistSkill.kt create mode 100644 app/src/main/kotlin/org/stypox/dicio/skills/checklist/ChecklistSkillOutput.kt create mode 100644 app/src/main/kotlin/org/stypox/dicio/skills/checklist/ChecklistYesNoSkillOutput.kt create mode 100644 app/src/main/kotlin/org/stypox/dicio/skills/checklist/SkillSettingsChecklistSerializer.kt create mode 100644 app/src/main/proto/skill_settings_checklist.proto create mode 100644 app/src/main/sentences/en/checklist.yml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7b43f14f..87ef99cc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -97,12 +97,8 @@ protobuf { generateProtoTasks { all().forEach { it.builtins { - create("kotlin") { - option("lite") - } - create("java") { - option("lite") - } + create("kotlin") + create("java") } } } @@ -163,8 +159,9 @@ dependencies { testAnnotationProcessor(libs.hilt.android.compiler) // Protobuf and Datastore - implementation(libs.protobuf.kotlin.lite) - implementation(libs.protobuf.java.lite) + implementation(libs.protobuf.kotlin) + implementation(libs.protobuf.java) + implementation(libs.protobuf.java.util) implementation(libs.datastore) // Navigation diff --git a/app/src/main/kotlin/org/stypox/dicio/error/UserAction.kt b/app/src/main/kotlin/org/stypox/dicio/error/UserAction.kt index 4f08bd96..69f8e802 100644 --- a/app/src/main/kotlin/org/stypox/dicio/error/UserAction.kt +++ b/app/src/main/kotlin/org/stypox/dicio/error/UserAction.kt @@ -14,5 +14,7 @@ enum class UserAction(val message: String) : Parcelable { GENERIC_EVALUATION("Evaluation"), SKILL_EVALUATION("Skill evaluation"), WAKE_DOWNLOADING("Downloading wake word model"), - WAKE_LOADING("Loading wake word model"); + WAKE_LOADING("Loading wake word model"), + IMPORTING_CHECKLIST("Importing checklist"), + EXPORTING_CHECKLIST("Exporting checklist"); } 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 506955ae..d829cd8c 100644 --- a/app/src/main/kotlin/org/stypox/dicio/eval/SkillHandler.kt +++ b/app/src/main/kotlin/org/stypox/dicio/eval/SkillHandler.kt @@ -18,6 +18,7 @@ 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.calculator.CalculatorInfo +import org.stypox.dicio.skills.checklist.ChecklistInfo import org.stypox.dicio.skills.current_time.CurrentTimeInfo import org.stypox.dicio.skills.fallback.text.TextFallbackInfo import org.stypox.dicio.skills.listening.ListeningInfo @@ -55,6 +56,7 @@ class SkillHandler @Inject constructor( JokeInfo, ListeningInfo(dataStore), TranslationInfo, + ChecklistInfo, ) // TODO add more fallback skills (e.g. search) diff --git a/app/src/main/kotlin/org/stypox/dicio/settings/MainSettingsScreen.kt b/app/src/main/kotlin/org/stypox/dicio/settings/MainSettingsScreen.kt index d17139a7..38548fa3 100644 --- a/app/src/main/kotlin/org/stypox/dicio/settings/MainSettingsScreen.kt +++ b/app/src/main/kotlin/org/stypox/dicio/settings/MainSettingsScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.DeleteSweep +import androidx.compose.material.icons.filled.Checklist import androidx.compose.material.icons.filled.Extension import androidx.compose.material.icons.filled.UploadFile import androidx.compose.material3.ExperimentalMaterial3Api @@ -31,6 +32,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavHost +import androidx.navigation.NavHostController import org.stypox.dicio.R import org.stypox.dicio.settings.datastore.InputDevice import org.stypox.dicio.settings.datastore.Language @@ -41,13 +44,15 @@ import org.stypox.dicio.settings.datastore.UserSettingsModule.Companion.newDataS import org.stypox.dicio.settings.datastore.WakeDevice import org.stypox.dicio.settings.ui.SettingsCategoryTitle import org.stypox.dicio.settings.ui.SettingsItem +import org.stypox.dicio.ui.nav.ChecklistSettings +import org.stypox.dicio.ui.nav.SkillSettings import org.stypox.dicio.ui.theme.AppTheme @Composable fun MainSettingsScreen( navigationIcon: @Composable () -> Unit, - navigateToSkillSettings: () -> Unit, + navigationController: NavHostController?, viewModel: MainSettingsViewModel = hiltViewModel(), ) { Scaffold( @@ -60,7 +65,7 @@ fun MainSettingsScreen( } ) { MainSettingsScreen( - navigateToSkillSettings = navigateToSkillSettings, + navigationController = navigationController, viewModel = viewModel, modifier = Modifier.padding(it), ) @@ -69,7 +74,7 @@ fun MainSettingsScreen( @Composable private fun MainSettingsScreen( - navigateToSkillSettings: () -> Unit, + navigationController: NavHostController?, viewModel: MainSettingsViewModel, modifier: Modifier = Modifier, ) { @@ -115,10 +120,19 @@ private fun MainSettingsScreen( icon = Icons.Default.Extension, description = stringResource(R.string.pref_skills_summary), modifier = Modifier - .clickable(onClick = navigateToSkillSettings) + .clickable(onClick = { navigationController?.navigate(SkillSettings) }) .testTag("skill_settings_item") ) } + item { + SettingsItem( + title = stringResource(R.string.checklists), + icon = Icons.Default.Checklist, + description = stringResource(R.string.skill_checklist_settings_description), + modifier = Modifier + .clickable(onClick = { navigationController?.navigate(ChecklistSettings) }) + ) + } /* INPUT AND OUTPUT METHODS */ item { SettingsCategoryTitle(stringResource(R.string.pref_io)) } @@ -208,7 +222,7 @@ private fun MainSettingsScreenPreview() { color = MaterialTheme.colorScheme.background ) { MainSettingsScreen( - navigateToSkillSettings = {}, + navigationController = null, viewModel = MainSettingsViewModel( application = Application(), wakeDeviceWrapper = null, @@ -235,7 +249,7 @@ private fun MainSettingsScreenWithTopBarPreview() { ) } }, - navigateToSkillSettings = {}, + navigationController = null, viewModel = MainSettingsViewModel( application = Application(), wakeDeviceWrapper = null, diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/checklist/ChecklistInfo.kt b/app/src/main/kotlin/org/stypox/dicio/skills/checklist/ChecklistInfo.kt new file mode 100644 index 00000000..913c0400 --- /dev/null +++ b/app/src/main/kotlin/org/stypox/dicio/skills/checklist/ChecklistInfo.kt @@ -0,0 +1,45 @@ +package org.stypox.dicio.skills.checklist + +import android.content.Context +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Checklist +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler +import androidx.datastore.dataStore +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 + +object ChecklistInfo : SkillInfo("checklist") { + override fun name(context: Context) = + context.getString(R.string.skill_name_checklist) + + override fun sentenceExample(context: Context) = + context.getString(R.string.skill_sentence_example_checklist) + + @Composable + override fun icon() = + rememberVectorPainter(Icons.Default.Checklist) + + override fun isAvailable(ctx: SkillContext): Boolean { + return Sentences.Checklist[ctx.sentencesLanguage] != null && + Sentences.UtilYesNo[ctx.sentencesLanguage] != null && + ctx.parserFormatter != null + } + + override fun build(ctx: SkillContext): Skill<*> { + return ChecklistSkill(ChecklistInfo, Sentences.Checklist[ctx.sentencesLanguage]!!) + } + + // no need to use Hilt injection here, let DataStore take care of handling the singleton itself + internal val Context.checklistDataStore by dataStore( + fileName = "checklist.pb", + serializer = SkillSettingsChecklistSerializer, + corruptionHandler = ReplaceFileCorruptionHandler { + SkillSettingsChecklistSerializer.defaultValue + }, + ) +} diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/checklist/ChecklistSettingsScreen.kt b/app/src/main/kotlin/org/stypox/dicio/skills/checklist/ChecklistSettingsScreen.kt new file mode 100644 index 00000000..c216975d --- /dev/null +++ b/app/src/main/kotlin/org/stypox/dicio/skills/checklist/ChecklistSettingsScreen.kt @@ -0,0 +1,591 @@ +package org.stypox.dicio.skills.checklist + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material.icons.filled.CheckBox +import androidx.compose.material.icons.filled.CheckBoxOutlineBlank +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.QuestionMark +import androidx.compose.material.icons.filled.RecordVoiceOver +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.hilt.navigation.compose.hiltViewModel +import kotlinx.coroutines.job +import org.stypox.dicio.R +import org.stypox.dicio.settings.ui.StringSetting +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.util.Locale + +@Composable +fun ChecklistSettingsScreen( + navigationIcon: @Composable () -> Unit, + viewModel: ChecklistSettingsViewModel = hiltViewModel(), +) { + Scaffold( + topBar = { + @OptIn(ExperimentalMaterial3Api::class) + (TopAppBar( + title = { Text(stringResource(R.string.checklists)) }, + navigationIcon = navigationIcon + )) + } + ) { + ChecklistSettingsScreen(viewModel = viewModel, modifier = Modifier.padding(it)) + } +} + +@Composable +fun ChecklistSettingsScreen( + viewModel: ChecklistSettingsViewModel, + modifier: Modifier = Modifier, +) { + val checklists by viewModel.checklists.collectAsState() + val exportLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { + viewModel.exportChecklists(it) + } + val importLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { + viewModel.importChecklists(it) + } + + LazyColumn( + contentPadding = PaddingValues(top = 4.dp, bottom = 4.dp), + modifier = modifier, + ) { + checklists.checklistsList.forEachIndexed { index, checklist -> + item { + val locale by viewModel.localeManager.locale.collectAsState() + var expanded by rememberSaveable { mutableStateOf(false) } + ChecklistSettingsItem( + locale, + checklist, + { viewModel.replaceChecklist(index, it) }, + expanded, + { expanded = !expanded }) + } + } + item { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp) + .animateContentSize() + ) { + TextButton( + onClick = { + viewModel.addChecklist(Checklist.getDefaultInstance()) + }, + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Text(stringResource(R.string.skill_checklist_settings_new)) + } + } + } + item { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp) + .animateContentSize() + ) { + TextButton( + onClick = { + importLauncher.launch(arrayOf("*/*")) + }, + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Text(stringResource(R.string.skill_checklist_import)) + } + } + } + item { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp) + .animateContentSize() + ) { + TextButton( + onClick = { + exportLauncher.launch("checklists.pb.json") + }, + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Text(stringResource(R.string.skill_checklist_export)) + } + } + } + } +} + +@Composable +fun ChecklistSettingsItem( + locale: Locale, + checklist: Checklist, + updateChecklist: (Checklist?) -> Unit, + expanded: Boolean, + toggleExpanded: () -> Unit, +) { + var deleteDialogOpen by rememberSaveable { mutableStateOf(false) } + var dialogOpenIndex: Int? by rememberSaveable { mutableStateOf(null) } + var attemptingItemDelete by rememberSaveable { mutableStateOf(false) } + val formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).withLocale(locale) + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp) + .animateContentSize() + ) { + ChecklistSettingsItemHeader( + expanded = expanded, + toggleExpanded = toggleExpanded, + checklist = checklist, + ) + + if (expanded) { + StringSetting( + title = stringResource(R.string.skill_checklist_name), + descriptionWhenEmpty = stringResource(R.string.skill_checklist_update_name_description), + ).Render( + value = checklist.checklistName, + onValueChange = { newName -> + updateChecklist(checklist.copy { + checklistName = newName + }) + }, + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = when (checklist.executionState) { + ChecklistState.NOT_STARTED -> stringResource(R.string.skill_checklist_state_not_started) + ChecklistState.IN_PROGRESS -> { + val startedAt = + ChecklistSkill.timestampToLocal(checklist.executionStartedAt) + stringResource( + R.string.skill_checklist_state_in_progress, + formatter.format(startedAt) + ) + } + + ChecklistState.COMPLETE -> { + val startedAt = + ChecklistSkill.timestampToLocal(checklist.executionStartedAt) + val endedAt = + ChecklistSkill.timestampToLocal(checklist.executionEndedAt) + stringResource( + R.string.skill_checklist_state_complete, + formatter.format(startedAt), + formatter.format(endedAt), + ) + } + + else -> stringResource(R.string.skill_checklist_state_unknown) + }, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + modifier = Modifier + .weight(1.0f) + .padding(horizontal = 16.dp, vertical = 8.dp), + color = LocalContentColor.current, + ) + } + + checklist.checklistItemList.forEachIndexed { index, item -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable { + dialogOpenIndex = index + attemptingItemDelete = false + } + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Icon( + imageVector = when (item.executionState) { + ItemState.NOT_ASKED -> Icons.Default.CheckBoxOutlineBlank + ItemState.ASKED -> Icons.Default.RecordVoiceOver + ItemState.COMPLETED -> Icons.Default.CheckBox + ItemState.SKIPPED -> Icons.Default.Pause + else -> Icons.Default.QuestionMark + }, + contentDescription = when (item.executionState) { + ItemState.NOT_ASKED -> stringResource(R.string.skill_checklist_item_state_not_asked) + ItemState.ASKED -> stringResource(R.string.skill_checklist_item_state_asked) + ItemState.COMPLETED -> stringResource(R.string.skill_checklist_item_state_completed) + ItemState.SKIPPED -> stringResource(R.string.skill_checklist_item_state_skipped) + else -> stringResource(R.string.skill_checklist_item_state_unknown) + }, + ) + Spacer(modifier = Modifier.width(24.dp)) + Text( + text = if (item.itemName.isBlank()) { + stringResource(R.string.skill_checklist_set_description, index + 1) + } else { + stringResource( + R.string.skill_checklist_item_has_description, + index + 1, + item.itemName + ) + } + "\n" + + stringResource( + R.string.skill_checklist_last_changed, + formatter.format(ChecklistSkill.timestampToLocal(item.executionLastChanged)) + ), + style = MaterialTheme.typography.bodyMedium, + ) + } + } + + if (dialogOpenIndex != null && dialogOpenIndex!! < checklist.checklistItemCount) { + var value by rememberSaveable { mutableStateOf(checklist.checklistItemList[dialogOpenIndex!!].itemName) } + + if (attemptingItemDelete) { + AlertDialog( + icon = { + Icon(Icons.Default.Warning, contentDescription = stringResource(R.string.warning)) + }, + title = { + Text(text = if (value.isBlank()) { + stringResource( + R.string.skill_checklist_item_no_description, + dialogOpenIndex!! + 1 + ) + } else { + stringResource( + R.string.skill_checklist_item_delete_has_description, + dialogOpenIndex!! + 1, + value + ) + }) + }, + text = { + Text(text = stringResource(R.string.skill_checklist_confirm_delete)) + }, + onDismissRequest = { + attemptingItemDelete = false + dialogOpenIndex = null + }, + confirmButton = { + TextButton( + onClick = { + updateChecklist( + checklist.toBuilder().clearChecklistItem() + .addAllChecklistItem(checklist.checklistItemList.filterIndexed { index, _ -> index != dialogOpenIndex!! }) + .build() + ) + attemptingItemDelete = false + dialogOpenIndex = null + } + ) { + Text(stringResource(R.string.skill_checklist_delete)) + } + }, + dismissButton = { + TextButton( + onClick = { + attemptingItemDelete = false + dialogOpenIndex = null + } + ) { + Text(stringResource(R.string.skill_checklist_cancel)) + } + } + ) + } else { + Dialog(onDismissRequest = { dialogOpenIndex = null }) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.extraLarge, + ) { + Row( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + IconButton( + onClick = { + if (dialogOpenIndex!! < checklist.checklistItemCount - 1) { + updateChecklist(checklist.copy { + val current = checklistItem[dialogOpenIndex!!] + checklistItem[dialogOpenIndex!!] = + checklistItem[dialogOpenIndex!! + 1] + checklistItem[dialogOpenIndex!! + 1] = current + }) + dialogOpenIndex = dialogOpenIndex!! + 1 + } + }, + enabled = (dialogOpenIndex!! < checklist.checklistItemCount - 1) + ) { + Icon( + imageVector = Icons.Default.ArrowDownward, + contentDescription = stringResource(R.string.skill_checklist_move_down), + modifier = Modifier.size(32.dp), + ) + } + Text( + text = stringResource( + R.string.skill_checklist_item_number, + dialogOpenIndex!! + 1 + ), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + modifier = Modifier + .weight(1.0f) + .padding( + start = 16.dp, + top = 16.dp, + end = 16.dp, + bottom = 8.dp + ), + ) + IconButton(onClick = { + if (dialogOpenIndex!! > 0) { + updateChecklist(checklist.copy { + val current = checklistItem[dialogOpenIndex!!] + checklistItem[dialogOpenIndex!!] = + checklistItem[dialogOpenIndex!! - 1] + checklistItem[dialogOpenIndex!! - 1] = current + }) + dialogOpenIndex = dialogOpenIndex!! - 1 + } + }, enabled = (dialogOpenIndex!! > 0)) { + Icon( + imageVector = Icons.Default.ArrowUpward, + contentDescription = stringResource(R.string.skill_checklist_move_up), + modifier = Modifier.size(32.dp), + ) + } + } + + Box( + contentAlignment = Alignment.TopCenter, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + val focusRequester = remember { FocusRequester() } + TextField( + value = value, + onValueChange = { value = it }, + textStyle = MaterialTheme.typography.bodyMedium, + modifier = Modifier.focusRequester(focusRequester), + ) + LaunchedEffect(null) { + coroutineContext.job.invokeOnCompletion { + focusRequester.requestFocus() + } + } + } + + Row( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + TextButton(onClick = { attemptingItemDelete = true }) { + Text(stringResource(R.string.skill_checklist_delete)) + } + Spacer(modifier = Modifier.weight(1.0f)) + TextButton(onClick = { dialogOpenIndex = null }) { + Text(stringResource(android.R.string.cancel)) + } + TextButton( + onClick = { + // only send value changes when the user presses ok + updateChecklist(checklist.copy { + checklistItem[dialogOpenIndex!!] = + checklistItem[dialogOpenIndex!!].copy { + itemName = value + } + }) + dialogOpenIndex = null + } + ) { + Text(stringResource(android.R.string.ok)) + } + } + } + } + } + } + + TextButton( + onClick = { + updateChecklist(checklist.copy { + checklistItem.add(ChecklistItem.getDefaultInstance()) + dialogOpenIndex = checklistItem.size - 1 + attemptingItemDelete = false + }) + }, + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Text(stringResource(R.string.skill_checklist_add_item)) + } + + TextButton( + onClick = { + deleteDialogOpen = true + }, + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Text(stringResource(R.string.skill_checklist_delete_checklist)) + } + } + + if (deleteDialogOpen) { + AlertDialog( + icon = { + Icon(Icons.Default.Warning, contentDescription = stringResource(R.string.warning)) + }, + title = { + Text(text = if (checklist.checklistName.isBlank()) { + stringResource(R.string.skill_checklist_unspecified_checklist) + } else { + stringResource( + R.string.skill_checklist_title_checklist, + checklist.checklistName + ) + }) + }, + text = { + Text(text = stringResource(R.string.skill_checklist_confirm_delete_checklist)) + }, + onDismissRequest = { + deleteDialogOpen = false + }, + confirmButton = { + TextButton( + onClick = { + updateChecklist(null) + deleteDialogOpen = false + } + ) { + Text(stringResource(R.string.skill_checklist_delete)) + } + }, + dismissButton = { + TextButton( + onClick = { + deleteDialogOpen = false + } + ) { + Text(stringResource(R.string.skill_checklist_cancel)) + } + } + ) + } + } +} + +@Composable +private fun ChecklistSettingsItemHeader( + expanded: Boolean, + toggleExpanded: () -> Unit, + checklist: Checklist, +) { + val expandedAnimation by animateFloatAsState( + label = "checklist ${checklist.checklistName} card expanded", + targetValue = if (expanded) 180f else 0f + ) + + Row( + modifier = Modifier + .clickable(onClick = toggleExpanded) + .padding(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = ChecklistInfo.icon(), + contentDescription = null, + modifier = Modifier + .padding(start = 12.dp) + .size(24.dp), + tint = LocalContentColor.current, + ) + Text( + text = if (checklist.checklistName.isEmpty()) { + stringResource( + R.string.skill_checklist_header_unspecified, + checklist.checklistItemCount + ) + } else { + stringResource( + R.string.skill_checklist_header, + checklist.checklistName, + checklist.checklistItemCount + ) + }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + maxLines = 1, + modifier = Modifier + .weight(1.0f) + .padding(start = 12.dp), + color = LocalContentColor.current, + ) + IconButton( + onClick = toggleExpanded, + ) { + Icon( + modifier = Modifier.rotate(expandedAnimation), + imageVector = Icons.Default.ArrowDropDown, + contentDescription = stringResource( + if (expanded) R.string.reduce else R.string.expand + ) + ) + } + } +} diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/checklist/ChecklistSettingsViewModel.kt b/app/src/main/kotlin/org/stypox/dicio/skills/checklist/ChecklistSettingsViewModel.kt new file mode 100644 index 00000000..d8bb9213 --- /dev/null +++ b/app/src/main/kotlin/org/stypox/dicio/skills/checklist/ChecklistSettingsViewModel.kt @@ -0,0 +1,91 @@ +package org.stypox.dicio.skills.checklist + +import android.app.Application +import android.net.Uri +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.google.protobuf.util.JsonFormat +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import org.stypox.dicio.App +import org.stypox.dicio.di.LocaleManager +import org.stypox.dicio.error.ErrorInfo +import org.stypox.dicio.error.ErrorUtils +import org.stypox.dicio.error.UserAction +import org.stypox.dicio.skills.checklist.ChecklistInfo.checklistDataStore +import org.stypox.dicio.util.toStateFlowDistinctBlockingFirst +import javax.inject.Inject + + +@HiltViewModel +class ChecklistSettingsViewModel @Inject constructor( + application: Application, + val localeManager: LocaleManager, +) : AndroidViewModel(application) { + + val dataStore = application.baseContext.checklistDataStore + + // run blocking because the checklist screen cannot start if checklists have not been loaded yet + val checklists = dataStore.data.toStateFlowDistinctBlockingFirst(viewModelScope) + + fun addChecklist(checklist: Checklist) { + viewModelScope.launch { + dataStore.updateData { + it.copy { checklists.add(checklist) } + } + } + } + + fun replaceChecklist(index: Int, checklist: Checklist?) { + viewModelScope.launch { + dataStore.updateData { + if (checklist != null) { + it.copy { checklists[index] = checklist } + } else { + it.toBuilder().clearChecklists().addAllChecklists(it.checklistsList.filterIndexed { itemIndexed, _ -> index != itemIndexed }).build() + } + } + } + } + + fun exportChecklists(uri: Uri?) { + if (uri != null) { + viewModelScope.launch { + try { + val export = dataStore.data.first() + val encoded = JsonFormat.printer().print(export) + getApplication().baseContext.contentResolver.openOutputStream(uri).use { + it?.write(encoded.toByteArray()) + } + } catch (throwable: Throwable) { + ErrorUtils.openActivity(getApplication().baseContext, ErrorInfo(throwable, UserAction.EXPORTING_CHECKLIST)) + } + } + } + } + + fun importChecklists(uri: Uri?) { + if (uri != null) { + viewModelScope.launch { + try { + val encoded = + getApplication().baseContext.contentResolver.openInputStream(uri).use { + it?.readBytes() + } + val builder = SkillSettingsChecklist.newBuilder() + JsonFormat.parser().merge(encoded?.decodeToString(), builder) + val loaded = builder.build() + dataStore.updateData { + it.copy { + executionLastChecklistIndex = loaded.executionLastChecklistIndex + checklists.size + checklists.addAll(loaded.checklistsList) + } + } + } catch (throwable: Throwable) { + ErrorUtils.openActivity(getApplication().baseContext, ErrorInfo(throwable, UserAction.IMPORTING_CHECKLIST)) + } + } + } + } +} diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/checklist/ChecklistSkill.kt b/app/src/main/kotlin/org/stypox/dicio/skills/checklist/ChecklistSkill.kt new file mode 100644 index 00000000..770af65b --- /dev/null +++ b/app/src/main/kotlin/org/stypox/dicio/skills/checklist/ChecklistSkill.kt @@ -0,0 +1,402 @@ +package org.stypox.dicio.skills.checklist + +import com.google.protobuf.Timestamp +import org.dicio.numbers.unit.Number +import org.dicio.skill.context.SkillContext +import org.dicio.skill.skill.InteractionPlan +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.R +import org.stypox.dicio.sentences.Sentences.Checklist +import org.stypox.dicio.skills.checklist.ChecklistInfo.checklistDataStore +import org.stypox.dicio.util.StringUtils +import org.stypox.dicio.util.getString +import java.time.Duration +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle + +class ChecklistSkill(correspondingSkillInfo: SkillInfo, data: StandardRecognizerData) : + StandardRecognizerSkill(correspondingSkillInfo, data) { + + override suspend fun generateOutput(ctx: SkillContext, inputData: Checklist): SkillOutput { + var output: SkillOutput? = null + ctx.android.checklistDataStore.updateData { + it.copy { + if (checklists.isEmpty()) { + output = ChecklistSkillOutput( + ctx.getString(R.string.skill_checklist_none_defined), + InteractionPlan.FinishInteraction, + ) + } else { + if (executionLastChecklistIndex >= checklists.size) { + executionLastChecklistIndex = 0 + } + when (inputData) { + is Checklist.StartList -> { + if (!inputData.list.isNullOrBlank()) { + val (checklistIndex, distance) = findChecklistByName( + it, + inputData.list + ) + if (distance < 0) { + executionLastChecklistIndex = checklistIndex + } else { + output = object : ChecklistYesNoSkillOutput( + ctx.getString( + R.string.skill_checklist_confirm_start, + checklists[checklistIndex].checklistName + ) + ) { + override suspend fun onYes(ctx: SkillContext): SkillOutput { + var innerOutput: SkillOutput? = null + ctx.android.checklistDataStore.updateData { innerIt -> + innerIt.copy { + executionLastChecklistIndex = + checklistIndex + val pair = advanceState( + ctx, + checklists[executionLastChecklistIndex], + true, "" + ) + innerOutput = pair.first + checklists[executionLastChecklistIndex] = + pair.second + } + } + return innerOutput!! + } + } + } + } + if (output == null) { + val pair = advanceState( + ctx, + checklists[executionLastChecklistIndex], + true, + "" + ) + output = pair.first + checklists[executionLastChecklistIndex] = pair.second + } + } + + is Checklist.CompleteItem -> { + if (!inputData.list.isNullOrBlank()) { + val (checklistIndex, distance) = findChecklistByName( + it, + inputData.list + ) + if (distance < 0) { + executionLastChecklistIndex = checklistIndex + } else { + output = ChecklistSkillOutput(ctx.getString(R.string.skill_checklist_not_recognized), InteractionPlan.FinishInteraction) + } + } + if (output == null) { + val checklist = checklists[executionLastChecklistIndex] + var itemIndex: Int? = null + if (inputData.itemNumber != null) { + val itemNumberInfo = ctx.parserFormatter?.extractNumber(inputData.itemNumber.trim())?.mixedWithText + val itemNumber: Number? = if (itemNumberInfo != null && itemNumberInfo.size == 1 && itemNumberInfo[0] is Number) { + itemNumberInfo[0] as Number + } else { + null + } + if (itemNumber != null && itemNumber.isInteger && itemNumber.integerValue() >= 1 && itemNumber.integerValue() <= checklist.checklistItemCount) { + itemIndex = itemNumber.integerValue().toInt() - 1 + } else { + output = ChecklistSkillOutput(ctx.getString(R.string.skill_checklist_item_unrecognized), InteractionPlan.FinishInteraction) + } + } + if (itemIndex == null && inputData.item != null) { + val (foundIndex, distance) = findItemByName(checklist, inputData.item) + if (distance < 0) { + itemIndex = foundIndex + } else { + output = ChecklistSkillOutput(ctx.getString(R.string.skill_checklist_item_unrecognized), InteractionPlan.FinishInteraction) + } + } + if (output == null) { + val pair = + advanceState( + ctx, + markItem(checklist, itemIndex, ItemState.COMPLETED), + false, "" + ) + output = pair.first + checklists[executionLastChecklistIndex] = pair.second + } + } + } + + is Checklist.SkipItem -> { + val checklist = checklists[executionLastChecklistIndex] + val pair = + advanceState(ctx, markItem(checklist, null, ItemState.SKIPPED), false, "") + output = pair.first + checklists[executionLastChecklistIndex] = pair.second + } + + is Checklist.Wait -> { + output = ChecklistSkillOutput(ctx.getString(R.string.skill_checklist_wait_acknowledge), InteractionPlan.FinishInteraction) + } + + is Checklist.QueryItem -> { + if (!inputData.list.isNullOrBlank()) { + val (checklistIndex, distance) = findChecklistByName( + it, + inputData.list + ) + if (distance < 0) { + executionLastChecklistIndex = checklistIndex + } else { + output = ChecklistSkillOutput(ctx.getString(R.string.skill_checklist_not_recognized), InteractionPlan.FinishInteraction) + } + } + if (output == null) { + val checklist = checklists[executionLastChecklistIndex] + val pair = advanceState(ctx, checklist, false, "") + output = pair.first + checklists[executionLastChecklistIndex] = pair.second + } + } + + is Checklist.ResetList -> { + val checklist = checklists[executionLastChecklistIndex] + val pair = advanceState( + ctx, + checklist.copy { executionState = ChecklistState.NOT_STARTED }, + true, ctx.getString(R.string.skill_checklist_confirm_restart) + ) + output = pair.first + checklists[executionLastChecklistIndex] = pair.second + } + } + } + } + } + return output!! + } + + companion object { + private fun timestampToInstant(timestamp: Timestamp): Instant = + Instant.ofEpochSecond(timestamp.seconds, timestamp.nanos.toLong()) + + fun timestampToLocal(timestamp: Timestamp): LocalDateTime = + LocalDateTime.ofInstant(timestampToInstant(timestamp), ZoneId.systemDefault()) + + private fun timestampNow(): Timestamp { + val now = Instant.now() + return Timestamp.newBuilder().setSeconds(now.epochSecond).setNanos(now.nano).build() + } + + fun findChecklistByName(checklists: SkillSettingsChecklist, name: String): Pair = + checklists.checklistsList.mapIndexedNotNull { index, checklist -> + Pair(index, StringUtils.customStringDistance(name, checklist.checklistName)) + } + .minByOrNull { pair -> pair.second }!! + + fun findItemByName(checklist: org.stypox.dicio.skills.checklist.Checklist, name: String): Pair = + checklist.checklistItemList.mapIndexedNotNull { index, item -> + Pair(index, StringUtils.customStringDistance(name, item.itemName)) + } + .minByOrNull { pair -> pair.second }!! + + private fun formatTime(ctx: SkillContext, timestamp: Timestamp): String { + val local = timestampToLocal(timestamp) + val nowLocal = timestampToLocal(timestampNow()) + + val formatter: DateTimeFormatter + if (local.year == nowLocal.year && local.dayOfYear == nowLocal.dayOfYear) { + formatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(ctx.locale) + // This is today, just use the time. + } else if (local.year == nowLocal.year && local.dayOfYear >= nowLocal.dayOfYear - 7 && local.dayOfYear < nowLocal.dayOfYear) { + // Within the past seven days, so can use the day of the week + formatter = DateTimeFormatter.ofPattern("hh:mm a 'on' EEEE") + } else if (local.year == nowLocal.year || (local.year == nowLocal.year - 1 && local.monthValue > nowLocal.monthValue)) { + // In the current year, or last year but in a month that has not happened this year + formatter = DateTimeFormatter.ofPattern("hh:mm a 'on' MMMM dd") + } else { + // Otherwise, we can go long form + formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).withLocale(ctx.locale) + } + + return formatter.format(local) + } + + private fun startChecklistIfNecessary( + ctx: SkillContext, + checklist: org.stypox.dicio.skills.checklist.Checklist, + now: Timestamp, + intro: String, + ): Pair = + if (checklist.executionState == ChecklistState.IN_PROGRESS) { + Pair(null, checklist) + } else { + val title = checklist.checklistName.ifEmpty { ctx.getString(R.string.skill_checklist_unspecified_item_name) } + // FIXME: Move output text into advance so it applies if we jump into a checklist using another command besides start + var outputText = intro.ifEmpty { + when (checklist.executionState) { + ChecklistState.IN_PROGRESS -> ctx.getString( + R.string.skill_checklist_continue_checklist, + title, + formatTime(ctx, checklist.executionStartedAt) + ) + ChecklistState.COMPLETE -> ctx.getString( + R.string.skill_checklist_restart_checklist, + title, + formatTime(ctx, checklist.executionEndedAt) + ) + else -> ctx.getString(R.string.skill_checklist_start_checklist, title) + } + } + + Pair(outputText, checklist.copy { + executionStartedAt = now + executionState = ChecklistState.IN_PROGRESS + executionLastIndex = 0 + checklistItem.forEachIndexed { index, item -> + checklistItem[index] = item.copy { + executionState = ItemState.NOT_ASKED + executionLastChanged = now + } + } + }) + } + + private fun fastForwardChecklist( + ctx: SkillContext, + checklist: org.stypox.dicio.skills.checklist.Checklist, + now: Timestamp, + ): Pair { + // Fast forward past any items already completed. + var currentIndex = checklist.executionLastIndex + while (currentIndex < checklist.checklistItemCount && checklist.checklistItemList[currentIndex].executionState == ItemState.COMPLETED) { + currentIndex += 1 + } + // If we hit the end, circle back to any incomplete items. + var message: String? = null + var isComplete = false + if (currentIndex >= checklist.checklistItemCount) { + isComplete = true + checklist.checklistItemList.forEachIndexed { index, item -> + if (isComplete && item.executionState != ItemState.COMPLETED) { + currentIndex = index + isComplete = false + } + } + if (!isComplete) { + message = ctx.getString(R.string.skill_checklist_revisit_skipped_items) + } + } + + return Pair(message, checklist.copy { + executionLastIndex = currentIndex + if (isComplete) { + executionEndedAt = now + executionState = ChecklistState.COMPLETE + } + }) + } + + private fun advanceState( + ctx: SkillContext, + checklistValue: org.stypox.dicio.skills.checklist.Checklist, + isChecklistStart: Boolean, + intro: String, + ): Pair { + val now = timestampNow() + val startPair = startChecklistIfNecessary(ctx, checklistValue, now, intro) + val startMessage = startPair.first + val ffpair = fastForwardChecklist(ctx, startPair.second, now) + val ffmessage = ffpair.first + var checklist = ffpair.second + if (checklist.executionState == ChecklistState.COMPLETE) { + val elapsedTime = Duration.between(timestampToInstant(checklist.executionStartedAt), timestampToInstant(now)) + return Pair(ChecklistSkillOutput(if (startMessage != null) { + "$startMessage " + } else { "" } + ctx.getString( + R.string.skill_checklist_complete, + renderDuration(ctx, elapsedTime) + ), InteractionPlan.FinishInteraction), checklist) + } + val item = checklist.checklistItemList[checklist.executionLastIndex] + checklist = checklist.copy { + checklistItem[checklist.executionLastIndex] = item.copy { + executionState = ItemState.ASKED + executionLastChanged = now + } + } + return Pair( + ChecklistSkillOutput(if (startMessage != null) { + "$startMessage " + } else { "" } + if (ffmessage != null) { + "$ffmessage " + } else { "" } + if (isChecklistStart) { + if (item.itemName.isBlank()) { + ctx.getString( + R.string.skill_checklist_start_no_description, + checklist.executionLastIndex + 1, + checklist.checklistItemCount + ) + } else { + ctx.getString( + R.string.skill_checklist_start_with_description, + checklist.executionLastIndex + 1, + checklist.checklistItemCount, + item.itemName + ) + } + } else { + item.itemName.ifBlank { + ctx.getString( + R.string.skill_checklist_next_no_description, + checklist.executionLastIndex + 1, + checklist.checklistItemCount + ) } + }, InteractionPlan.Continue(true)), + checklist + ) + } + + private fun renderDuration(ctx: SkillContext, elapsedTime: Duration): String = + if (elapsedTime.toDays() > 0) { + ctx.getString( + R.string.skill_checklist_days_hours, + elapsedTime.toDays(), + elapsedTime.toHours() % 24 + ) + } else if (elapsedTime.toHours() > 0) { + ctx.getString( + R.string.skill_checklist_hours_minutes, + elapsedTime.toHours(), + elapsedTime.toMinutes() % 60 + ) + } else if (elapsedTime.toMinutes() > 0) { + ctx.getString( + R.string.skill_checklist_minutes_seconds, + elapsedTime.toMinutes(), + elapsedTime.toSeconds() % 60 + ) + } else { + ctx.getString(R.string.skill_checklist_seconds, elapsedTime.toSeconds()) + } + + fun markItem(checklist: org.stypox.dicio.skills.checklist.Checklist, itemIndex: Int?, newState: ItemState): org.stypox.dicio.skills.checklist.Checklist = + checklist.copy { + val selectedIndex: Int = itemIndex ?: executionLastIndex + if (selectedIndex < checklistItem.size && checklistItem[selectedIndex].executionState != newState) { + checklistItem[selectedIndex] = checklistItem[selectedIndex].copy { + executionState = newState + executionLastChanged = timestampNow() + } + executionLastIndex = selectedIndex + 1 + } + } + } +} diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/checklist/ChecklistSkillOutput.kt b/app/src/main/kotlin/org/stypox/dicio/skills/checklist/ChecklistSkillOutput.kt new file mode 100644 index 00000000..37756247 --- /dev/null +++ b/app/src/main/kotlin/org/stypox/dicio/skills/checklist/ChecklistSkillOutput.kt @@ -0,0 +1,19 @@ +package org.stypox.dicio.skills.checklist + +import org.dicio.skill.context.SkillContext +import org.dicio.skill.skill.InteractionPlan +import org.dicio.skill.skill.SkillOutput +import org.stypox.dicio.io.graphical.HeadlineSpeechSkillOutput + +/** + * A [SkillOutput] where the graphical output is just a headline text with the speech output. + */ +open class ChecklistSkillOutput(private val literal: String, private val interactionPlan: InteractionPlan) : HeadlineSpeechSkillOutput { + override fun getSpeechOutput(ctx: SkillContext): String = literal + + override fun getInteractionPlan(ctx: SkillContext): InteractionPlan = + interactionPlan + + fun updateText(update: (String) -> String): ChecklistSkillOutput = + ChecklistSkillOutput(update(literal), interactionPlan) +} diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/checklist/ChecklistYesNoSkillOutput.kt b/app/src/main/kotlin/org/stypox/dicio/skills/checklist/ChecklistYesNoSkillOutput.kt new file mode 100644 index 00000000..f8e2ca20 --- /dev/null +++ b/app/src/main/kotlin/org/stypox/dicio/skills/checklist/ChecklistYesNoSkillOutput.kt @@ -0,0 +1,41 @@ +package org.stypox.dicio.skills.checklist + +import org.dicio.skill.context.SkillContext +import org.dicio.skill.skill.InteractionPlan +import org.dicio.skill.skill.SkillOutput +import org.stypox.dicio.R +import org.stypox.dicio.io.graphical.HeadlineSpeechSkillOutput +import org.stypox.dicio.sentences.Sentences +import org.stypox.dicio.util.RecognizeYesNoSkill +import org.stypox.dicio.util.getString + +/** + * A [SkillOutput] where the graphical output is just a headline text with the speech output. + */ +abstract class ChecklistYesNoSkillOutput(private val literal: String) : HeadlineSpeechSkillOutput { + override fun getSpeechOutput(ctx: SkillContext): String = literal + + abstract suspend fun onYes(ctx: SkillContext): SkillOutput + + override fun getInteractionPlan(ctx: SkillContext): InteractionPlan = + InteractionPlan.ReplaceSubInteraction( + true, listOf( + object : RecognizeYesNoSkill( + ChecklistInfo, + Sentences.UtilYesNo[ctx.sentencesLanguage]!! + ) { + override suspend fun generateOutput( + ctx: SkillContext, + inputData: Boolean + ): SkillOutput = + if (inputData) { + onYes(ctx) + } else { + ChecklistSkillOutput( + ctx.getString(R.string.skill_checklist_unrecognized), + InteractionPlan.FinishInteraction + ) + } + } + )) +} diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/checklist/SkillSettingsChecklistSerializer.kt b/app/src/main/kotlin/org/stypox/dicio/skills/checklist/SkillSettingsChecklistSerializer.kt new file mode 100644 index 00000000..12ee43ef --- /dev/null +++ b/app/src/main/kotlin/org/stypox/dicio/skills/checklist/SkillSettingsChecklistSerializer.kt @@ -0,0 +1,23 @@ +package org.stypox.dicio.skills.checklist + +import androidx.datastore.core.CorruptionException +import androidx.datastore.core.Serializer +import com.google.protobuf.InvalidProtocolBufferException +import java.io.InputStream +import java.io.OutputStream + +object SkillSettingsChecklistSerializer : Serializer { + override val defaultValue: SkillSettingsChecklist = SkillSettingsChecklist.getDefaultInstance() + + override suspend fun readFrom(input: InputStream): SkillSettingsChecklist { + try { + return SkillSettingsChecklist.parseFrom(input) + } catch (exception: InvalidProtocolBufferException) { + throw CorruptionException("Cannot read proto", exception) + } + } + + override suspend fun writeTo(t: SkillSettingsChecklist, output: OutputStream) { + t.writeTo(output) + } +} diff --git a/app/src/main/kotlin/org/stypox/dicio/ui/nav/Navigation.kt b/app/src/main/kotlin/org/stypox/dicio/ui/nav/Navigation.kt index 14a8c96e..10eb8047 100644 --- a/app/src/main/kotlin/org/stypox/dicio/ui/nav/Navigation.kt +++ b/app/src/main/kotlin/org/stypox/dicio/ui/nav/Navigation.kt @@ -20,6 +20,7 @@ import org.stypox.dicio.R import org.stypox.dicio.io.input.stt_popup.SttPopupActivity import org.stypox.dicio.settings.MainSettingsScreen import org.stypox.dicio.settings.SkillSettingsScreen +import org.stypox.dicio.skills.checklist.ChecklistSettingsScreen import org.stypox.dicio.ui.home.HomeScreen @Composable @@ -62,13 +63,17 @@ fun Navigation() { composable { MainSettingsScreen( navigationIcon = backIcon, - navigateToSkillSettings = { navController.navigate(SkillSettings) }, + navigationController = navController, ) } composable { SkillSettingsScreen(navigationIcon = backIcon) } + + composable { + ChecklistSettingsScreen(navigationIcon = backIcon) + } } } diff --git a/app/src/main/kotlin/org/stypox/dicio/ui/nav/Routes.kt b/app/src/main/kotlin/org/stypox/dicio/ui/nav/Routes.kt index a99de1cc..183f24e8 100644 --- a/app/src/main/kotlin/org/stypox/dicio/ui/nav/Routes.kt +++ b/app/src/main/kotlin/org/stypox/dicio/ui/nav/Routes.kt @@ -10,3 +10,6 @@ object MainSettings @Serializable object SkillSettings + +@Serializable +object ChecklistSettings diff --git a/app/src/main/proto/skill_settings_checklist.proto b/app/src/main/proto/skill_settings_checklist.proto new file mode 100644 index 00000000..5cb6d129 --- /dev/null +++ b/app/src/main/proto/skill_settings_checklist.proto @@ -0,0 +1,42 @@ +syntax = "proto3"; + +import "google/protobuf/timestamp.proto"; + +option java_package = "org.stypox.dicio.skills.checklist"; +option java_multiple_files = true; + +message SkillSettingsChecklist { + repeated Checklist checklists = 1; + + uint32 execution_last_checklist_index = 2; +} + +message Checklist { + string checklist_name = 1; + repeated ChecklistItem checklist_item = 2; + + ChecklistState execution_state = 3; + google.protobuf.Timestamp execution_started_at = 4; + uint32 execution_last_index = 5; + google.protobuf.Timestamp execution_ended_at = 6; +} + +enum ChecklistState { + NOT_STARTED = 0; + IN_PROGRESS = 1; + COMPLETE = 2; +} + +message ChecklistItem { + string item_name = 1; + + ItemState execution_state = 2; + google.protobuf.Timestamp execution_last_changed = 3; +} + +enum ItemState { + NOT_ASKED = 0; + COMPLETED = 1; + ASKED = 2; + SKIPPED = 3; +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8230af59..b26c5b52 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -246,4 +246,61 @@ Failed to copy to clipboard Auto DuckDuckGo did not provide results, asking for a Captcha to be solved + Checklist + Start the checklist + You haven\'t defined any checklists + Okay + Checklists + Create new checklist + Create, edit, and delete voice checklists + Import checklists + Export checklists + Checklist Name + Set the name to be used for the checklist + Not started. + Started at %1$s\nIn progress. + Started at %1$s\nCompleted at %2$s + Unknown State + Not Asked + Asked + Completed + Skipped + Unknown + Set the description for item %1$d + %1$d. %2$s + State last changed at %1$s + Warning + Item %1$d. No Description + Item %1$d. %2$s + Are you sure you want to delete this checklist item? + Delete + Cancel + Move Down + Item %1$d + Move Up + Add item to checklist + Delete checklist + Unspecified Checklist + %1$s Checklist + Are you sure you want to delete this checklist? + Unspecified Checklist (%1$d item(s)) + %1$s Checklist (%2$d item(s)) + Do you want to start the %1$s checklist? + I did not recognize the checklist + I did not recognize the item + Okay! + Let\'s restart. + Unspecified + We started the %1$s Checklist at %2$s. Let\'s continue. + We completed the %1$s Checklist at %2$s. Let\'s restart. + Let\'s start the %1$s Checklist. + Let\'s circle back. + Checklist complete. Time elapsed: %1$s. + Next is Item %1$d of %2$d. There\'s no description for it. + Item %1$d of %2$d. There\'s no description for it. + Item %1$d of %2$d. %3$s + %1$d days %2$d hours + %1$d hours %2$d minutes + %1$d minutes %2$d seconds + %1$d seconds diff --git a/app/src/main/sentences/en/checklist.yml b/app/src/main/sentences/en/checklist.yml new file mode 100644 index 00000000..797dffb8 --- /dev/null +++ b/app/src/main/sentences/en/checklist.yml @@ -0,0 +1,31 @@ +start_list: + - (let s|us)? start|resume|begin|continue (a|the)? (last|.list.)? (checklist|list) (for? .list.)? + +complete_item: + - (check (mark|(it? off))?)|(complete item?) (it|((this|that) (one|item|step)?)|((.item.|(item? number .item_number.)) (on the? (checklist|list)? .list. (checklist|list)?)?))? + +skip_item: + - (skip)|(come back to) it|(this|that (one|item|step)?) + - skip + - come back + - next step + +wait: + - wait + - hold on + - give me a (second|minute|moment) + - abort|exit|stop the? checklist + +query_item: + - what (s|is|was) next (on the? (checklist|list)? .list. (checklist|list)?)? + - ((which one)|(what (item|step)?)) ((was|am) I)|((were|are) we) ((working? on)|doing) (on (checklist|list)? .list.)? + - say (it|that|this)? again + - remind me + - let (s|us) continue + - where ((was|am) I)|((were|are) we) (on (checklist|list)? .list.)? + - please? repeat (that again?)? + +reset_list: + - (let (us|s))? take this from the top + - (let (us|s))? restart|start|(begin again) (at|from) (the beginning)|(the top) + - reset|restart the? checklist diff --git a/app/src/main/sentences/skill_definitions.yml b/app/src/main/sentences/skill_definitions.yml index 7aa23f72..dc878d4a 100644 --- a/app/src/main/sentences/skill_definitions.yml +++ b/app/src/main/sentences/skill_definitions.yml @@ -123,3 +123,26 @@ skills: type: string - id: target type: string + + - id: checklist + specificity: medium + sentences: + - id: start_list + captures: + - id: list + type: string + - id: complete_item + captures: + - id: item + type: string + - id: item_number + type: string + - id: list + type: string + - id: skip_item + - id: wait + - id: query_item + captures: + - id: list + type: string + - id: reset_list diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 81bf998b..100b7a9d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -82,8 +82,9 @@ kotlinpoet = { module = "com.squareup:kotlinpoet", version.ref = "kotlinpoet" } navigation = { module = "androidx.navigation:navigation-compose", version.ref = "navigation" } okhttp = { module = "com.squareup.okhttp3:okhttp" } okhttp-bom = { module = "com.squareup.okhttp3:okhttp-bom", version.ref = "okhttp" } -protobuf-java-lite = { module = "com.google.protobuf:protobuf-javalite", version.ref = "protoc" } -protobuf-kotlin-lite = { module = "com.google.protobuf:protobuf-kotlin-lite", version.ref = "protoc" } +protobuf-java = { module = "com.google.protobuf:protobuf-java", version.ref = "protoc" } +protobuf-java-util = { module = "com.google.protobuf:protobuf-java-util", version.ref = "protoc" } +protobuf-kotlin = { module = "com.google.protobuf:protobuf-kotlin", version.ref = "protoc" } protobuf-protoc = { module = "com.google.protobuf:protoc", version.ref = "protoc" } test-android-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } test-core = { module = "androidx.test:core", version.ref = "androidxTest" }