From a41beee03bc70e495d73f3694f42b0794730dd33 Mon Sep 17 00:00:00 2001 From: Max Starikov Date: Sat, 18 Jul 2026 10:23:30 +0200 Subject: [PATCH] welcome screen --- .../MainActivityInstrumentedTest.kt | 160 ++++++++++++++ .../voiceinbox/AndroidInlineOnboarding.kt | 208 ++++++++++++++++++ .../voiceinbox/AndroidMainScreenStateHost.kt | 10 + .../voiceinbox/AndroidTaskActionRouter.kt | 5 + .../me/maxistar/voiceinbox/MainActivity.kt | 82 ++++--- .../me/maxistar/voiceinbox/TaskListAdapter.kt | 84 ++++++- app/src/main/res/drawable/ic_close_24.xml | 9 + .../main/res/layout/row_onboarding_hint.xml | 91 ++++++++ app/src/main/res/values/strings.xml | 5 + .../voiceinbox/AndroidInlineOnboardingTest.kt | 117 ++++++++++ .../AndroidMainScreenStateHostTest.kt | 44 ++++ .../voiceinbox/AndroidTaskActionRouterTest.kt | 87 ++++++++ .../voiceinbox/TaskListDisplayItemsTest.kt | 55 +++++ .../core/TaskListPresentationController.kt | 2 +- .../TaskListPresentationControllerTest.kt | 2 + 15 files changed, 929 insertions(+), 32 deletions(-) create mode 100644 app/src/main/java/me/maxistar/voiceinbox/AndroidInlineOnboarding.kt create mode 100644 app/src/main/res/drawable/ic_close_24.xml create mode 100644 app/src/main/res/layout/row_onboarding_hint.xml create mode 100644 app/src/test/java/me/maxistar/voiceinbox/AndroidInlineOnboardingTest.kt diff --git a/app/src/androidTest/java/me/maxistar/voiceinbox/MainActivityInstrumentedTest.kt b/app/src/androidTest/java/me/maxistar/voiceinbox/MainActivityInstrumentedTest.kt index 31694a1..99407dd 100644 --- a/app/src/androidTest/java/me/maxistar/voiceinbox/MainActivityInstrumentedTest.kt +++ b/app/src/androidTest/java/me/maxistar/voiceinbox/MainActivityInstrumentedTest.kt @@ -381,6 +381,137 @@ class MainActivityInstrumentedTest { } } + @Test + fun inlineOnboardingAdvancesInPlaceAndDismissalSurvivesRecreation() { + clearOnboardingActivityState() + ActivityScenario.launch(MainActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + setField(activity, "onboardingHintLifecycle", AndroidOnboardingHintLifecycle.ACTIVE) + stateHost(activity).replace(onboardingInput()) + } + awaitActivity(scenario) { activity -> + displayItems(activity).any { it is TaskListDisplayItem.OnboardingHint } + } + scenario.onActivity { activity -> + val items = displayItems(activity) + val hintIndex = items.indexOfFirst { it is TaskListDisplayItem.OnboardingHint } + assertTrue(items.take(hintIndex).all { it is TaskListDisplayItem.Setup }) + val hint = items[hintIndex] as TaskListDisplayItem.OnboardingHint + assertEquals(TaskActionKind.DOWNLOAD_MODEL, hint.presentation.action?.kind) + assertTrue(hint.presentation.downloadDisclosure?.contains("downloads") == true) + } + + scenario.onActivity { activity -> + stateHost(activity).replace( + onboardingInput( + model = me.maxistar.voiceinbox.core.ModelSetupSnapshot( + state = ModelSetupSnapshotState.INSTALLING, + installationPhase = "Installing local model", + canCancel = true, + ), + ), + ) + } + awaitActivity(scenario) { activity -> + displayItems(activity).filterIsInstance() + .singleOrNull()?.presentation?.action?.enabled == false + } + scenario.onActivity { activity -> + val items = displayItems(activity) + val model = items.filterIsInstance().first() + val hint = items.filterIsInstance().single() + assertTrue(model.task.progress != null) + assertTrue(model.task.actions.any { it.kind == TaskActionKind.CANCEL_MODEL_DOWNLOAD }) + assertEquals("Installing speech model…", hint.presentation.action?.label) + } + + scenario.onActivity { activity -> + stateHost(activity).replace( + onboardingInput( + model = me.maxistar.voiceinbox.core.ModelSetupSnapshot(ModelSetupSnapshotState.READY), + ), + ) + } + awaitActivity(scenario) { activity -> + displayItems(activity).filterIsInstance() + .singleOrNull()?.presentation?.action?.kind == TaskActionKind.SELECT_OUTPUT + } + scenario.onActivity { activity -> + stateHost(activity).replace( + onboardingInput( + model = me.maxistar.voiceinbox.core.ModelSetupSnapshot(ModelSetupSnapshotState.READY), + output = me.maxistar.voiceinbox.core.OutputSetupSnapshot( + me.maxistar.voiceinbox.core.OutputSetupSnapshotState.READY, + ), + ), + ) + } + awaitActivity(scenario) { activity -> + displayItems(activity).filterIsInstance() + .singleOrNull()?.presentation?.action?.kind == TaskActionKind.SELECT_FOLDER + } + + scenario.onActivity { activity -> + activity.findViewById(R.id.onboardingClose).performClick() + } + awaitActivity(scenario) { activity -> + displayItems(activity).none { it is TaskListDisplayItem.OnboardingHint } + } + val context = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals( + AndroidOnboardingHintLifecycle.DISMISSED, + AndroidOnboardingHintStore( + context.getSharedPreferences(AndroidOnboardingHintStore.PREFERENCES_NAME, Context.MODE_PRIVATE), + ).load(), + ) + + scenario.recreate() + awaitActivity(scenario) { activity -> stateHost(activity).currentInput.hydration.modelKnown } + scenario.onActivity { activity -> + assertTrue(displayItems(activity).none { it is TaskListDisplayItem.OnboardingHint }) + } + } + } + + @Test + fun inlineOnboardingCompletesOnlyFromHydratedReadySetupAndDoesNotReplay() { + clearOnboardingActivityState() + ActivityScenario.launch(MainActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + setField(activity, "onboardingHintLifecycle", AndroidOnboardingHintLifecycle.ACTIVE) + setField(activity, "modelSetupState", ModelSetupSnapshotState.READY) + setField(activity, "modelReady", true) + setField(activity, "outputUri", Uri.parse("content://test/output")) + setField(activity, "outputAccessReady", true) + setField(activity, "folderUri", Uri.parse("content://test/folder")) + setField(activity, "folderAccessReady", true) + setField(activity, "modelPresentationKnown", true) + setField(activity, "outputPresentationKnown", true) + setField(activity, "folderPresentationKnown", true) + invoke(activity, "publishTaskState") + } + awaitActivity(scenario) { activity -> + stateHost(activity).currentInput.onboardingLifecycle == AndroidOnboardingHintLifecycle.COMPLETED + } + val context = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals( + AndroidOnboardingHintLifecycle.COMPLETED, + AndroidOnboardingHintStore( + context.getSharedPreferences(AndroidOnboardingHintStore.PREFERENCES_NAME, Context.MODE_PRIVATE), + ).load(), + ) + + scenario.onActivity { activity -> + setField(activity, "modelSetupState", ModelSetupSnapshotState.REQUIRED) + setField(activity, "modelReady", false) + invoke(activity, "publishTaskState") + } + awaitActivity(scenario) { activity -> + displayItems(activity).none { it is TaskListDisplayItem.OnboardingHint } + } + } + } + private fun clearActivityState() { val context = InstrumentationRegistry.getInstrumentation().targetContext WorkManager.getInstance(context).cancelUniqueWork(TranscriptionWorker.UNIQUE_WORK_NAME).result.get(30, TimeUnit.SECONDS) @@ -389,10 +520,39 @@ class MainActivityInstrumentedTest { context.getSharedPreferences("speech_model_import", Context.MODE_PRIVATE).edit().clear().commit() context.getSharedPreferences(DocumentSelectionStore.PREFERENCES_NAME, Context.MODE_PRIVATE).edit().clear().commit() context.getSharedPreferences(StartupProcessingPolicyStore.PREFERENCES_NAME, Context.MODE_PRIVATE).edit().clear().commit() + context.getSharedPreferences(AndroidOnboardingHintStore.PREFERENCES_NAME, Context.MODE_PRIVATE).edit().clear().commit() + context.deleteDatabase(AndroidSqlDelightAudioCatalogFactory.DATABASE_NAME) + java.io.File(context.filesDir, AndroidAudioImportConstants.DIRECTORY_NAME).deleteRecursively() + } + + private fun clearOnboardingActivityState() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + context.getSharedPreferences("speech_model_import", Context.MODE_PRIVATE).edit().clear().commit() + context.getSharedPreferences(DocumentSelectionStore.PREFERENCES_NAME, Context.MODE_PRIVATE).edit().clear().commit() + context.getSharedPreferences(StartupProcessingPolicyStore.PREFERENCES_NAME, Context.MODE_PRIVATE).edit().clear().commit() + context.getSharedPreferences(AndroidOnboardingHintStore.PREFERENCES_NAME, Context.MODE_PRIVATE).edit().clear().commit() context.deleteDatabase(AndroidSqlDelightAudioCatalogFactory.DATABASE_NAME) java.io.File(context.filesDir, AndroidAudioImportConstants.DIRECTORY_NAME).deleteRecursively() } + private fun onboardingInput( + model: me.maxistar.voiceinbox.core.ModelSetupSnapshot = me.maxistar.voiceinbox.core.ModelSetupSnapshot( + ModelSetupSnapshotState.REQUIRED, + downloadAvailable = true, + ), + output: me.maxistar.voiceinbox.core.OutputSetupSnapshot = me.maxistar.voiceinbox.core.OutputSetupSnapshot( + me.maxistar.voiceinbox.core.OutputSetupSnapshotState.REQUIRED, + ), + ) = AndroidMainScreenInput( + model = model, + output = output, + folder = me.maxistar.voiceinbox.core.FolderSetupSnapshot( + me.maxistar.voiceinbox.core.FolderSetupSnapshotState.UNSELECTED, + ), + hydration = AndroidMainScreenHydration(true, true, true, true), + onboardingLifecycle = AndroidOnboardingHintLifecycle.ACTIVE, + ) + private fun seedCatalogEntry( source: Uri, displayName: String, diff --git a/app/src/main/java/me/maxistar/voiceinbox/AndroidInlineOnboarding.kt b/app/src/main/java/me/maxistar/voiceinbox/AndroidInlineOnboarding.kt new file mode 100644 index 0000000..d3bdcb6 --- /dev/null +++ b/app/src/main/java/me/maxistar/voiceinbox/AndroidInlineOnboarding.kt @@ -0,0 +1,208 @@ +package me.maxistar.voiceinbox + +import android.content.SharedPreferences +import me.maxistar.voiceinbox.core.FolderSetupSnapshot +import me.maxistar.voiceinbox.core.FolderSetupSnapshotState +import me.maxistar.voiceinbox.core.ModelSetupSnapshot +import me.maxistar.voiceinbox.core.ModelSetupSnapshotState +import me.maxistar.voiceinbox.core.OutputSetupSnapshot +import me.maxistar.voiceinbox.core.OutputSetupSnapshotState +import me.maxistar.voiceinbox.core.TaskActionKind +import me.maxistar.voiceinbox.core.TaskListFilter + +enum class AndroidOnboardingHintLifecycle { + ACTIVE, + DISMISSED, + COMPLETED, +} + +interface AndroidOnboardingHintStorage { + fun loadRaw(): String? + fun saveRaw(value: String) +} + +class AndroidOnboardingHintStore( + private val storage: AndroidOnboardingHintStorage, +) { + constructor(preferences: SharedPreferences) : this( + SharedPreferencesAndroidOnboardingHintStorage(preferences), + ) + + fun load(): AndroidOnboardingHintLifecycle = when (storage.loadRaw()) { + VALUE_DISMISSED -> AndroidOnboardingHintLifecycle.DISMISSED + VALUE_COMPLETED -> AndroidOnboardingHintLifecycle.COMPLETED + else -> AndroidOnboardingHintLifecycle.ACTIVE + } + + fun save(lifecycle: AndroidOnboardingHintLifecycle) { + storage.saveRaw( + when (lifecycle) { + AndroidOnboardingHintLifecycle.ACTIVE -> VALUE_ACTIVE + AndroidOnboardingHintLifecycle.DISMISSED -> VALUE_DISMISSED + AndroidOnboardingHintLifecycle.COMPLETED -> VALUE_COMPLETED + }, + ) + } + + companion object { + const val PREFERENCES_NAME = "android_inline_onboarding" + private const val VALUE_ACTIVE = "active" + private const val VALUE_DISMISSED = "dismissed" + private const val VALUE_COMPLETED = "completed" + } +} + +private class SharedPreferencesAndroidOnboardingHintStorage( + private val preferences: SharedPreferences, +) : AndroidOnboardingHintStorage { + override fun loadRaw(): String? = preferences.getString(KEY_LIFECYCLE, null) + + override fun saveRaw(value: String) { + preferences.edit().putString(KEY_LIFECYCLE, value).apply() + } + + companion object { + private const val KEY_LIFECYCLE = "hint_lifecycle" + } +} + +enum class AndroidOnboardingStepKind { + MODEL, + OUTPUT, + FOLDER, +} + +data class AndroidOnboardingChecklistStep( + val kind: AndroidOnboardingStepKind, + val label: String, + val complete: Boolean, + val optional: Boolean = false, +) + +data class AndroidOnboardingHintAction( + val label: String, + val enabled: Boolean, + val kind: TaskActionKind, +) + +data class AndroidOnboardingHintPresentation( + val visible: Boolean = false, + val title: String = "Set up Voice Inbox", + val explanation: String = "Follow these steps, or use the setup tasks above in any order.", + val downloadDisclosure: String? = null, + val steps: List = emptyList(), + val action: AndroidOnboardingHintAction? = null, +) { + companion object { + val HIDDEN = AndroidOnboardingHintPresentation() + } +} + +object AndroidOnboardingHintPresenter { + fun present( + lifecycle: AndroidOnboardingHintLifecycle, + filter: TaskListFilter, + hydration: AndroidMainScreenHydration, + model: ModelSetupSnapshot, + output: OutputSetupSnapshot, + folder: FolderSetupSnapshot, + ): AndroidOnboardingHintPresentation { + if ( + lifecycle != AndroidOnboardingHintLifecycle.ACTIVE || + filter != TaskListFilter.NEW || + !setupKnown(hydration) || + allStepsComplete(model, output, folder) + ) { + return AndroidOnboardingHintPresentation.HIDDEN + } + + val steps = listOf( + AndroidOnboardingChecklistStep( + kind = AndroidOnboardingStepKind.MODEL, + label = "Install speech model", + complete = model.state == ModelSetupSnapshotState.READY, + ), + AndroidOnboardingChecklistStep( + kind = AndroidOnboardingStepKind.OUTPUT, + label = "Select transcript output", + complete = output.state == OutputSetupSnapshotState.READY, + ), + AndroidOnboardingChecklistStep( + kind = AndroidOnboardingStepKind.FOLDER, + label = "Select audio folder · Optional", + complete = folder.state == FolderSetupSnapshotState.READY, + optional = true, + ), + ) + val action = nextAction(model, output, folder) + return AndroidOnboardingHintPresentation( + visible = true, + steps = steps, + downloadDisclosure = "Start setup downloads the speech model to this device." + .takeIf { + action.enabled && action.kind in setOf( + TaskActionKind.DOWNLOAD_MODEL, + TaskActionKind.RETRY_MODEL_DOWNLOAD, + ) + }, + action = action, + ) + } + + fun shouldComplete( + lifecycle: AndroidOnboardingHintLifecycle, + hydration: AndroidMainScreenHydration, + model: ModelSetupSnapshot, + output: OutputSetupSnapshot, + folder: FolderSetupSnapshot, + ): Boolean = lifecycle == AndroidOnboardingHintLifecycle.ACTIVE && + setupKnown(hydration) && + allStepsComplete(model, output, folder) + + private fun setupKnown(hydration: AndroidMainScreenHydration): Boolean = + hydration.modelKnown && hydration.outputKnown && hydration.folderKnown + + private fun allStepsComplete( + model: ModelSetupSnapshot, + output: OutputSetupSnapshot, + folder: FolderSetupSnapshot, + ): Boolean = model.state == ModelSetupSnapshotState.READY && + output.state == OutputSetupSnapshotState.READY && + folder.state == FolderSetupSnapshotState.READY + + private fun nextAction( + model: ModelSetupSnapshot, + output: OutputSetupSnapshot, + folder: FolderSetupSnapshot, + ): AndroidOnboardingHintAction = when { + model.state == ModelSetupSnapshotState.INSTALLING -> AndroidOnboardingHintAction( + label = "Installing speech model…", + enabled = false, + kind = TaskActionKind.DOWNLOAD_MODEL, + ) + model.state != ModelSetupSnapshotState.READY && model.downloadAvailable -> AndroidOnboardingHintAction( + label = if (model.state == ModelSetupSnapshotState.INVALID) "Retry setup" else "Start setup", + enabled = true, + kind = if (model.state == ModelSetupSnapshotState.INVALID) { + TaskActionKind.RETRY_MODEL_DOWNLOAD + } else { + TaskActionKind.DOWNLOAD_MODEL + }, + ) + model.state != ModelSetupSnapshotState.READY -> AndroidOnboardingHintAction( + label = "Install model from folder", + enabled = true, + kind = TaskActionKind.IMPORT_MODEL, + ) + output.state != OutputSetupSnapshotState.READY -> AndroidOnboardingHintAction( + label = "Select output file", + enabled = true, + kind = TaskActionKind.SELECT_OUTPUT, + ) + else -> AndroidOnboardingHintAction( + label = "Select audio folder (optional)", + enabled = folder.state != FolderSetupSnapshotState.SCANNING, + kind = TaskActionKind.SELECT_FOLDER, + ) + } +} diff --git a/app/src/main/java/me/maxistar/voiceinbox/AndroidMainScreenStateHost.kt b/app/src/main/java/me/maxistar/voiceinbox/AndroidMainScreenStateHost.kt index 795d378..f1c48aa 100644 --- a/app/src/main/java/me/maxistar/voiceinbox/AndroidMainScreenStateHost.kt +++ b/app/src/main/java/me/maxistar/voiceinbox/AndroidMainScreenStateHost.kt @@ -34,6 +34,7 @@ data class AndroidMainScreenInput( val importEnabled: Boolean = true, val hydration: AndroidMainScreenHydration = AndroidMainScreenHydration(), val folderSync: AndroidFolderSyncPresentation = AndroidFolderSyncPresentation(), + val onboardingLifecycle: AndroidOnboardingHintLifecycle = AndroidOnboardingHintLifecycle.DISMISSED, ) data class AndroidMainScreenHydration( @@ -67,6 +68,7 @@ data class AndroidMainScreenState( val entriesById: Map, val importEnabled: Boolean, val folderSync: AndroidFolderSyncPresentation, + val onboardingHint: AndroidOnboardingHintPresentation, ) { val refreshFolderVisible: Boolean get() = folderSync.visible val refreshFolderEnabled: Boolean get() = folderSync.enabled @@ -114,6 +116,14 @@ object AndroidTaskListSnapshotMapper { entriesById = input.entries.associateBy(AudioCatalogEntry::id), importEnabled = input.importEnabled, folderSync = input.folderSync, + onboardingHint = AndroidOnboardingHintPresenter.present( + lifecycle = input.onboardingLifecycle, + filter = input.filter, + hydration = input.hydration, + model = input.model, + output = input.output, + folder = input.folder, + ), ) } diff --git a/app/src/main/java/me/maxistar/voiceinbox/AndroidTaskActionRouter.kt b/app/src/main/java/me/maxistar/voiceinbox/AndroidTaskActionRouter.kt index 367a020..5d0992b 100644 --- a/app/src/main/java/me/maxistar/voiceinbox/AndroidTaskActionRouter.kt +++ b/app/src/main/java/me/maxistar/voiceinbox/AndroidTaskActionRouter.kt @@ -21,6 +21,11 @@ class AndroidTaskActionRouter( fun route(request: AndroidTaskActionRequest): Boolean { val state = currentState() val authorized = when { + request.stableId == TaskListDisplayItem.OnboardingHint.STABLE_KEY -> + state.onboardingHint.visible && + state.onboardingHint.action?.let { action -> + action.kind == request.kind && action.enabled + } == true request.kind == TaskActionKind.TRANSCRIBE_ALL -> request.stableId == TaskListDisplayItem.BatchAction.STABLE_KEY && state.taskList.batchAction.visible && diff --git a/app/src/main/java/me/maxistar/voiceinbox/MainActivity.kt b/app/src/main/java/me/maxistar/voiceinbox/MainActivity.kt index ea1bd12..6ae5a19 100644 --- a/app/src/main/java/me/maxistar/voiceinbox/MainActivity.kt +++ b/app/src/main/java/me/maxistar/voiceinbox/MainActivity.kt @@ -53,6 +53,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen private lateinit var modelReadiness: SpeechModelReadinessManager private lateinit var startupPolicyStore: StartupProcessingPolicyStore private lateinit var startupCoordinator: StartupProcessingCoordinator + private lateinit var onboardingHintStore: AndroidOnboardingHintStore private val folderExecutor = Executors.newSingleThreadExecutor() private val importExecutor = Executors.newSingleThreadExecutor() @@ -117,6 +118,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen private val startRefreshIndicator = Runnable(::startRefreshIndicatorAnimation) private val stopRefreshIndicator = Runnable(::stopRefreshIndicatorAnimation) private val queuedImportUris = mutableListOf() + private var onboardingHintLifecycle = AndroidOnboardingHintLifecycle.DISMISSED private val outputPicker = registerForActivityResult( ActivityResultContracts.OpenDocument(), @@ -165,6 +167,10 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen startupPolicyStore = StartupProcessingPolicyStore( getSharedPreferences(StartupProcessingPolicyStore.PREFERENCES_NAME, MODE_PRIVATE), ) + onboardingHintStore = AndroidOnboardingHintStore( + getSharedPreferences(AndroidOnboardingHintStore.PREFERENCES_NAME, MODE_PRIVATE), + ) + onboardingHintLifecycle = onboardingHintStore.load() startupCoordinator = StartupProcessingCoordinator.restore( savedInstanceState?.getString(STATE_STARTUP_PROCESSING_STAGE), ) @@ -277,7 +283,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen allTab = findViewById(R.id.allTab) taskFilters = findViewById(R.id.taskFilters) taskList = findViewById(R.id.taskList) - taskAdapter = TaskListAdapter(::handleTaskAction) + taskAdapter = TaskListAdapter(::handleTaskAction, ::dismissOnboardingHint) taskList.layoutManager = LinearLayoutManager(this) taskList.adapter = taskAdapter (taskList.itemAnimator as? SimpleItemAnimator)?.supportsChangeAnimations = false @@ -1238,26 +1244,47 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen ) val controls = selectionControls() val folderSyncState = taskStateHost.folderSyncCoordinator.state + val modelSnapshot = ModelSetupSnapshot( + state = modelSetupState, + detail = androidModelTaskDetail(modelSetupState, modelMessage), + installationPhase = modelMessage.takeIf { + modelSetupState == ModelSetupSnapshotState.INSTALLING + }, + progressPercent = modelDownloadProgress, + downloadAvailable = modelDownloadAvailable, + canCancel = modelInstallCanCancel, + ) + val outputSnapshot = OutputSetupSnapshot( + state = outputState, + detail = outputAccessError ?: outputDisplayName, + ) + val folderSnapshot = FolderSetupSnapshot( + state = folderState, + detail = folderAccessError ?: scanMessage ?: folderDisplayName, + ) + val hydration = AndroidMainScreenHydration( + modelKnown = modelPresentationKnown, + outputKnown = outputPresentationKnown, + folderKnown = folderPresentationKnown, + catalogKnown = catalogPresentationKnown, + ) + if ( + AndroidOnboardingHintPresenter.shouldComplete( + onboardingHintLifecycle, + hydration, + modelSnapshot, + outputSnapshot, + folderSnapshot, + ) + ) { + onboardingHintLifecycle = AndroidOnboardingHintLifecycle.COMPLETED + onboardingHintStore.save(onboardingHintLifecycle) + } taskStateHost.update { current -> current.copy( - model = ModelSetupSnapshot( - state = modelSetupState, - detail = androidModelTaskDetail(modelSetupState, modelMessage), - installationPhase = modelMessage.takeIf { - modelSetupState == ModelSetupSnapshotState.INSTALLING - }, - progressPercent = modelDownloadProgress, - downloadAvailable = modelDownloadAvailable, - canCancel = modelInstallCanCancel, - ), - output = OutputSetupSnapshot( - state = outputState, - detail = outputAccessError ?: outputDisplayName, - ), - folder = FolderSetupSnapshot( - state = folderState, - detail = folderAccessError ?: scanMessage ?: folderDisplayName, - ), + model = modelSnapshot, + output = outputSnapshot, + folder = folderSnapshot, entries = currentEntries, preview = PreviewTaskSnapshot( activeEntryId = previewEntryId, @@ -1267,12 +1294,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen transcriptionEligible = eligible, previewEligible = !folderBusy() && !transcriptionActive(), importEnabled = !ingestionActive, - hydration = AndroidMainScreenHydration( - modelKnown = modelPresentationKnown, - outputKnown = outputPresentationKnown, - folderKnown = folderPresentationKnown, - catalogKnown = catalogPresentationKnown, - ), + hydration = hydration, folderSync = AndroidFolderSyncPresentation( visible = folderUri != null || folderSyncState.active, active = folderSyncState.active, @@ -1283,6 +1305,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen AndroidFolderSyncPresentation.ACCESSIBILITY_REFRESH }, ), + onboardingLifecycle = onboardingHintLifecycle, ) } } @@ -1298,7 +1321,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen ) renderingFilter = false importAudio.isEnabled = state.importEnabled - taskAdapter.submitList(TaskListDisplayItems.from(state.taskList)) + taskAdapter.submitList(TaskListDisplayItems.from(state.taskList, state.onboardingHint)) invalidateOptionsMenu() } @@ -1306,6 +1329,13 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen taskActionRouter.route(request) } + private fun dismissOnboardingHint() { + if (onboardingHintLifecycle != AndroidOnboardingHintLifecycle.ACTIVE) return + onboardingHintLifecycle = AndroidOnboardingHintLifecycle.DISMISSED + onboardingHintStore.save(onboardingHintLifecycle) + publishTaskState() + } + private fun performTaskAction(kind: TaskActionKind, entry: AudioCatalogEntry?) { when (kind) { TaskActionKind.DOWNLOAD_MODEL, diff --git a/app/src/main/java/me/maxistar/voiceinbox/TaskListAdapter.kt b/app/src/main/java/me/maxistar/voiceinbox/TaskListAdapter.kt index 787eec7..c5706ed 100644 --- a/app/src/main/java/me/maxistar/voiceinbox/TaskListAdapter.kt +++ b/app/src/main/java/me/maxistar/voiceinbox/TaskListAdapter.kt @@ -4,6 +4,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.LinearLayout +import android.widget.ImageButton import android.widget.ProgressBar import android.widget.TextView import androidx.core.view.isVisible @@ -45,6 +46,16 @@ sealed class TaskListDisplayItem { } } + data class OnboardingHint( + val presentation: AndroidOnboardingHintPresentation, + ) : TaskListDisplayItem() { + override val stableKey: String = STABLE_KEY + + companion object { + const val STABLE_KEY = "hint:android-onboarding" + } + } + data class Empty( val message: String, val actions: List, @@ -58,15 +69,17 @@ sealed class TaskListDisplayItem { } object TaskListDisplayItems { - fun from(state: TaskListState): List { - val emptyMessage = state.emptyMessage - if (emptyMessage != null) { - return listOf(TaskListDisplayItem.Empty(emptyMessage, state.emptyActions)) - } + fun from( + state: TaskListState, + onboardingHint: AndroidOnboardingHintPresentation = AndroidOnboardingHintPresentation.HIDDEN, + ): List { return buildList { state.tasks.filterIsInstance().forEach { add(TaskListDisplayItem.Setup(it)) } + if (onboardingHint.visible) { + add(TaskListDisplayItem.OnboardingHint(onboardingHint)) + } if (state.batchAction.visible) { add( TaskListDisplayItem.BatchAction( @@ -78,6 +91,9 @@ object TaskListDisplayItems { state.tasks.filterIsInstance().forEach { add(TaskListDisplayItem.Audio(it)) } + state.emptyMessage?.let { message -> + add(TaskListDisplayItem.Empty(message, state.emptyActions)) + } } } } @@ -114,6 +130,7 @@ sealed class TaskListChangePayload { class TaskListAdapter( private val onAction: (AndroidTaskActionRequest) -> Unit, + private val onDismissOnboarding: () -> Unit, ) : ListAdapter(TaskListDisplayItemDiff) { init { setHasStableIds(true) @@ -125,6 +142,7 @@ class TaskListAdapter( is TaskListDisplayItem.Setup -> VIEW_SETUP is TaskListDisplayItem.Audio -> VIEW_AUDIO is TaskListDisplayItem.BatchAction -> VIEW_BATCH + is TaskListDisplayItem.OnboardingHint -> VIEW_ONBOARDING is TaskListDisplayItem.Empty -> VIEW_EMPTY } @@ -137,6 +155,11 @@ class TaskListAdapter( inflater.inflate(R.layout.row_batch_action, parent, false), onAction, ) + VIEW_ONBOARDING -> OnboardingHintViewHolder( + inflater.inflate(R.layout.row_onboarding_hint, parent, false), + onAction, + onDismissOnboarding, + ) VIEW_EMPTY -> EmptyTaskViewHolder( inflater.inflate(R.layout.row_empty_task, parent, false), onAction, @@ -150,6 +173,7 @@ class TaskListAdapter( is TaskListDisplayItem.Setup -> (holder as SetupTaskViewHolder).bind(item.task) is TaskListDisplayItem.Audio -> (holder as AudioTaskViewHolder).bind(item.task) is TaskListDisplayItem.BatchAction -> (holder as BatchActionViewHolder).bind(item) + is TaskListDisplayItem.OnboardingHint -> (holder as OnboardingHintViewHolder).bind(item) is TaskListDisplayItem.Empty -> (holder as EmptyTaskViewHolder).bind(item) } } @@ -275,6 +299,55 @@ class TaskListAdapter( } } + private class OnboardingHintViewHolder( + itemView: View, + private val onAction: (AndroidTaskActionRequest) -> Unit, + onDismiss: () -> Unit, + ) : RecyclerView.ViewHolder(itemView) { + private val title: TextView = itemView.findViewById(R.id.onboardingTitle) + private val explanation: TextView = itemView.findViewById(R.id.onboardingExplanation) + private val disclosure: TextView = itemView.findViewById(R.id.onboardingDownloadDisclosure) + private val modelStep: TextView = itemView.findViewById(R.id.onboardingModelStep) + private val outputStep: TextView = itemView.findViewById(R.id.onboardingOutputStep) + private val folderStep: TextView = itemView.findViewById(R.id.onboardingFolderStep) + private val action: MaterialButton = itemView.findViewById(R.id.onboardingAction) + private val close: ImageButton = itemView.findViewById(R.id.onboardingClose) + + init { + close.setOnClickListener { onDismiss() } + } + + fun bind(item: TaskListDisplayItem.OnboardingHint) { + val presentation = item.presentation + title.text = presentation.title + explanation.text = presentation.explanation + disclosure.text = presentation.downloadDisclosure.orEmpty() + disclosure.isVisible = presentation.downloadDisclosure != null + bindStep(modelStep, presentation.steps.first { it.kind == AndroidOnboardingStepKind.MODEL }) + bindStep(outputStep, presentation.steps.first { it.kind == AndroidOnboardingStepKind.OUTPUT }) + bindStep(folderStep, presentation.steps.first { it.kind == AndroidOnboardingStepKind.FOLDER }) + action.text = presentation.action?.label.orEmpty() + action.isEnabled = presentation.action?.enabled == true + action.isVisible = presentation.action != null + action.setOnClickListener { + presentation.action?.let { current -> + onAction(AndroidTaskActionRequest(item.stableKey, null, current.kind)) + } + } + } + + private fun bindStep(view: TextView, step: AndroidOnboardingChecklistStep) { + view.text = itemView.resources.getString( + if (step.complete) R.string.onboarding_step_complete else R.string.onboarding_step_incomplete, + step.label, + ) + view.contentDescription = itemView.resources.getString( + if (step.complete) R.string.onboarding_step_complete_accessibility else R.string.onboarding_step_incomplete_accessibility, + step.label, + ) + } + } + private class EmptyTaskViewHolder( itemView: View, private val onAction: (AndroidTaskActionRequest) -> Unit, @@ -311,6 +384,7 @@ class TaskListAdapter( const val VIEW_AUDIO = 2 const val VIEW_BATCH = 3 const val VIEW_EMPTY = 4 + const val VIEW_ONBOARDING = 5 fun stableLongId(value: String): Long { var hash = -0x340d631b8c46753bL diff --git a/app/src/main/res/drawable/ic_close_24.xml b/app/src/main/res/drawable/ic_close_24.xml new file mode 100644 index 0000000..52445c0 --- /dev/null +++ b/app/src/main/res/drawable/ic_close_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/row_onboarding_hint.xml b/app/src/main/res/layout/row_onboarding_hint.xml new file mode 100644 index 0000000..0b57c7a --- /dev/null +++ b/app/src/main/res/layout/row_onboarding_hint.xml @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a8b59dc..cd48ddd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -57,6 +57,11 @@ All No new audio files Transcribe all + Dismiss setup guide + ✓ %1$s + ○ %1$s + Completed: %1$s + Not completed: %1$s Transcribe all (%1$d file) Transcribe all (%1$d files) diff --git a/app/src/test/java/me/maxistar/voiceinbox/AndroidInlineOnboardingTest.kt b/app/src/test/java/me/maxistar/voiceinbox/AndroidInlineOnboardingTest.kt new file mode 100644 index 0000000..7deea6e --- /dev/null +++ b/app/src/test/java/me/maxistar/voiceinbox/AndroidInlineOnboardingTest.kt @@ -0,0 +1,117 @@ +package me.maxistar.voiceinbox + +import me.maxistar.voiceinbox.core.FolderSetupSnapshot +import me.maxistar.voiceinbox.core.FolderSetupSnapshotState +import me.maxistar.voiceinbox.core.ModelSetupSnapshot +import me.maxistar.voiceinbox.core.ModelSetupSnapshotState +import me.maxistar.voiceinbox.core.OutputSetupSnapshot +import me.maxistar.voiceinbox.core.OutputSetupSnapshotState +import me.maxistar.voiceinbox.core.TaskActionKind +import me.maxistar.voiceinbox.core.TaskListFilter +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AndroidInlineOnboardingTest { + @Test + fun lifecycleDefaultsActiveAndPersistsTerminalValues() { + val storage = FakeStorage() + val store = AndroidOnboardingHintStore(storage) + + assertEquals(AndroidOnboardingHintLifecycle.ACTIVE, store.load()) + store.save(AndroidOnboardingHintLifecycle.DISMISSED) + assertEquals(AndroidOnboardingHintLifecycle.DISMISSED, store.load()) + store.save(AndroidOnboardingHintLifecycle.COMPLETED) + assertEquals(AndroidOnboardingHintLifecycle.COMPLETED, store.load()) + storage.raw = "future-value" + assertEquals(AndroidOnboardingHintLifecycle.ACTIVE, store.load()) + } + + @Test + fun pendingHydrationTerminalLifecycleAndOtherFiltersHideHint() { + assertFalse(present(hydration = AndroidMainScreenHydration()).visible) + assertFalse(present(lifecycle = AndroidOnboardingHintLifecycle.DISMISSED).visible) + assertFalse(present(lifecycle = AndroidOnboardingHintLifecycle.COMPLETED).visible) + assertFalse(present(filter = TaskListFilter.PROCESSED).visible) + assertFalse(present(filter = TaskListFilter.ALL).visible) + } + + @Test + fun modelActionUsesDownloadRetryManualImportAndNeutralInstallingState() { + assertEquals(TaskActionKind.DOWNLOAD_MODEL, present().action?.kind) + assertEquals("Start setup", present().action?.label) + + val invalid = present( + model = ModelSetupSnapshot(ModelSetupSnapshotState.INVALID, downloadAvailable = true), + ) + assertEquals(TaskActionKind.RETRY_MODEL_DOWNLOAD, invalid.action?.kind) + + val manual = present( + model = ModelSetupSnapshot(ModelSetupSnapshotState.REQUIRED, downloadAvailable = false), + ) + assertEquals(TaskActionKind.IMPORT_MODEL, manual.action?.kind) + + val installing = present( + model = ModelSetupSnapshot(ModelSetupSnapshotState.INSTALLING, downloadAvailable = true), + ) + assertFalse(installing.action!!.enabled) + assertEquals("Installing speech model…", installing.action?.label) + } + + @Test + fun directSetupCompletionAdvancesThroughOutputAndOptionalFolder() { + val output = present(model = readyModel()) + assertEquals(TaskActionKind.SELECT_OUTPUT, output.action?.kind) + assertTrue(output.steps.first().complete) + + val folder = present(model = readyModel(), output = readyOutput()) + assertEquals(TaskActionKind.SELECT_FOLDER, folder.action?.kind) + assertTrue(folder.steps.last().optional) + assertTrue(folder.steps.last().label.contains("Optional")) + } + + @Test + fun fullyConfiguredSetupRetiresAndCompletesOnlyAfterHydration() { + val readyFolder = FolderSetupSnapshot(FolderSetupSnapshotState.READY) + assertFalse(present(model = readyModel(), output = readyOutput(), folder = readyFolder).visible) + assertTrue( + AndroidOnboardingHintPresenter.shouldComplete( + AndroidOnboardingHintLifecycle.ACTIVE, + hydrated(), + readyModel(), + readyOutput(), + readyFolder, + ), + ) + assertFalse( + AndroidOnboardingHintPresenter.shouldComplete( + AndroidOnboardingHintLifecycle.ACTIVE, + AndroidMainScreenHydration(), + readyModel(), + readyOutput(), + readyFolder, + ), + ) + } + + private fun present( + lifecycle: AndroidOnboardingHintLifecycle = AndroidOnboardingHintLifecycle.ACTIVE, + filter: TaskListFilter = TaskListFilter.NEW, + hydration: AndroidMainScreenHydration = hydrated(), + model: ModelSetupSnapshot = ModelSetupSnapshot(ModelSetupSnapshotState.REQUIRED, downloadAvailable = true), + output: OutputSetupSnapshot = OutputSetupSnapshot(OutputSetupSnapshotState.REQUIRED), + folder: FolderSetupSnapshot = FolderSetupSnapshot(FolderSetupSnapshotState.UNSELECTED), + ) = AndroidOnboardingHintPresenter.present(lifecycle, filter, hydration, model, output, folder) + + private fun hydrated() = AndroidMainScreenHydration(true, true, true, true) + private fun readyModel() = ModelSetupSnapshot(ModelSetupSnapshotState.READY) + private fun readyOutput() = OutputSetupSnapshot(OutputSetupSnapshotState.READY) + + private class FakeStorage(var raw: String? = null) : AndroidOnboardingHintStorage { + override fun loadRaw(): String? = raw + override fun saveRaw(value: String) { + raw = value + } + } +} diff --git a/app/src/test/java/me/maxistar/voiceinbox/AndroidMainScreenStateHostTest.kt b/app/src/test/java/me/maxistar/voiceinbox/AndroidMainScreenStateHostTest.kt index 1230776..31dede5 100644 --- a/app/src/test/java/me/maxistar/voiceinbox/AndroidMainScreenStateHostTest.kt +++ b/app/src/test/java/me/maxistar/voiceinbox/AndroidMainScreenStateHostTest.kt @@ -183,6 +183,50 @@ class AndroidMainScreenStateHostTest { assertTrue(state.taskList.tasks.isEmpty()) assertNull(state.taskList.emptyMessage) assertTrue(state.taskList.emptyActions.isEmpty()) + assertFalse(state.onboardingHint.visible) + } + + @Test + fun onboardingPresentationUsesCurrentSnapshotsAndFilterWithoutMutatingSetupTasks() { + val initial = AndroidTaskListSnapshotMapper.state( + AndroidMainScreenInput( + model = ModelSetupSnapshot(ModelSetupSnapshotState.REQUIRED, downloadAvailable = true), + output = OutputSetupSnapshot(OutputSetupSnapshotState.REQUIRED), + folder = FolderSetupSnapshot(FolderSetupSnapshotState.UNSELECTED), + hydration = hydrated(), + onboardingLifecycle = AndroidOnboardingHintLifecycle.ACTIVE, + ), + ) + assertTrue(initial.onboardingHint.visible) + assertEquals(TaskActionKind.DOWNLOAD_MODEL, initial.onboardingHint.action?.kind) + assertEquals( + listOf("setup:model", "setup:output"), + initial.taskList.tasks.map { it.stableId }, + ) + + val directModelCompletion = AndroidTaskListSnapshotMapper.state( + AndroidMainScreenInput( + model = ModelSetupSnapshot(ModelSetupSnapshotState.READY), + output = OutputSetupSnapshot(OutputSetupSnapshotState.REQUIRED), + folder = FolderSetupSnapshot(FolderSetupSnapshotState.UNSELECTED), + hydration = hydrated(), + onboardingLifecycle = AndroidOnboardingHintLifecycle.ACTIVE, + ), + ) + assertEquals(TaskActionKind.SELECT_OUTPUT, directModelCompletion.onboardingHint.action?.kind) + assertEquals(listOf("setup:output"), directModelCompletion.taskList.tasks.map { it.stableId }) + + val allFilter = AndroidTaskListSnapshotMapper.state( + AndroidMainScreenInput( + filter = TaskListFilter.ALL, + model = ModelSetupSnapshot(ModelSetupSnapshotState.REQUIRED), + output = OutputSetupSnapshot(OutputSetupSnapshotState.REQUIRED), + folder = FolderSetupSnapshot(FolderSetupSnapshotState.UNSELECTED), + hydration = hydrated(), + onboardingLifecycle = AndroidOnboardingHintLifecycle.ACTIVE, + ), + ) + assertFalse(allFilter.onboardingHint.visible) } @Test diff --git a/app/src/test/java/me/maxistar/voiceinbox/AndroidTaskActionRouterTest.kt b/app/src/test/java/me/maxistar/voiceinbox/AndroidTaskActionRouterTest.kt index 5ab31cb..300a260 100644 --- a/app/src/test/java/me/maxistar/voiceinbox/AndroidTaskActionRouterTest.kt +++ b/app/src/test/java/me/maxistar/voiceinbox/AndroidTaskActionRouterTest.kt @@ -75,6 +75,74 @@ class AndroidTaskActionRouterTest { assertEquals(processed, routed?.second) } + @Test + fun routesOnlyCurrentEnabledOnboardingActionIncludingOptionalFolder() { + var state = onboardingState( + model = ModelSetupSnapshot(ModelSetupSnapshotState.REQUIRED, downloadAvailable = true), + ) + val calls = mutableListOf() + val router = AndroidTaskActionRouter({ state }) { kind, _ -> calls += kind } + + assertTrue( + router.route( + request(TaskListDisplayItem.OnboardingHint.STABLE_KEY, null, TaskActionKind.DOWNLOAD_MODEL), + ), + ) + assertFalse( + router.route( + request(TaskListDisplayItem.OnboardingHint.STABLE_KEY, null, TaskActionKind.SELECT_OUTPUT), + ), + ) + + state = onboardingState( + model = ModelSetupSnapshot(ModelSetupSnapshotState.READY), + output = OutputSetupSnapshot(OutputSetupSnapshotState.READY), + folder = FolderSetupSnapshot(FolderSetupSnapshotState.UNSELECTED), + ) + assertTrue(state.taskList.tasks.none { it.stableId == "setup:folder" }) + assertTrue( + router.route( + request(TaskListDisplayItem.OnboardingHint.STABLE_KEY, null, TaskActionKind.SELECT_FOLDER), + ), + ) + + state = onboardingState( + model = ModelSetupSnapshot(ModelSetupSnapshotState.INSTALLING), + ) + assertFalse( + router.route( + request(TaskListDisplayItem.OnboardingHint.STABLE_KEY, null, TaskActionKind.DOWNLOAD_MODEL), + ), + ) + assertEquals(listOf(TaskActionKind.DOWNLOAD_MODEL, TaskActionKind.SELECT_FOLDER), calls) + } + + @Test + fun terminalOrCompletedOnboardingRejectsStaleClick() { + var state = onboardingState( + model = ModelSetupSnapshot(ModelSetupSnapshotState.REQUIRED, downloadAvailable = true), + ) + var callCount = 0 + val router = AndroidTaskActionRouter({ state }) { _, _ -> callCount++ } + val stale = request(TaskListDisplayItem.OnboardingHint.STABLE_KEY, null, TaskActionKind.DOWNLOAD_MODEL) + + state = AndroidTaskListSnapshotMapper.state( + stateInput( + model = ModelSetupSnapshot(ModelSetupSnapshotState.REQUIRED, downloadAvailable = true), + lifecycle = AndroidOnboardingHintLifecycle.DISMISSED, + ), + ) + assertFalse(router.route(stale)) + + state = onboardingState( + model = ModelSetupSnapshot(ModelSetupSnapshotState.READY), + output = OutputSetupSnapshot(OutputSetupSnapshotState.READY), + folder = FolderSetupSnapshot(FolderSetupSnapshotState.READY), + ) + assertFalse(router.route(stale)) + assertEquals(0, callCount) + } + private fun state( modelReady: Boolean = true, filter: TaskListFilter = TaskListFilter.NEW, @@ -94,6 +162,25 @@ class AndroidTaskActionRouterTest { ), ) + private fun onboardingState( + model: ModelSetupSnapshot, + output: OutputSetupSnapshot = OutputSetupSnapshot(OutputSetupSnapshotState.REQUIRED), + folder: FolderSetupSnapshot = FolderSetupSnapshot(FolderSetupSnapshotState.UNSELECTED), + ) = AndroidTaskListSnapshotMapper.state(stateInput(model, output, folder)) + + private fun stateInput( + model: ModelSetupSnapshot, + output: OutputSetupSnapshot = OutputSetupSnapshot(OutputSetupSnapshotState.REQUIRED), + folder: FolderSetupSnapshot = FolderSetupSnapshot(FolderSetupSnapshotState.UNSELECTED), + lifecycle: AndroidOnboardingHintLifecycle = AndroidOnboardingHintLifecycle.ACTIVE, + ) = AndroidMainScreenInput( + model = model, + output = output, + folder = folder, + hydration = AndroidMainScreenHydration(true, true, true, true), + onboardingLifecycle = lifecycle, + ) + private fun request(stableId: String, entryId: Long?, kind: TaskActionKind) = AndroidTaskActionRequest(stableId, entryId, kind) diff --git a/app/src/test/java/me/maxistar/voiceinbox/TaskListDisplayItemsTest.kt b/app/src/test/java/me/maxistar/voiceinbox/TaskListDisplayItemsTest.kt index 4634874..623cd5c 100644 --- a/app/src/test/java/me/maxistar/voiceinbox/TaskListDisplayItemsTest.kt +++ b/app/src/test/java/me/maxistar/voiceinbox/TaskListDisplayItemsTest.kt @@ -101,12 +101,56 @@ class TaskListDisplayItemsTest { ) } + @Test + fun onboardingHintIsStableAndOrderedAfterSetupBeforeBatchAudioAndEmpty() { + val hint = onboardingHint() + val withWork = items( + model = ModelSetupSnapshot(ModelSetupSnapshotState.REQUIRED, downloadAvailable = true), + output = OutputSetupSnapshot(OutputSetupSnapshotState.REQUIRED), + audio = listOf(pending(1), pending(2), pending(3)), + onboardingHint = hint, + ) + assertEquals( + listOf( + "setup:model", + "setup:output", + TaskListDisplayItem.OnboardingHint.STABLE_KEY, + TaskListDisplayItem.BatchAction.STABLE_KEY, + "audio:3", + "audio:2", + "audio:1", + ), + withWork.map { it.stableKey }, + ) + + val empty = items(onboardingHint = hint) + assertTrue(empty[0] is TaskListDisplayItem.OnboardingHint) + assertTrue(empty[1] is TaskListDisplayItem.Empty) + + val changedHint = hint.copy( + steps = hint.steps.mapIndexed { index, step -> step.copy(complete = index == 0) }, + action = AndroidOnboardingHintAction("Select output file", true, TaskActionKind.SELECT_OUTPUT), + ) + val oldItem = empty.first() + val newItem = items(onboardingHint = changedHint).first() + assertTrue(TaskListDisplayItemDiff.areItemsTheSame(oldItem, newItem)) + assertFalse(TaskListDisplayItemDiff.areContentsTheSame(oldItem, newItem)) + } + + @Test + fun absentOrIneligibleHintLeavesProcessedAndAllListsUnchanged() { + val hidden = items(filter = TaskListFilter.PROCESSED, onboardingHint = AndroidOnboardingHintPresentation.HIDDEN) + assertTrue(hidden.single() is TaskListDisplayItem.Empty) + assertTrue(items(filter = TaskListFilter.ALL).none { it is TaskListDisplayItem.OnboardingHint }) + } + private fun items( filter: TaskListFilter = TaskListFilter.NEW, model: ModelSetupSnapshot = ModelSetupSnapshot(ModelSetupSnapshotState.READY), output: OutputSetupSnapshot = OutputSetupSnapshot(OutputSetupSnapshotState.READY), audio: List = emptyList(), transcription: TranscriptionTaskSnapshot = TranscriptionTaskSnapshot(), + onboardingHint: AndroidOnboardingHintPresentation = AndroidOnboardingHintPresentation.HIDDEN, ): List = TaskListDisplayItems.from( TaskListPresentationController.state( TaskListInput( @@ -118,6 +162,17 @@ class TaskListDisplayItemsTest { transcription = transcription, ), ), + onboardingHint, + ) + + private fun onboardingHint() = AndroidOnboardingHintPresentation( + visible = true, + steps = listOf( + AndroidOnboardingChecklistStep(AndroidOnboardingStepKind.MODEL, "Install speech model", false), + AndroidOnboardingChecklistStep(AndroidOnboardingStepKind.OUTPUT, "Select transcript output", false), + AndroidOnboardingChecklistStep(AndroidOnboardingStepKind.FOLDER, "Select audio folder · Optional", false, true), + ), + action = AndroidOnboardingHintAction("Start setup", true, TaskActionKind.DOWNLOAD_MODEL), ) private fun pending(id: Long) = AudioTaskSnapshot( diff --git a/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationController.kt b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationController.kt index 40ac0fc..79c55d8 100644 --- a/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationController.kt +++ b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationController.kt @@ -274,7 +274,7 @@ object TaskListPresentationController { else -> SetupTaskState.REQUIRED }, title = "Install Speech Model", - detail = snapshot.detail, + detail = snapshot.detail.takeUnless { active }, badge = if (active) "Installing" else if (error) "Needs attention" else "Required", progress = if (active) { TaskProgressPresentation( diff --git a/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationControllerTest.kt b/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationControllerTest.kt index 228e1f3..ec28530 100644 --- a/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationControllerTest.kt +++ b/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationControllerTest.kt @@ -60,11 +60,13 @@ class TaskListPresentationControllerTest { state( model = ModelSetupSnapshot( state = ModelSetupSnapshotState.INSTALLING, + detail = "Installing from selected folder", installationPhase = "Verifying local model", progressPercent = 75, ), ).tasks.single(), ) + assertNull(localImport.detail) assertEquals("Verifying local model", localImport.progress?.phase) assertTrue(localImport.actions.isEmpty())