diff --git a/app/src/main/kotlin/me/xizzhu/android/joshua/annotated/AnnotatedVersesActivity.kt b/app/src/main/kotlin/me/xizzhu/android/joshua/annotated/AnnotatedVersesActivity.kt index dc160baa..18b35c04 100644 --- a/app/src/main/kotlin/me/xizzhu/android/joshua/annotated/AnnotatedVersesActivity.kt +++ b/app/src/main/kotlin/me/xizzhu/android/joshua/annotated/AnnotatedVersesActivity.kt @@ -37,7 +37,11 @@ import me.xizzhu.android.joshua.ui.listDialog abstract class AnnotatedVersesActivity>( @StringRes private val toolbarText: Int ) : BaseActivityV2(), BookmarkItem.Callback, HighlightItem.Callback, NoteItem.Callback, VersePreviewItem.Callback { - override fun inflateViewBinding(): ActivityAnnotatedBinding = ActivityAnnotatedBinding.inflate(layoutInflater) + override val viewBinding: ActivityAnnotatedBinding by lazy { ActivityAnnotatedBinding.inflate(layoutInflater) } + + override fun initializeView() { + // TODO + } override fun onViewActionEmitted(viewAction: AnnotatedVersesViewModel.ViewAction) = when (viewAction) { AnnotatedVersesViewModel.ViewAction.OpenReadingScreen -> navigator.navigate(this, Navigator.SCREEN_READING, extrasForOpeningVerse()) diff --git a/app/src/main/kotlin/me/xizzhu/android/joshua/infra/BaseActivityV2.kt b/app/src/main/kotlin/me/xizzhu/android/joshua/infra/BaseActivityV2.kt index 16e933d3..c06187b0 100644 --- a/app/src/main/kotlin/me/xizzhu/android/joshua/infra/BaseActivityV2.kt +++ b/app/src/main/kotlin/me/xizzhu/android/joshua/infra/BaseActivityV2.kt @@ -35,9 +35,9 @@ abstract class BaseActivityV2(), ReadingProgressDetailItem.Callback { - private val readingProgressViewModel: ReadingProgressViewModel by viewModels() +class ReadingProgressActivity : BaseActivityV2() { + @Inject + lateinit var coroutineDispatcherProvider: CoroutineDispatcherProvider - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) + private lateinit var readingProgressAdapter: ReadingProgressAdapter - observeSettings() - observeReadingProgress() - } + override val viewModel: ReadingProgressViewModel by viewModels() - private fun observeSettings() { - readingProgressViewModel.settings().onEach { viewBinding.readingProgressList.setSettings(it) }.launchIn(lifecycleScope) - } + override val viewBinding: ActivityReadingProgressBinding by lazy { ActivityReadingProgressBinding.inflate(layoutInflater) } - private fun observeReadingProgress() { - readingProgressViewModel.readingProgress() - .onEach( - onLoading = { - with(viewBinding) { - loadingSpinner.fadeIn() - readingProgressList.visibility = View.GONE - } - }, - onSuccess = { - with(viewBinding) { - readingProgressList.setItems(it.items) - readingProgressList.fadeIn() - loadingSpinner.visibility = View.GONE - } - }, - onFailure = { - viewBinding.loadingSpinner.visibility = View.GONE - dialog(false, R.string.dialog_title_error, R.string.dialog_message_failed_to_load_reading_progress, { _, _ -> loadReadingProgress() }, { _, _ -> finish() }) - } - ) - .launchIn(lifecycleScope) - } + override fun initializeView() { + readingProgressAdapter = ReadingProgressAdapter( + inflater = layoutInflater, + executor = coroutineDispatcherProvider.default.asExecutor() + ) { viewEvent -> + when (viewEvent) { + is ReadingProgressAdapter.ViewEvent.ExpandOrCollapseBook -> viewModel.expandOrCollapseBook(viewEvent.bookIndex) + is ReadingProgressAdapter.ViewEvent.OpenVerse -> viewModel.openVerse(viewEvent.verseToOpen) + } + } - override fun onStart() { - super.onStart() - loadReadingProgress() + with(viewBinding.readingProgressList) { + adapter = readingProgressAdapter + layoutManager = LinearLayoutManager(this@ReadingProgressActivity, RecyclerView.VERTICAL, false) + } } - private fun loadReadingProgress() { - readingProgressViewModel.loadReadingProgress() + override fun onViewActionEmitted(viewAction: ReadingProgressViewModel.ViewAction) = when (viewAction) { + ReadingProgressViewModel.ViewAction.OpenReadingScreen -> navigator.navigate(this, Navigator.SCREEN_READING) } - override fun inflateViewBinding(): ActivityReadingProgressBinding = ActivityReadingProgressBinding.inflate(layoutInflater) + override fun onViewStateUpdated(viewState: ReadingProgressViewModel.ViewState) = with(viewBinding) { + if (viewState.loading) { + loadingSpinner.fadeIn() + readingProgressList.isVisible = false + } else { + loadingSpinner.isVisible = false + readingProgressList.fadeIn() + } - override fun viewModel(): ReadingProgressViewModel = readingProgressViewModel + readingProgressAdapter.submitList(viewState.items) - override fun openVerse(verseToOpen: VerseIndex) { - readingProgressViewModel.saveCurrentVerseIndex(verseToOpen) - .onSuccess { navigator.navigate(this, Navigator.SCREEN_READING) } - .onFailure { dialog(true, R.string.dialog_title_error, R.string.dialog_message_failed_to_select_verse, { _, _ -> openVerse(verseToOpen) }) } - .launchIn(lifecycleScope) + when (val error = viewState.error) { + is ReadingProgressViewModel.ViewState.Error.ReadingProgressLoadingError -> { + dialog( + cancelable = false, + title = R.string.dialog_title_error, + message = R.string.dialog_message_failed_to_load_reading_progress, + onPositive = { _, _ -> viewModel.loadReadingProgress() }, + onNegative = { _, _ -> finish() }, + onDismiss = { viewModel.markErrorAsShown(error) } + ) + } + is ReadingProgressViewModel.ViewState.Error.VerseOpeningError -> { + dialog( + cancelable = true, + title = R.string.dialog_title_error, + message = R.string.dialog_message_failed_to_select_verse, + onPositive = { _, _ -> viewModel.openVerse(error.verseToOpen) }, + onDismiss = { viewModel.markErrorAsShown(error) } + ) + } + null -> { + // Do nothing + } + } + } + + override fun onStart() { + super.onStart() + viewModel.loadReadingProgress() } } diff --git a/app/src/main/kotlin/me/xizzhu/android/joshua/progress/ReadingProgressAdapter.kt b/app/src/main/kotlin/me/xizzhu/android/joshua/progress/ReadingProgressAdapter.kt new file mode 100644 index 00000000..8550500b --- /dev/null +++ b/app/src/main/kotlin/me/xizzhu/android/joshua/progress/ReadingProgressAdapter.kt @@ -0,0 +1,220 @@ +/* + * Copyright (C) 2022 Xizhi Zhu + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package me.xizzhu.android.joshua.progress + +import android.graphics.Typeface +import android.text.SpannableStringBuilder +import android.text.style.CharacterStyle +import android.text.style.ForegroundColorSpan +import android.text.style.StyleSpan +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.view.children +import androidx.core.view.isVisible +import androidx.recyclerview.widget.AsyncDifferConfig +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import androidx.viewbinding.ViewBinding +import me.xizzhu.android.joshua.R +import me.xizzhu.android.joshua.core.Settings +import me.xizzhu.android.joshua.core.VerseIndex +import me.xizzhu.android.joshua.databinding.ItemReadingProgressBinding +import me.xizzhu.android.joshua.databinding.ItemReadingProgressHeaderBinding +import me.xizzhu.android.joshua.ui.append +import me.xizzhu.android.joshua.ui.clearAll +import me.xizzhu.android.joshua.ui.setPrimaryTextSize +import me.xizzhu.android.joshua.ui.setSpans +import me.xizzhu.android.joshua.ui.toCharSequence +import java.util.concurrent.Executor + +class ReadingProgressAdapter( + private val inflater: LayoutInflater, + executor: Executor, + private val onViewEvent: (ViewEvent) -> Unit, +) : ListAdapter>( + AsyncDifferConfig.Builder(ReadingProgressItem.DiffCallback()).setBackgroundThreadExecutor(executor).build() +) { + sealed class ViewEvent { + data class ExpandOrCollapseBook(val bookIndex: Int) : ViewEvent() + data class OpenVerse(val verseToOpen: VerseIndex) : ViewEvent() + } + + override fun getItemViewType(position: Int): Int = getItem(position).viewType + + @Suppress("UNCHECKED_CAST") + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ReadingProgressViewHolder = when (viewType) { + ReadingProgressItem.Summary.VIEW_TYPE -> ReadingProgressViewHolder.Summary(inflater, parent) + ReadingProgressItem.Book.VIEW_TYPE -> ReadingProgressViewHolder.Book(inflater, parent, onViewEvent) + else -> throw IllegalStateException("Unknown view type - $viewType") + } as ReadingProgressViewHolder + + override fun onBindViewHolder(holder: ReadingProgressViewHolder, position: Int) { + holder.bindData(getItem(position)) + } + + override fun onBindViewHolder(holder: ReadingProgressViewHolder, position: Int, payloads: MutableList) { + holder.bindData(getItem(position), payloads) + } +} + +sealed class ReadingProgressItem(val viewType: Int) { + class DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: ReadingProgressItem, newItem: ReadingProgressItem): Boolean = oldItem::class == newItem::class + + override fun areContentsTheSame(oldItem: ReadingProgressItem, newItem: ReadingProgressItem): Boolean = oldItem == newItem + } + + data class Summary( + val settings: Settings, + val continuousReadingDays: Int, + val chaptersRead: Int, + val finishedBooks: Int, + val finishedOldTestament: Int, + val finishedNewTestament: Int + ) : ReadingProgressItem(VIEW_TYPE) { + companion object { + const val VIEW_TYPE = R.layout.item_reading_progress_header + } + } + + data class Book( + val settings: Settings, + val bookName: String, + val bookIndex: Int, + val chaptersRead: List, + val chaptersReadCount: Int, + val expanded: Boolean + ) : ReadingProgressItem(VIEW_TYPE) { + companion object { + const val VIEW_TYPE = R.layout.item_reading_progress + } + } +} + +sealed class ReadingProgressViewHolder(protected val viewBinding: VB) + : RecyclerView.ViewHolder(viewBinding.root) { + protected var item: Item? = null + + fun bindData(item: Item, payloads: List = emptyList()) { + this.item = item + bind(item, payloads) + } + + protected abstract fun bind(item: Item, payloads: List) + + class Summary(inflater: LayoutInflater, parent: ViewGroup) + : ReadingProgressViewHolder( + ItemReadingProgressHeaderBinding.inflate(inflater, parent, false) + ) { + override fun bind(item: ReadingProgressItem.Summary, payloads: List) = with(viewBinding) { + continuousReadingDaysTitle.setPrimaryTextSize(item.settings) + continuousReadingDaysValue.setPrimaryTextSize(item.settings) + chaptersReadTitle.setPrimaryTextSize(item.settings) + chaptersReadValue.setPrimaryTextSize(item.settings) + finishedBooksTitle.setPrimaryTextSize(item.settings) + finishedBooksValue.setPrimaryTextSize(item.settings) + finishedOldTestamentTitle.setPrimaryTextSize(item.settings) + finishedOldTestamentValue.setPrimaryTextSize(item.settings) + finishedNewTestamentTitle.setPrimaryTextSize(item.settings) + finishedNewTestamentValue.setPrimaryTextSize(item.settings) + + continuousReadingDaysValue.text = continuousReadingDaysValue.resources.getString(R.string.text_continuous_reading_count, item.continuousReadingDays) + chaptersReadValue.text = item.chaptersRead.toString() + finishedBooksValue.text = item.finishedBooks.toString() + finishedOldTestamentValue.text = item.finishedOldTestament.toString() + finishedNewTestamentValue.text = item.finishedNewTestament.toString() + } + } + + class Book(private val inflater: LayoutInflater, parent: ViewGroup, onViewEvent: (ReadingProgressAdapter.ViewEvent) -> Unit) + : ReadingProgressViewHolder( + ItemReadingProgressBinding.inflate(inflater, parent, false) + ) { + companion object { + private const val ROW_CHILD_COUNT = 5 + + private val SPANNABLE_STRING_BUILDER = SpannableStringBuilder() + private val CHAPTER_READ_SPANS: Array = arrayOf( + ForegroundColorSpan(0xFF99CC00.toInt()), // R.color.dark_lime + StyleSpan(Typeface.BOLD) + ) + } + + private val onChapterClickListener: View.OnClickListener = View.OnClickListener { v -> + item?.let { item -> + onViewEvent(ReadingProgressAdapter.ViewEvent.OpenVerse(verseToOpen = VerseIndex(item.bookIndex, v.tag as Int, 0))) + } + } + + init { + itemView.setOnClickListener { + item?.let { item -> + onViewEvent(ReadingProgressAdapter.ViewEvent.ExpandOrCollapseBook(bookIndex = item.bookIndex)) + } + } + } + + override fun bind(item: ReadingProgressItem.Book, payloads: List) = with(viewBinding) { + bookName.setPrimaryTextSize(item.settings) + bookName.text = item.bookName + + readingProgressBar.progress = item.chaptersReadCount * readingProgressBar.maxProgress / item.chaptersRead.size + readingProgressBar.text = "${item.chaptersReadCount} / ${item.chaptersRead.size}" + + if (item.expanded) { + val rowCount = item.chaptersRead.size / ROW_CHILD_COUNT + if (item.chaptersRead.size % ROW_CHILD_COUNT == 0) 0 else 1 + if (chapters.childCount > rowCount) { + chapters.removeViews(rowCount, chapters.childCount - rowCount) + } + repeat(rowCount - chapters.childCount) { + val row = inflater.inflate(R.layout.row_reading_progress_chapters, chapters, false) as LinearLayout + chapters.addView(row) + row.children.forEach { it.setOnClickListener(onChapterClickListener) } + } + + for (i in 0 until rowCount) { + val row = chapters.getChildAt(i) as LinearLayout + for (j in 0 until ROW_CHILD_COUNT) { + val chapter = i * ROW_CHILD_COUNT + j + with(row.getChildAt(j) as TextView) { + if (chapter >= item.chaptersRead.size) { + isVisible = false + } else { + isVisible = true + tag = chapter + + SPANNABLE_STRING_BUILDER.clearAll().append(chapter + 1) + if (item.chaptersRead[chapter]) { + SPANNABLE_STRING_BUILDER.setSpans(CHAPTER_READ_SPANS) + } + text = SPANNABLE_STRING_BUILDER.toCharSequence() + } + } + } + } + + chapters.isVisible = true + } else { + chapters.isVisible = false + } + } + } +} diff --git a/app/src/main/kotlin/me/xizzhu/android/joshua/progress/ReadingProgressItem.kt b/app/src/main/kotlin/me/xizzhu/android/joshua/progress/ReadingProgressItem.kt deleted file mode 100644 index 7dc11955..00000000 --- a/app/src/main/kotlin/me/xizzhu/android/joshua/progress/ReadingProgressItem.kt +++ /dev/null @@ -1,175 +0,0 @@ -/* - * Copyright (C) 2022 Xizhi Zhu - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package me.xizzhu.android.joshua.progress - -import android.graphics.Typeface -import android.text.SpannableStringBuilder -import android.text.style.CharacterStyle -import android.text.style.ForegroundColorSpan -import android.text.style.StyleSpan -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.LinearLayout -import android.widget.TextView -import me.xizzhu.android.joshua.R -import me.xizzhu.android.joshua.core.Settings -import me.xizzhu.android.joshua.core.VerseIndex -import me.xizzhu.android.joshua.databinding.ItemReadingProgressBinding -import me.xizzhu.android.joshua.databinding.ItemReadingProgressHeaderBinding -import me.xizzhu.android.joshua.ui.activity -import me.xizzhu.android.joshua.ui.append -import me.xizzhu.android.joshua.ui.clearAll -import me.xizzhu.android.joshua.ui.recyclerview.BaseItem -import me.xizzhu.android.joshua.ui.recyclerview.BaseViewHolder -import me.xizzhu.android.joshua.ui.setPrimaryTextSize -import me.xizzhu.android.joshua.ui.setSpans -import me.xizzhu.android.joshua.ui.toCharSequence - -class ReadingProgressSummaryItem( - val continuousReadingDays: Int, val chaptersRead: Int, val finishedBooks: Int, - val finishedOldTestament: Int, val finishedNewTestament: Int -) : BaseItem(R.layout.item_reading_progress_header, { inflater, parent -> ReadingProgressSummaryItemViewHolder(inflater, parent) }) - -private class ReadingProgressSummaryItemViewHolder(inflater: LayoutInflater, parent: ViewGroup) - : BaseViewHolder(ItemReadingProgressHeaderBinding.inflate(inflater, parent, false)) { - override fun bind(settings: Settings, item: ReadingProgressSummaryItem, payloads: List) { - viewBinding.continuousReadingDaysTitle.setPrimaryTextSize(settings) - viewBinding.continuousReadingDaysValue.setPrimaryTextSize(settings) - viewBinding.chaptersReadTitle.setPrimaryTextSize(settings) - viewBinding.chaptersReadValue.setPrimaryTextSize(settings) - viewBinding.finishedBooksTitle.setPrimaryTextSize(settings) - viewBinding.finishedBooksValue.setPrimaryTextSize(settings) - viewBinding.finishedOldTestamentTitle.setPrimaryTextSize(settings) - viewBinding.finishedOldTestamentValue.setPrimaryTextSize(settings) - viewBinding.finishedNewTestamentTitle.setPrimaryTextSize(settings) - viewBinding.finishedNewTestamentValue.setPrimaryTextSize(settings) - - viewBinding.continuousReadingDaysValue.text = - viewBinding.continuousReadingDaysValue.resources.getString(R.string.text_continuous_reading_count, item.continuousReadingDays) - viewBinding.chaptersReadValue.text = item.chaptersRead.toString() - viewBinding.finishedBooksValue.text = item.finishedBooks.toString() - viewBinding.finishedOldTestamentValue.text = item.finishedOldTestament.toString() - viewBinding.finishedNewTestamentValue.text = item.finishedNewTestament.toString() - } -} - -class ReadingProgressDetailItem( - val bookName: String, val bookIndex: Int, - val chaptersRead: List, val chaptersReadCount: Int, - val onBookClicked: (Int, Boolean) -> Unit, - var expanded: Boolean -) : BaseItem(R.layout.item_reading_progress, { inflater, parent -> ReadingProgressDetailItemViewHolder(inflater, parent) }) { - interface Callback { - fun openVerse(verseToOpen: VerseIndex) - } -} - -private class ReadingProgressDetailItemViewHolder(private val inflater: LayoutInflater, parent: ViewGroup) - : BaseViewHolder(ItemReadingProgressBinding.inflate(inflater, parent, false)) { - companion object { - private const val ROW_CHILD_COUNT = 5 - - private val SPANNABLE_STRING_BUILDER = SpannableStringBuilder() - private val CHAPTER_READ_SPANS: Array = arrayOf( - ForegroundColorSpan(0xFF99CC00.toInt()), // R.color.dark_lime - StyleSpan(Typeface.BOLD) - ) - } - - private val onClickListener: View.OnClickListener = View.OnClickListener { v -> - item?.let { - item?.let { item -> - (itemView.activity as? ReadingProgressDetailItem.Callback)?.openVerse(VerseIndex(item.bookIndex, v.tag as Int, 0)) - ?: throw IllegalStateException("Attached activity [${itemView.activity.javaClass.name}] does not implement ReadingProgressDetailItem.Callback") - } - } - } - - init { - itemView.setOnClickListener { - item?.let { - if (it.expanded) { - viewBinding.chapters.visibility = View.GONE - it.expanded = false - } else { - showChapters(it) - it.expanded = true - } - it.onBookClicked(it.bookIndex, it.expanded) - } - } - } - - override fun bind(settings: Settings, item: ReadingProgressDetailItem, payloads: List) { - with(viewBinding.bookName) { - setPrimaryTextSize(settings) - text = item.bookName - } - - with(viewBinding.readingProgressBar) { - progress = item.chaptersReadCount * maxProgress / item.chaptersRead.size - text = "${item.chaptersReadCount} / ${item.chaptersRead.size}" - } - - if (item.expanded) { - showChapters(item) - } else { - viewBinding.chapters.visibility = View.GONE - } - } - - private fun showChapters(item: ReadingProgressDetailItem) { - val rowCount = item.chaptersRead.size / ROW_CHILD_COUNT + if (item.chaptersRead.size % ROW_CHILD_COUNT == 0) 0 else 1 - with(viewBinding.chapters) { - if (childCount > rowCount) { - removeViews(rowCount, childCount - rowCount) - } - repeat(rowCount - childCount) { - (inflater.inflate(R.layout.row_reading_progress_chapters, this, false) as LinearLayout).also { - addView(it) - for (i in 0 until it.childCount) { - it.getChildAt(i).setOnClickListener(onClickListener) - } - } - } - - for (i in 0 until rowCount) { - val row = viewBinding.chapters.getChildAt(i) as LinearLayout - for (j in 0 until ROW_CHILD_COUNT) { - val chapter = i * ROW_CHILD_COUNT + j - with(row.getChildAt(j) as TextView) { - if (chapter >= item.chaptersRead.size) { - visibility = View.GONE - } else { - visibility = View.VISIBLE - tag = chapter - - SPANNABLE_STRING_BUILDER.clearAll().append(chapter + 1) - if (item.chaptersRead[chapter]) { - SPANNABLE_STRING_BUILDER.setSpans(CHAPTER_READ_SPANS) - } - text = SPANNABLE_STRING_BUILDER.toCharSequence() - } - } - } - } - - visibility = View.VISIBLE - } - } -} diff --git a/app/src/main/kotlin/me/xizzhu/android/joshua/progress/ReadingProgressViewModel.kt b/app/src/main/kotlin/me/xizzhu/android/joshua/progress/ReadingProgressViewModel.kt index c3a56b80..5a182619 100644 --- a/app/src/main/kotlin/me/xizzhu/android/joshua/progress/ReadingProgressViewModel.kt +++ b/app/src/main/kotlin/me/xizzhu/android/joshua/progress/ReadingProgressViewModel.kt @@ -16,56 +16,74 @@ package me.xizzhu.android.joshua.progress -import android.app.Application import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch -import me.xizzhu.android.joshua.core.* -import me.xizzhu.android.joshua.infra.BaseViewModel -import me.xizzhu.android.joshua.infra.onFailure -import me.xizzhu.android.joshua.infra.viewData -import me.xizzhu.android.joshua.ui.recyclerview.BaseItem +import me.xizzhu.android.joshua.core.Bible +import me.xizzhu.android.joshua.core.BibleReadingManager +import me.xizzhu.android.joshua.core.ReadingProgress +import me.xizzhu.android.joshua.core.ReadingProgressManager +import me.xizzhu.android.joshua.core.Settings +import me.xizzhu.android.joshua.core.SettingsManager +import me.xizzhu.android.joshua.core.VerseIndex +import me.xizzhu.android.joshua.core.provider.CoroutineDispatcherProvider +import me.xizzhu.android.joshua.infra.BaseViewModelV2 import me.xizzhu.android.joshua.utils.firstNotEmpty import me.xizzhu.android.logger.Log import javax.inject.Inject -class ReadingProgressViewData(val items: List) - @HiltViewModel class ReadingProgressViewModel @Inject constructor( - private val bibleReadingManager: BibleReadingManager, - private val readingProgressManager: ReadingProgressManager, - settingsManager: SettingsManager, - application: Application -) : BaseViewModel(settingsManager, application) { - private val readingProgress: MutableStateFlow?> = MutableStateFlow(null) - private val expanded: Array = Array(Bible.BOOK_COUNT) { it == 0 } + private val bibleReadingManager: BibleReadingManager, + private val readingProgressManager: ReadingProgressManager, + private val settingsManager: SettingsManager, + private val coroutineDispatcherProvider: CoroutineDispatcherProvider +) : BaseViewModelV2( + initialViewState = ViewState( + loading = false, + items = emptyList(), + error = null, + ) +) { + sealed class ViewAction { + object OpenReadingScreen : ViewAction() + } + + data class ViewState( + val loading: Boolean, + val items: List, + val error: Error?, + ) { + sealed class Error { + object ReadingProgressLoadingError : Error() + data class VerseOpeningError(val verseToOpen: VerseIndex) : Error() + } + } - fun readingProgress(): Flow> = readingProgress.filterNotNull() + private val expanded: Array = Array(Bible.BOOK_COUNT) { it == 0 } fun loadReadingProgress() { - viewModelScope.launch { - try { - readingProgress.value = ViewData.Loading() - readingProgress.value = ViewData.Success(ReadingProgressViewData( - items = buildReadingProgressItems( - readingProgress = readingProgressManager.read(), - bookNames = bibleReadingManager.readBookNames(bibleReadingManager.currentTranslation().firstNotEmpty()) - ) - )) - } catch (e: Exception) { + viewModelScope.launch(coroutineDispatcherProvider.default) { + runCatching { + updateViewState { it.copy(loading = true, items = emptyList()) } + + val items = buildReadingProgressItems( + settings = settingsManager.settings().first(), + readingProgress = readingProgressManager.read(), + bookNames = bibleReadingManager.readBookNames(bibleReadingManager.currentTranslation().firstNotEmpty()), + ) + updateViewState { it.copy(loading = false, items = items) } + }.onFailure { e -> Log.e(tag, "Error occurred while loading reading progress", e) - readingProgress.value = ViewData.Failure(e) + updateViewState { it.copy(loading = false, error = ViewState.Error.ReadingProgressLoadingError) } } } } - private fun buildReadingProgressItems(readingProgress: ReadingProgress, bookNames: List): List { - val items = ArrayList(1 + Bible.BOOK_COUNT) - items.add(ReadingProgressSummaryItem(0, 0, 0, 0, 0)) + private fun buildReadingProgressItems(settings: Settings, readingProgress: ReadingProgress, bookNames: List): List { + val items = ArrayList(1 + Bible.BOOK_COUNT) + items.add(ReadingProgressItem.Summary(settings, 0, 0, 0, 0, 0)) var totalChaptersRead = 0 val chaptersReadPerBook = Array(Bible.BOOK_COUNT) { i -> @@ -94,23 +112,47 @@ class ReadingProgressViewModel @Inject constructor( ++finishedNewTestament } } - items.add(ReadingProgressDetailItem( - bookNames[bookIndex], bookIndex, chaptersRead, chaptersReadCount, ::onBookClicked, expanded[bookIndex] + items.add(ReadingProgressItem.Book( + settings = settings, + bookName = bookNames[bookIndex], + bookIndex = bookIndex, + chaptersRead = chaptersRead, + chaptersReadCount = chaptersReadCount, + expanded = expanded[bookIndex] )) } - items[0] = ReadingProgressSummaryItem( - readingProgress.continuousReadingDays, totalChaptersRead, finishedBooks, finishedOldTestament, finishedNewTestament + items[0] = ReadingProgressItem.Summary( + settings, readingProgress.continuousReadingDays, totalChaptersRead, finishedBooks, finishedOldTestament, finishedNewTestament ) return items } - private fun onBookClicked(bookIndex: Int, expanded: Boolean) { - this.expanded[bookIndex] = expanded + fun expandOrCollapseBook(bookIndex: Int) { + updateViewState { current -> + (current.items.getOrNull(bookIndex + 1) as? ReadingProgressItem.Book)?.let { bookItem -> + val expanded = bookItem.expanded.not() + this.expanded[bookIndex] = expanded + val newBookItem = bookItem.copy(expanded = expanded) + current.copy(items = ArrayList(current.items).apply { set(bookIndex + 1, newBookItem) }) + } + } } - fun saveCurrentVerseIndex(verseToOpen: VerseIndex): Flow> = viewData { - bibleReadingManager.saveCurrentVerseIndex(verseToOpen) - }.onFailure { Log.e(tag, "Failed to save current verse", it) } + fun openVerse(verseToOpen: VerseIndex) { + viewModelScope.launch(coroutineDispatcherProvider.default) { + runCatching { + bibleReadingManager.saveCurrentVerseIndex(verseToOpen) + emitViewAction(ViewAction.OpenReadingScreen) + }.onFailure { e -> + Log.e(tag, "Failed to save current verse", e) + updateViewState { it.copy(error = ViewState.Error.VerseOpeningError(verseToOpen)) } + } + } + } + + fun markErrorAsShown(error: ViewState.Error) { + updateViewState { current -> if (current.error == error) current.copy(error = null) else null } + } } diff --git a/app/src/main/kotlin/me/xizzhu/android/joshua/search/SearchActivity.kt b/app/src/main/kotlin/me/xizzhu/android/joshua/search/SearchActivity.kt index 53bf87fa..b4273107 100644 --- a/app/src/main/kotlin/me/xizzhu/android/joshua/search/SearchActivity.kt +++ b/app/src/main/kotlin/me/xizzhu/android/joshua/search/SearchActivity.kt @@ -44,7 +44,11 @@ class SearchActivity : BaseActivityV2 navigator.navigate(this, Navigator.SCREEN_READING) @@ -83,13 +87,7 @@ class SearchActivity : BaseActivityV2 - listDialog( - title = preview.title, - settings = preview.settings, - items = preview.items, - selected = preview.currentPosition, - onDismiss = { viewModel.markPreviewAsClosed() } - ) + listDialog(title = preview.title, settings = preview.settings, items = preview.items, selected = preview.currentPosition, onDismiss = { viewModel.markPreviewAsClosed() }) } viewState.toast?.let { @@ -109,23 +107,10 @@ class SearchActivity : BaseActivityV2 { - dialog( - cancelable = true, - title = R.string.dialog_title_error, - message = R.string.dialog_message_failed_to_select_verse, - onPositive = { _, _ -> openVerse(error.verseToOpen) }, - onDismiss = { viewModel.markErrorAsShown(error) } - ) + dialog(cancelable = true, title = R.string.dialog_title_error, message = R.string.dialog_message_failed_to_select_verse, onPositive = { _, _ -> openVerse(error.verseToOpen) }, onDismiss = { viewModel.markErrorAsShown(error) }) } is SearchViewModel.ViewState.Error.VerseSearchingError -> { - dialog( - cancelable = false, - title = R.string.dialog_title_error, - message = R.string.dialog_message_failed_to_search, - onPositive = { _, _ -> viewModel.retrySearch() }, - onNegative = { _, _ -> finish() }, - onDismiss = { viewModel.markErrorAsShown(error) } - ) + dialog(cancelable = false, title = R.string.dialog_title_error, message = R.string.dialog_message_failed_to_search, onPositive = { _, _ -> viewModel.retrySearch() }, onNegative = { _, _ -> finish() }, onDismiss = { viewModel.markErrorAsShown(error) }) } null -> { // Do nothing @@ -138,34 +123,25 @@ class SearchActivity : BaseActivityV2 navigator.navigate(this, Navigator.SCREEN_READING) diff --git a/app/src/main/res/layout/activity_reading_progress.xml b/app/src/main/res/layout/activity_reading_progress.xml index 1e8fb5b2..18a83b6d 100644 --- a/app/src/main/res/layout/activity_reading_progress.xml +++ b/app/src/main/res/layout/activity_reading_progress.xml @@ -4,7 +4,7 @@ android:layout_width="match_parent" android:layout_height="match_parent"> - ().apply { setTheme(R.style.AppTheme) } + adapter = ReadingProgressAdapter( + inflater = LayoutInflater.from(context), + executor = TestExecutor() + ) {} + } + + @Test + fun `test getItemViewType()`() { + adapter.submitList( + listOf( + ReadingProgressItem.Summary(Settings.DEFAULT, 0, 0, 0, 0, 0), + ReadingProgressItem.Book(Settings.DEFAULT, "", 0, emptyList(), 0, false) + ) + ) { + assertEquals(R.layout.item_reading_progress_header, adapter.getItemViewType(0)) + assertEquals(R.layout.item_reading_progress, adapter.getItemViewType(1)) + } + } + + @Test(expected = IllegalStateException::class) + fun `test onCreateViewHolder(), with unsupported viewType`() { + adapter.onCreateViewHolder(FrameLayout(context), 0) + } + + @Test + fun `test onCreateViewHolder()`() { + adapter.onCreateViewHolder(FrameLayout(context), ReadingProgressItem.Summary.VIEW_TYPE) as ReadingProgressViewHolder.Summary + adapter.onCreateViewHolder(FrameLayout(context), ReadingProgressItem.Book.VIEW_TYPE) as ReadingProgressViewHolder.Book + } + + @Test + fun `test ReadingProgressItem_DiffCallback`() { + val diffCallback = ReadingProgressItem.DiffCallback() + assertTrue(diffCallback.areItemsTheSame( + ReadingProgressItem.Summary(Settings.DEFAULT, 0, 0, 0, 0, 0), + ReadingProgressItem.Summary(Settings.DEFAULT, 0, 0, 0, 0, 0) + )) + assertTrue(diffCallback.areContentsTheSame( + ReadingProgressItem.Summary(Settings.DEFAULT, 0, 0, 0, 0, 0), + ReadingProgressItem.Summary(Settings.DEFAULT, 0, 0, 0, 0, 0) + )) + assertTrue(diffCallback.areItemsTheSame( + ReadingProgressItem.Summary(Settings.DEFAULT, 0, 0, 0, 0, 0), + ReadingProgressItem.Summary(Settings.DEFAULT, 1, 0, 0, 0, 0) + )) + assertFalse(diffCallback.areContentsTheSame( + ReadingProgressItem.Summary(Settings.DEFAULT, 0, 0, 0, 0, 0), + ReadingProgressItem.Summary(Settings.DEFAULT, 1, 0, 0, 0, 0) + )) + + assertTrue(diffCallback.areItemsTheSame( + ReadingProgressItem.Book(Settings.DEFAULT, "", 0, emptyList(), 0, false), + ReadingProgressItem.Book(Settings.DEFAULT, "", 0, emptyList(), 0, false) + )) + assertTrue(diffCallback.areContentsTheSame( + ReadingProgressItem.Book(Settings.DEFAULT, "", 0, emptyList(), 0, false), + ReadingProgressItem.Book(Settings.DEFAULT, "", 0, emptyList(), 0, false) + )) + assertTrue(diffCallback.areItemsTheSame( + ReadingProgressItem.Book(Settings.DEFAULT, "", 0, emptyList(), 0, false), + ReadingProgressItem.Book(Settings.DEFAULT, "", 1, emptyList(), 0, false) + )) + assertFalse(diffCallback.areContentsTheSame( + ReadingProgressItem.Book(Settings.DEFAULT, "", 0, emptyList(), 0, false), + ReadingProgressItem.Book(Settings.DEFAULT, "", 1, emptyList(), 0, false) + )) + + assertFalse(diffCallback.areItemsTheSame( + ReadingProgressItem.Summary(Settings.DEFAULT, 0, 0, 0, 0, 0), + ReadingProgressItem.Book(Settings.DEFAULT, "", 0, emptyList(), 0, false) + )) + assertFalse(diffCallback.areContentsTheSame( + ReadingProgressItem.Summary(Settings.DEFAULT, 0, 0, 0, 0, 0), + ReadingProgressItem.Book(Settings.DEFAULT, "", 0, emptyList(), 0, false) + )) + } + + @Test + fun `test ReadingProgressItem viewType`() { + assertEquals(R.layout.item_reading_progress_header, ReadingProgressItem.Summary(Settings.DEFAULT, 0, 0, 0, 0, 0).viewType) + assertEquals(R.layout.item_reading_progress, ReadingProgressItem.Book(Settings.DEFAULT, "", 0, emptyList(), 0, false).viewType) + } +} diff --git a/app/src/test/kotlin/me/xizzhu/android/joshua/progress/ReadingProgressViewModelTest.kt b/app/src/test/kotlin/me/xizzhu/android/joshua/progress/ReadingProgressViewModelTest.kt index 430377a0..e9bc97c0 100644 --- a/app/src/test/kotlin/me/xizzhu/android/joshua/progress/ReadingProgressViewModelTest.kt +++ b/app/src/test/kotlin/me/xizzhu/android/joshua/progress/ReadingProgressViewModelTest.kt @@ -16,30 +16,32 @@ package me.xizzhu.android.joshua.progress -import android.app.Application import io.mockk.coEvery +import io.mockk.every import io.mockk.mockk +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.flow.* import kotlinx.coroutines.test.runTest import me.xizzhu.android.joshua.core.BibleReadingManager import me.xizzhu.android.joshua.core.ReadingProgress import me.xizzhu.android.joshua.core.ReadingProgressManager +import me.xizzhu.android.joshua.core.Settings import me.xizzhu.android.joshua.core.SettingsManager -import me.xizzhu.android.joshua.infra.BaseViewModel +import me.xizzhu.android.joshua.core.VerseIndex import me.xizzhu.android.joshua.tests.BaseUnitTest import me.xizzhu.android.joshua.tests.MockContents import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue class ReadingProgressViewModelTest : BaseUnitTest() { private lateinit var readingProgressManager: ReadingProgressManager private lateinit var bibleReadingManager: BibleReadingManager private lateinit var settingsManager: SettingsManager - private lateinit var application: Application private lateinit var readingProgressViewModel: ReadingProgressViewModel @@ -49,20 +51,44 @@ class ReadingProgressViewModelTest : BaseUnitTest() { readingProgressManager = mockk() bibleReadingManager = mockk() - settingsManager = mockk() - application = mockk() + settingsManager = mockk().apply { every { settings() } returns flowOf(Settings.DEFAULT) } - readingProgressViewModel = ReadingProgressViewModel(bibleReadingManager, readingProgressManager, settingsManager, application) + readingProgressViewModel = ReadingProgressViewModel(bibleReadingManager, readingProgressManager, settingsManager, testCoroutineDispatcherProvider) } @Test - fun `test loadReadingProgress with exception`() = runTest { + fun `test loadReadingProgress, with exception`() = runTest { coEvery { readingProgressManager.read() } throws RuntimeException("random exception") - val job = async { readingProgressViewModel.readingProgress().first { it is BaseViewModel.ViewData.Failure } } readingProgressViewModel.loadReadingProgress() + assertEquals( + ReadingProgressViewModel.ViewState( + loading = false, + items = emptyList(), + error = ReadingProgressViewModel.ViewState.Error.ReadingProgressLoadingError + ), + readingProgressViewModel.viewState().first() + ) + + readingProgressViewModel.markErrorAsShown(ReadingProgressViewModel.ViewState.Error.VerseOpeningError(VerseIndex(0, 0, 0))) + assertEquals( + ReadingProgressViewModel.ViewState( + loading = false, + items = emptyList(), + error = ReadingProgressViewModel.ViewState.Error.ReadingProgressLoadingError + ), + readingProgressViewModel.viewState().first() + ) - job.await() + readingProgressViewModel.markErrorAsShown(ReadingProgressViewModel.ViewState.Error.ReadingProgressLoadingError) + assertEquals( + ReadingProgressViewModel.ViewState( + loading = false, + items = emptyList(), + error = null + ), + readingProgressViewModel.viewState().first() + ) } @Test @@ -70,30 +96,39 @@ class ReadingProgressViewModelTest : BaseUnitTest() { coEvery { bibleReadingManager.currentTranslation() } returns flowOf(MockContents.kjvShortName) coEvery { bibleReadingManager.readBookNames(MockContents.kjvShortName) } returns MockContents.kjvBookNames coEvery { readingProgressManager.read() } returns ReadingProgress( - continuousReadingDays = 2, - lastReadingTimestamp = 12345L, - chapterReadingStatus = listOf( - ReadingProgress.ChapterReadingStatus(0, 0, 1, 2L, 123L), - ReadingProgress.ChapterReadingStatus(0, 1, 1, 2L, 123L), - ReadingProgress.ChapterReadingStatus(1, 1, 1, 2L, 123L), - ReadingProgress.ChapterReadingStatus(30, 0, 1, 2L, 123L), - ReadingProgress.ChapterReadingStatus(62, 0, 1, 2L, 123L), - ) + continuousReadingDays = 2, + lastReadingTimestamp = 12345L, + chapterReadingStatus = listOf( + ReadingProgress.ChapterReadingStatus(bookIndex = 0, chapterIndex = 0, readCount = 1, timeSpentInMillis = 2L, lastReadingTimestamp = 123L), + ReadingProgress.ChapterReadingStatus(bookIndex = 0, chapterIndex = 1, readCount = 1, timeSpentInMillis = 2L, lastReadingTimestamp = 123L), + ReadingProgress.ChapterReadingStatus(bookIndex = 0, chapterIndex = 2, readCount = 0, timeSpentInMillis = 2L, lastReadingTimestamp = 123L), + ReadingProgress.ChapterReadingStatus(bookIndex = 1, chapterIndex = 1, readCount = 1, timeSpentInMillis = 2L, lastReadingTimestamp = 123L), + ReadingProgress.ChapterReadingStatus(bookIndex = 30, chapterIndex = 0, readCount = 1, timeSpentInMillis = 2L, lastReadingTimestamp = 123L), + ReadingProgress.ChapterReadingStatus(bookIndex = 62, chapterIndex = 0, readCount = 1, timeSpentInMillis = 2L, lastReadingTimestamp = 123L), + ) ) - val job = async { readingProgressViewModel.readingProgress().first { it is BaseViewModel.ViewData.Success } } readingProgressViewModel.loadReadingProgress() - val actual = job.await() - assertTrue(actual is BaseViewModel.ViewData.Success) - assertEquals(67, actual.data.items.size) - assertEquals(2, (actual.data.items[0] as ReadingProgressSummaryItem).continuousReadingDays) - assertEquals(5, (actual.data.items[0] as ReadingProgressSummaryItem).chaptersRead) - assertEquals(2, (actual.data.items[0] as ReadingProgressSummaryItem).finishedBooks) - assertEquals(1, (actual.data.items[0] as ReadingProgressSummaryItem).finishedOldTestament) - assertEquals(1, (actual.data.items[0] as ReadingProgressSummaryItem).finishedNewTestament) - actual.data.items.subList(1, actual.data.items.size).forEachIndexed { book, item -> - assertTrue(item is ReadingProgressDetailItem) + val actual = readingProgressViewModel.viewState().first() + assertFalse(actual.loading) + assertEquals(67, actual.items.size) + assertEquals( + ReadingProgressItem.Summary( + settings = Settings.DEFAULT, + continuousReadingDays = 2, + chaptersRead = 5, + finishedBooks = 2, + finishedOldTestament = 1, + finishedNewTestament = 1 + ), + actual.items[0] + ) + actual.items.subList(1, actual.items.size).forEachIndexed { book, item -> + assertTrue(item is ReadingProgressItem.Book) + assertEquals(Settings.DEFAULT, item.settings) + assertEquals(book, item.bookIndex) + assertEquals(book == 0, item.expanded) when (book) { 0 -> { item.chaptersRead.forEachIndexed { chapter, read -> assertEquals(chapter == 0 || chapter == 1, read) } @@ -117,5 +152,72 @@ class ReadingProgressViewModelTest : BaseUnitTest() { } } } + assertNull(actual.error) + + readingProgressViewModel.expandOrCollapseBook(bookIndex = 1) + with(readingProgressViewModel.viewState().first().items.subList(1, actual.items.size)) { + forEachIndexed { book, item -> + assertTrue(item is ReadingProgressItem.Book) + assertEquals(book == 0 || book == 1, item.expanded) + } + } + + readingProgressViewModel.expandOrCollapseBook(bookIndex = 1) + with(readingProgressViewModel.viewState().first().items.subList(1, actual.items.size)) { + forEachIndexed { book, item -> + assertTrue(item is ReadingProgressItem.Book) + assertEquals(book == 0, item.expanded) + } + } + } + + @Test + fun `test expandOrCollapseBook(), with no items`() = runTest { + readingProgressViewModel.expandOrCollapseBook(bookIndex = 0) + + assertEquals( + ReadingProgressViewModel.ViewState( + loading = false, + items = emptyList(), + error = null + ), + readingProgressViewModel.viewState().first() + ) + } + + @Test + fun `test openVerse(), with exception`() = runTest { + coEvery { bibleReadingManager.saveCurrentVerseIndex(VerseIndex(0, 0, 0)) } throws RuntimeException("random exception") + + readingProgressViewModel.openVerse(VerseIndex(0, 0, 0)) + + assertEquals( + ReadingProgressViewModel.ViewState( + loading = false, + items = emptyList(), + error = ReadingProgressViewModel.ViewState.Error.VerseOpeningError(VerseIndex(0, 0, 0)) + ), + readingProgressViewModel.viewState().first() + ) + } + + @Test + fun `test openVerse()`() = runTest { + coEvery { bibleReadingManager.saveCurrentVerseIndex(VerseIndex(0, 0, 0)) } returns Unit + + val viewAction = async(Dispatchers.Unconfined) { readingProgressViewModel.viewAction().first() } + + readingProgressViewModel.markErrorAsShown(ReadingProgressViewModel.ViewState.Error.ReadingProgressLoadingError) + readingProgressViewModel.openVerse(VerseIndex(0, 0, 0)) + + assertEquals( + ReadingProgressViewModel.ViewState( + loading = false, + items = emptyList(), + error = null + ), + readingProgressViewModel.viewState().first() + ) + assertEquals(ReadingProgressViewModel.ViewAction.OpenReadingScreen, viewAction.await()) } } diff --git a/app/src/test/kotlin/me/xizzhu/android/joshua/progress/ReadingProgressItemTest.kt b/app/src/test/kotlin/me/xizzhu/android/joshua/tests/TestExecutor.kt similarity index 54% rename from app/src/test/kotlin/me/xizzhu/android/joshua/progress/ReadingProgressItemTest.kt rename to app/src/test/kotlin/me/xizzhu/android/joshua/tests/TestExecutor.kt index 99e6292b..abbfabad 100644 --- a/app/src/test/kotlin/me/xizzhu/android/joshua/progress/ReadingProgressItemTest.kt +++ b/app/src/test/kotlin/me/xizzhu/android/joshua/tests/TestExecutor.kt @@ -14,17 +14,12 @@ * limitations under the License. */ -package me.xizzhu.android.joshua.progress +package me.xizzhu.android.joshua.tests -import me.xizzhu.android.joshua.R -import me.xizzhu.android.joshua.tests.BaseUnitTest -import kotlin.test.Test -import kotlin.test.assertEquals +import java.util.concurrent.Executor -class ReadingProgressItemTest : BaseUnitTest() { - @Test - fun testItemViewType() { - assertEquals(R.layout.item_reading_progress_header, ReadingProgressSummaryItem(0, 0, 0, 0, 0).viewType) - assertEquals(R.layout.item_reading_progress, ReadingProgressDetailItem("", 0, emptyList(), 0, { _, _ -> }, false).viewType) +class TestExecutor : Executor { + override fun execute(command: Runnable?) { + command?.run() } }