diff --git a/app/src/main/java/com/willowtree/vocable/AppKoinModule.kt b/app/src/main/java/com/willowtree/vocable/AppKoinModule.kt index e0224cb7..7f1332d3 100644 --- a/app/src/main/java/com/willowtree/vocable/AppKoinModule.kt +++ b/app/src/main/java/com/willowtree/vocable/AppKoinModule.kt @@ -101,7 +101,7 @@ val vocableKoinModule = module { single { VocableDatabase.createVocableDatabase(get()) } single { get().presetPhrasesDao() } single { VocableEnvironmentImpl() } - viewModel { PresetsViewModel(get(), get(), get(named())) } + viewModel { PresetsViewModel(get(), get(), get(named()), get()) } viewModel { EditCategoriesViewModel(get()) } viewModel { EditCategoryPhrasesViewModel(get(), get(), get()) } viewModel { AddUpdateCategoryViewModel(get(), get(), get()) } diff --git a/app/src/main/java/com/willowtree/vocable/presets/NumberPadFragment.kt b/app/src/main/java/com/willowtree/vocable/presets/NumberPadFragment.kt index 59b5cf50..dd6cd938 100644 --- a/app/src/main/java/com/willowtree/vocable/presets/NumberPadFragment.kt +++ b/app/src/main/java/com/willowtree/vocable/presets/NumberPadFragment.kt @@ -19,7 +19,7 @@ class NumberPadFragment : BaseFragment() { private const val KEY_PHRASES = "KEY_PHRASES" const val MAX_PHRASES = 12 - fun newInstance(phrases: List) = NumberPadFragment().apply { + fun newInstance(phrases: List) = NumberPadFragment().apply { arguments = bundleOf(KEY_PHRASES to ArrayList(phrases)) } } @@ -37,7 +37,7 @@ class NumberPadFragment : BaseFragment() { val numColumns = resources.getInteger(R.integer.number_pad_columns) val numRows = resources.getInteger(R.integer.number_pad_rows) - val phrases = arguments?.getParcelableArrayList(KEY_PHRASES) + val phrases = arguments?.getParcelableArrayList(KEY_PHRASES) phrases?.let { with(binding.phrasesContainer) { diff --git a/app/src/main/java/com/willowtree/vocable/presets/PhraseGridItem.kt b/app/src/main/java/com/willowtree/vocable/presets/PhraseGridItem.kt new file mode 100644 index 00000000..d6d24a23 --- /dev/null +++ b/app/src/main/java/com/willowtree/vocable/presets/PhraseGridItem.kt @@ -0,0 +1,17 @@ +package com.willowtree.vocable.presets + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +sealed class PhraseGridItem : Parcelable { + + @Parcelize + data class Phrase( + val phraseId: String, + val text: String + ) : PhraseGridItem() + + @Parcelize + object AddPhrase : PhraseGridItem() +} diff --git a/app/src/main/java/com/willowtree/vocable/presets/PhrasesFragment.kt b/app/src/main/java/com/willowtree/vocable/presets/PhrasesFragment.kt index fa6454b5..db11b95b 100644 --- a/app/src/main/java/com/willowtree/vocable/presets/PhrasesFragment.kt +++ b/app/src/main/java/com/willowtree/vocable/presets/PhrasesFragment.kt @@ -19,7 +19,7 @@ class PhrasesFragment : BaseFragment() { companion object { private const val KEY_PHRASES = "KEY_PHRASES" - fun newInstance(phrases: List): PhrasesFragment { + fun newInstance(phrases: List): PhrasesFragment { return PhrasesFragment().apply { arguments = Bundle().apply { putParcelableArrayList(KEY_PHRASES, ArrayList(phrases)) @@ -41,7 +41,7 @@ class PhrasesFragment : BaseFragment() { val numColumns = resources.getInteger(R.integer.phrases_columns) val numRows = resources.getInteger(R.integer.phrases_rows) - val phrases = arguments?.getParcelableArrayList(KEY_PHRASES) + val phrases = arguments?.getParcelableArrayList(KEY_PHRASES) phrases?.let { with(binding.phrasesContainer) { layoutManager = GridLayoutManager(requireContext(), numColumns) diff --git a/app/src/main/java/com/willowtree/vocable/presets/PresetsFragment.kt b/app/src/main/java/com/willowtree/vocable/presets/PresetsFragment.kt index 92885266..d9e2a5df 100644 --- a/app/src/main/java/com/willowtree/vocable/presets/PresetsFragment.kt +++ b/app/src/main/java/com/willowtree/vocable/presets/PresetsFragment.kt @@ -261,7 +261,7 @@ class PresetsFragment : BaseFragment() { } } - private fun handlePhrases(phrases: List) { + private fun handlePhrases(phrases: List) { binding.emptyPhrasesText.isVisible = phrases.isEmpty() && !recentsCategorySelected && categoriesAdapter.getSize() > 0 binding.emptyAddPhraseButton.isVisible = @@ -306,9 +306,9 @@ class PresetsFragment : BaseFragment() { } inner class PhrasesPagerAdapter(fm: FragmentManager) : - VocableFragmentStateAdapter(fm, viewLifecycleOwner.lifecycle) { + VocableFragmentStateAdapter(fm, viewLifecycleOwner.lifecycle) { - override fun setItems(items: List) { + override fun setItems(items: List) { super.setItems(items) setPagingButtonsEnabled(phrasesAdapter.numPages > 1) } diff --git a/app/src/main/java/com/willowtree/vocable/presets/PresetsViewModel.kt b/app/src/main/java/com/willowtree/vocable/presets/PresetsViewModel.kt index 2070deb1..920b8391 100644 --- a/app/src/main/java/com/willowtree/vocable/presets/PresetsViewModel.kt +++ b/app/src/main/java/com/willowtree/vocable/presets/PresetsViewModel.kt @@ -7,11 +7,13 @@ import androidx.lifecycle.asLiveData import androidx.lifecycle.viewModelScope import com.willowtree.vocable.ICategoriesUseCase import com.willowtree.vocable.IPhrasesUseCase +import com.willowtree.vocable.utils.ILocalizedResourceUtility import com.willowtree.vocable.utils.IdlingResourceContainer import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest @@ -23,7 +25,8 @@ import kotlinx.coroutines.launch class PresetsViewModel( private val categoriesUseCase: ICategoriesUseCase, private val phrasesUseCase: IPhrasesUseCase, - private val idlingResourceContainer: IdlingResourceContainer + private val idlingResourceContainer: IdlingResourceContainer, + private val localizedResourceUtility: ILocalizedResourceUtility ) : ViewModel() { val categoryList: LiveData> = categoriesUseCase.categories() @@ -53,24 +56,30 @@ class PresetsViewModel( }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), null) val selectedCategoryLiveData: LiveData = selectedCategory.asLiveData() - val currentPhrases: LiveData> = selectedCategoryId + val currentPhrases: LiveData> = selectedCategoryId .filterNotNull() .flatMapLatest { categoryId -> phrasesUseCase.getPhrasesForCategoryFlow(categoryId).map { phrases -> - val phrasesToReturn = phrases.run { + val phraseGridItems: List = phrases.run { if (categoryId != PresetCategories.RECENTS.id) { sortedBy { it.sortOrder } } else { this } - }.toMutableList() + }.map { + PhraseGridItem.Phrase( + it.phraseId, + localizedResourceUtility.getTextFromPhrase(it) + ) + } if (categoryId != PresetCategories.RECENTS.id && categoryId != PresetCategories.USER_KEYPAD.id && phrases.isNotEmpty()) { - //Add null to end of normal non empty category phrase list for the "+ Add Phrase" button - phrasesToReturn.add(null) + phraseGridItems + PhraseGridItem.AddPhrase + } else { + phraseGridItems } - phrasesToReturn } } + .distinctUntilChanged() .asLiveData() private val liveNavToAddPhrase = MutableLiveData() @@ -90,10 +99,10 @@ class PresetsViewModel( selectedCategoryId.update { categoryId } } - fun addToRecents(phrase: Phrase) { + fun addToRecents(phraseId: String) { viewModelScope.launch { idlingResourceContainer.run { - phrasesUseCase.updatePhraseLastSpokenTime(phrase.phraseId) + phrasesUseCase.updatePhraseLastSpokenTime(phraseId) } } } diff --git a/app/src/main/java/com/willowtree/vocable/presets/adapter/PhraseAdapter.kt b/app/src/main/java/com/willowtree/vocable/presets/adapter/PhraseAdapter.kt index 9b6e928a..1affb149 100644 --- a/app/src/main/java/com/willowtree/vocable/presets/adapter/PhraseAdapter.kt +++ b/app/src/main/java/com/willowtree/vocable/presets/adapter/PhraseAdapter.kt @@ -8,56 +8,51 @@ import androidx.recyclerview.widget.RecyclerView import com.willowtree.vocable.R import com.willowtree.vocable.databinding.PhraseButtonAddBinding import com.willowtree.vocable.databinding.PhraseButtonBinding -import com.willowtree.vocable.presets.Phrase -import com.willowtree.vocable.utils.locale.LocalizedResourceUtility -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject +import com.willowtree.vocable.presets.PhraseGridItem import java.util.Locale class PhraseAdapter( - private val phrases: List, + private val phrases: List, private val numRows: Int, - private val phraseClickAction: ((Phrase) -> Unit)?, + private val phraseClickAction: ((String) -> Unit)?, private val phraseAddClickAction: (() -> Unit)? -) : - RecyclerView.Adapter(), KoinComponent { +) : RecyclerView.Adapter() { abstract inner class PhraseViewHolder( itemView: View ) : RecyclerView.ViewHolder(itemView) { - abstract fun bind(text: String, position: Int) + abstract fun bind(position: Int) } - inner class PhraseItemViewHolder(itemView: View) : PhraseAdapter.PhraseViewHolder(itemView) { + inner class PhraseGridItemViewHolder(itemView: View) : + PhraseAdapter.PhraseViewHolder(itemView) { - override fun bind(text: String, position: Int) { - val binding = PhraseButtonBinding.bind(itemView) - binding.root.setText(text, Locale.getDefault()) - binding.root.action = { - phrases[position]?.let { phraseClickAction?.invoke(it) } - } - } - } - - inner class PhraseAddItemViewHolder(itemView: View) : PhraseAdapter.PhraseViewHolder(itemView) { + override fun bind(position: Int) { + when (val gridItem = phrases[position]) { + is PhraseGridItem.Phrase -> { + val binding = PhraseButtonBinding.bind(itemView) + binding.root.setText(gridItem.text, Locale.getDefault()) + binding.root.action = { + phraseClickAction?.invoke(gridItem.phraseId) + } + } - override fun bind(text: String, position: Int) { - val binding = PhraseButtonAddBinding.bind(itemView) - binding.root.action = { - phraseAddClickAction?.invoke() + PhraseGridItem.AddPhrase -> { + val binding = PhraseButtonAddBinding.bind(itemView) + binding.root.action = { + phraseAddClickAction?.invoke() + } + } } } } - private val localizedResourceUtility: LocalizedResourceUtility by inject() - private var _minHeight: Int? = null override fun getItemViewType(position: Int): Int { - return if (phrases[position] == null) { - R.layout.phrase_button_add - } else { - R.layout.phrase_button + return when (phrases[position]) { + is PhraseGridItem.Phrase -> R.layout.phrase_button + PhraseGridItem.AddPhrase -> R.layout.phrase_button_add } } @@ -74,16 +69,12 @@ class PhraseAdapter( isInvisible = false } } - return if (viewType == R.layout.phrase_button_add) { - PhraseAddItemViewHolder(itemView) - } else { - PhraseItemViewHolder(itemView) - } + + return PhraseGridItemViewHolder(itemView) } override fun onBindViewHolder(holder: PhraseAdapter.PhraseViewHolder, position: Int) { - val text = localizedResourceUtility.getTextFromPhrase(phrases[position]) - holder.bind(text, position) + holder.bind(position) } private fun getMinHeight(parent: ViewGroup): Int { diff --git a/app/src/main/java/com/willowtree/vocable/utils/ILocalizedResourceUtility.kt b/app/src/main/java/com/willowtree/vocable/utils/ILocalizedResourceUtility.kt index c74ae5f0..6b5ac72b 100644 --- a/app/src/main/java/com/willowtree/vocable/utils/ILocalizedResourceUtility.kt +++ b/app/src/main/java/com/willowtree/vocable/utils/ILocalizedResourceUtility.kt @@ -1,7 +1,9 @@ package com.willowtree.vocable.utils import com.willowtree.vocable.presets.Category +import com.willowtree.vocable.presets.Phrase interface ILocalizedResourceUtility { fun getTextFromCategory(category: Category?): String + fun getTextFromPhrase(phrase: Phrase?): String } \ No newline at end of file diff --git a/app/src/main/java/com/willowtree/vocable/utils/locale/LocalizedResourceUtility.kt b/app/src/main/java/com/willowtree/vocable/utils/locale/LocalizedResourceUtility.kt index 76f6b9a8..a64e10b1 100644 --- a/app/src/main/java/com/willowtree/vocable/utils/locale/LocalizedResourceUtility.kt +++ b/app/src/main/java/com/willowtree/vocable/utils/locale/LocalizedResourceUtility.kt @@ -13,7 +13,7 @@ class LocalizedResourceUtility( return category?.text(context) ?: "" } - fun getTextFromPhrase(phrase: Phrase?): String { + override fun getTextFromPhrase(phrase: Phrase?): String { return phrase?.text(context) ?: "" } } \ No newline at end of file diff --git a/app/src/test/java/com/willowtree/vocable/presets/PresetsViewModelTest.kt b/app/src/test/java/com/willowtree/vocable/presets/PresetsViewModelTest.kt index 676d670c..c3d1b104 100644 --- a/app/src/test/java/com/willowtree/vocable/presets/PresetsViewModelTest.kt +++ b/app/src/test/java/com/willowtree/vocable/presets/PresetsViewModelTest.kt @@ -8,6 +8,7 @@ import com.willowtree.vocable.MainDispatcherRule import com.willowtree.vocable.getOrAwaitValue import com.willowtree.vocable.room.CategoryDto import com.willowtree.vocable.room.PhraseDto +import com.willowtree.vocable.utils.FakeLocalizedResourceUtility import com.willowtree.vocable.utils.IdlingResourceContainerImpl import com.willowtree.vocable.utils.locale.LocalesWithText import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -34,7 +35,8 @@ class PresetsViewModelTest { return PresetsViewModel( fakeCategoriesUseCase, fakePhrasesUseCase, - prodIdlingResourceContainer + prodIdlingResourceContainer, + FakeLocalizedResourceUtility() ) } @@ -109,126 +111,129 @@ class PresetsViewModelTest { @OptIn(ExperimentalCoroutinesApi::class) @Test - fun `selected category is hidden and next immediate category is shown`() = runTest(UnconfinedTestDispatcher()) { - fakeCategoriesUseCase._categories.update { - listOf( - Category.StoredCategory( - categoryId = "1", - localizedName = LocalesWithText(mapOf("en_US" to "category")), - hidden = true, - sortOrder = 0 - ), - Category.StoredCategory( - categoryId = "2", - localizedName = LocalesWithText(mapOf("en_US" to "second category")), - hidden = false, - sortOrder = 1 + fun `selected category is hidden and next immediate category is shown`() = + runTest(UnconfinedTestDispatcher()) { + fakeCategoriesUseCase._categories.update { + listOf( + Category.StoredCategory( + categoryId = "1", + localizedName = LocalesWithText(mapOf("en_US" to "category")), + hidden = true, + sortOrder = 0 + ), + Category.StoredCategory( + categoryId = "2", + localizedName = LocalesWithText(mapOf("en_US" to "second category")), + hidden = false, + sortOrder = 1 + ) ) - ) - } + } - val vm = createViewModel() + val vm = createViewModel() - vm.onCategorySelected("1") + vm.onCategorySelected("1") - vm.selectedCategory.test { - assertEquals( - Category.StoredCategory( - categoryId = "2", - localizedName = LocalesWithText(mapOf("en_US" to "second category")), - hidden = false, - sortOrder = 1 - ), - awaitItem() - ) + vm.selectedCategory.test { + assertEquals( + Category.StoredCategory( + categoryId = "2", + localizedName = LocalesWithText(mapOf("en_US" to "second category")), + hidden = false, + sortOrder = 1 + ), + awaitItem() + ) + } } - } @OptIn(ExperimentalCoroutinesApi::class) @Test - fun `selected category (last in list) is hidden and first category is shown`() = runTest(UnconfinedTestDispatcher()) { - fakeCategoriesUseCase._categories.update { - listOf( - Category.StoredCategory( - categoryId = "1", - localizedName = LocalesWithText(mapOf("en_US" to "category")), - hidden = false, - sortOrder = 0 - ), - Category.StoredCategory( - categoryId = "2", - localizedName = LocalesWithText(mapOf("en_US" to "second category")), - hidden = false, - sortOrder = 1 - ), - Category.StoredCategory( - categoryId = "3", - localizedName = LocalesWithText(mapOf("en_US" to "third category")), - hidden = true, - sortOrder = 2 + fun `selected category (last in list) is hidden and first category is shown`() = + runTest(UnconfinedTestDispatcher()) { + fakeCategoriesUseCase._categories.update { + listOf( + Category.StoredCategory( + categoryId = "1", + localizedName = LocalesWithText(mapOf("en_US" to "category")), + hidden = false, + sortOrder = 0 + ), + Category.StoredCategory( + categoryId = "2", + localizedName = LocalesWithText(mapOf("en_US" to "second category")), + hidden = false, + sortOrder = 1 + ), + Category.StoredCategory( + categoryId = "3", + localizedName = LocalesWithText(mapOf("en_US" to "third category")), + hidden = true, + sortOrder = 2 + ) ) - ) - } + } - val vm = createViewModel() + val vm = createViewModel() - vm.onCategorySelected("3") + vm.onCategorySelected("3") - vm.selectedCategory.test { - assertEquals( - Category.StoredCategory( - categoryId = "1", - localizedName = LocalesWithText(mapOf("en_US" to "category")), - hidden = false, - sortOrder = 0 - ), - awaitItem() - ) + vm.selectedCategory.test { + assertEquals( + Category.StoredCategory( + categoryId = "1", + localizedName = LocalesWithText(mapOf("en_US" to "category")), + hidden = false, + sortOrder = 0 + ), + awaitItem() + ) + } } - } @OptIn(ExperimentalCoroutinesApi::class) @Test - fun `selected category is hidden and next non-hidden category is shown`() = runTest(UnconfinedTestDispatcher()) { - fakeCategoriesUseCase._categories.update { - listOf( - Category.StoredCategory( - categoryId = "1", - localizedName = LocalesWithText(mapOf("en_US" to "category")), - hidden = true, - sortOrder = 0 - ), - Category.StoredCategory( - categoryId = "2", - localizedName = LocalesWithText(mapOf("en_US" to "second category")), - hidden = false, - sortOrder = 1 - ), - Category.StoredCategory( - categoryId = "3", - localizedName = LocalesWithText(mapOf("en_US" to "third category")), - hidden = true, - sortOrder = 2 + fun `selected category is hidden and next non-hidden category is shown`() = + runTest(UnconfinedTestDispatcher()) { + fakeCategoriesUseCase._categories.update { + listOf( + Category.StoredCategory( + categoryId = "1", + localizedName = LocalesWithText(mapOf("en_US" to "category")), + hidden = true, + sortOrder = 0 + ), + Category.StoredCategory( + categoryId = "2", + localizedName = LocalesWithText(mapOf("en_US" to "second category")), + hidden = false, + sortOrder = 1 + ), + Category.StoredCategory( + categoryId = "3", + localizedName = LocalesWithText(mapOf("en_US" to "third category")), + hidden = true, + sortOrder = 2 + ) ) - ) - } + } - val vm = createViewModel() + val vm = createViewModel() - vm.onCategorySelected("3") + vm.onCategorySelected("3") - vm.selectedCategory.test { - assertEquals( - Category.StoredCategory( - categoryId = "2", - localizedName = LocalesWithText(mapOf("en_US" to "second category")), - hidden = false, - sortOrder = 1 - ), - awaitItem() - ) + vm.selectedCategory.test { + assertEquals( + Category.StoredCategory( + categoryId = "2", + localizedName = LocalesWithText(mapOf("en_US" to "second category")), + hidden = false, + sortOrder = 1 + ), + awaitItem() + ) + } } - } @Test fun `current phrases updated when category ID changed`() { @@ -277,13 +282,11 @@ class PresetsViewModelTest { assertEquals( listOf( - CustomPhrase( + PhraseGridItem.Phrase( phraseId = "2", - localizedUtterance = LocalesWithText(mapOf("en_US" to "Goodbye")), - sortOrder = 0, - lastSpokenDate = null, + text = "Goodbye", ), - null + PhraseGridItem.AddPhrase ), vm.currentPhrases.getOrAwaitValue() ) @@ -326,19 +329,15 @@ class PresetsViewModelTest { vm.onCategorySelected("2") assertEquals( listOf( - CustomPhrase( + PhraseGridItem.Phrase( phraseId = "2", - localizedUtterance = LocalesWithText(mapOf("en_US" to "Goodbye")), - sortOrder = 0, - lastSpokenDate = null, + text = "Goodbye", ), - CustomPhrase( + PhraseGridItem.Phrase( phraseId = "1", - localizedUtterance = LocalesWithText(mapOf("en_US" to "Hello")), - sortOrder = 1, - lastSpokenDate = null, + text = "Hello" ), - null + PhraseGridItem.AddPhrase ), vm.currentPhrases.getOrAwaitValue() ) @@ -381,17 +380,13 @@ class PresetsViewModelTest { vm.onCategorySelected(PresetCategories.RECENTS.id) assertEquals( listOf( - CustomPhrase( + PhraseGridItem.Phrase( phraseId = "1", - localizedUtterance = LocalesWithText(mapOf("en_US" to "Hello")), - sortOrder = 1, - lastSpokenDate = null, + text = "Hello", ), - CustomPhrase( + PhraseGridItem.Phrase( phraseId = "2", - localizedUtterance = LocalesWithText(mapOf("en_US" to "Goodbye")), - sortOrder = 0, - lastSpokenDate = null, + text = "Goodbye", ) ), vm.currentPhrases.getOrAwaitValue() diff --git a/app/src/test/java/com/willowtree/vocable/utils/FakeLocalizedResourceUtility.kt b/app/src/test/java/com/willowtree/vocable/utils/FakeLocalizedResourceUtility.kt index 26ed4ee4..fd18295e 100644 --- a/app/src/test/java/com/willowtree/vocable/utils/FakeLocalizedResourceUtility.kt +++ b/app/src/test/java/com/willowtree/vocable/utils/FakeLocalizedResourceUtility.kt @@ -1,6 +1,9 @@ package com.willowtree.vocable.utils import com.willowtree.vocable.presets.Category +import com.willowtree.vocable.presets.CustomPhrase +import com.willowtree.vocable.presets.Phrase +import com.willowtree.vocable.presets.PresetPhrase class FakeLocalizedResourceUtility : ILocalizedResourceUtility { override fun getTextFromCategory(category: Category?): String { @@ -11,4 +14,12 @@ class FakeLocalizedResourceUtility : ILocalizedResourceUtility { null -> "" } } + + override fun getTextFromPhrase(phrase: Phrase?): String { + return when(phrase) { + is CustomPhrase -> phrase.localizedUtterance?.localesTextMap?.entries?.first()?.value ?: "" + is PresetPhrase -> phrase.phraseId + null -> "" + } + } } \ No newline at end of file