feat: add board detail viewmodel state and selection logic

This commit is contained in:
2026-03-16 00:43:48 -04:00
parent 2c40892906
commit 89537a57b7
2 changed files with 900 additions and 0 deletions

View File

@@ -0,0 +1,368 @@
package space.hackenslacker.kanbn4droid.app.boarddetail
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
data class BoardDetailUiState(
val isInitialLoading: Boolean = false,
val isRefreshing: Boolean = false,
val isMutating: Boolean = false,
val boardDetail: BoardDetail? = null,
val fullScreenErrorMessage: String? = null,
val currentPageIndex: Int = 0,
val selectedCardIds: Set<String> = emptySet(),
val editingListId: String? = null,
val editingListTitle: String = "",
)
sealed interface BoardDetailUiEvent {
data class NavigateToCardPlaceholder(val cardId: String) : BoardDetailUiEvent
data class ShowServerError(val message: String) : BoardDetailUiEvent
data class ShowWarning(val message: String) : BoardDetailUiEvent
}
interface BoardDetailDataSource {
suspend fun getBoardDetail(boardId: String): BoardsApiResult<BoardDetail>
suspend fun moveCards(cardIds: Collection<String>, targetListId: String): CardBatchMutationResult
suspend fun deleteCards(cardIds: Collection<String>): CardBatchMutationResult
suspend fun renameList(listId: String, newTitle: String): BoardsApiResult<Unit>
}
internal class BoardDetailRepositoryDataSource(
private val repository: BoardDetailRepository,
) : BoardDetailDataSource {
override suspend fun getBoardDetail(boardId: String): BoardsApiResult<BoardDetail> {
return repository.getBoardDetail(boardId)
}
override suspend fun moveCards(cardIds: Collection<String>, targetListId: String): CardBatchMutationResult {
return repository.moveCards(cardIds, targetListId)
}
override suspend fun deleteCards(cardIds: Collection<String>): CardBatchMutationResult {
return repository.deleteCards(cardIds)
}
override suspend fun renameList(listId: String, newTitle: String): BoardsApiResult<Unit> {
return repository.renameList(listId, newTitle)
}
}
class BoardDetailViewModel(
private val boardId: String,
private val repository: BoardDetailDataSource,
) : ViewModel() {
private val _uiState = MutableStateFlow(BoardDetailUiState())
val uiState: StateFlow<BoardDetailUiState> = _uiState.asStateFlow()
private val _events = MutableSharedFlow<BoardDetailUiEvent>()
val events: SharedFlow<BoardDetailUiEvent> = _events.asSharedFlow()
fun loadBoardDetail() {
fetchBoardDetail(initial = true)
}
fun retryLoad() {
fetchBoardDetail(initial = true)
}
fun refreshBoardDetail() {
fetchBoardDetail(initial = false, refresh = true)
}
fun setCurrentPage(pageIndex: Int) {
_uiState.update {
it.copy(currentPageIndex = clampPageIndex(detail = it.boardDetail, pageIndex = pageIndex))
}
}
fun selectAllOnCurrentPage() {
val current = _uiState.value
val pageCards = current.boardDetail
?.lists
?.getOrNull(current.currentPageIndex)
?.cards
.orEmpty()
.map { it.id }
.toSet()
if (pageCards.isEmpty()) {
return
}
_uiState.update {
it.copy(selectedCardIds = it.selectedCardIds + pageCards)
}
}
fun onCardLongPressed(cardId: String) {
toggleCardSelection(cardId)
}
fun onCardTapped(cardId: String) {
val hasSelection = _uiState.value.selectedCardIds.isNotEmpty()
if (hasSelection) {
toggleCardSelection(cardId)
return
}
viewModelScope.launch {
_events.emit(BoardDetailUiEvent.NavigateToCardPlaceholder(cardId))
}
}
fun onBackPressed(): Boolean {
if (_uiState.value.selectedCardIds.isEmpty()) {
return false
}
_uiState.update { it.copy(selectedCardIds = emptySet()) }
return true
}
fun moveSelectedCards(targetListId: String) {
runMutation { selectedIds -> repository.moveCards(selectedIds, targetListId) }
}
fun deleteSelectedCards() {
runMutation(repository::deleteCards)
}
fun startEditingList(listId: String) {
val list = _uiState.value.boardDetail?.lists?.firstOrNull { it.id == listId } ?: return
_uiState.update {
it.copy(
editingListId = list.id,
editingListTitle = list.title,
)
}
}
fun updateEditingTitle(title: String) {
_uiState.update { it.copy(editingListTitle = title) }
}
fun submitRenameList() {
val snapshot = _uiState.value
val editingListId = snapshot.editingListId ?: return
val currentList = snapshot.boardDetail?.lists?.firstOrNull { it.id == editingListId } ?: return
val trimmedTitle = snapshot.editingListTitle.trim()
if (trimmedTitle == currentList.title.trim()) {
_uiState.update { it.copy(editingListId = null, editingListTitle = "") }
return
}
viewModelScope.launch {
_uiState.update { it.copy(isMutating = true) }
when (val result = repository.renameList(editingListId, trimmedTitle)) {
is BoardsApiResult.Success -> {
_uiState.update { it.copy(editingListId = null, editingListTitle = "") }
val reloadFailureMessage = tryReloadDetailAndReconcile()
_uiState.update { it.copy(isMutating = false) }
if (reloadFailureMessage != null) {
_events.emit(
BoardDetailUiEvent.ShowWarning(
"Changes applied, but refresh failed. Pull to refresh.",
),
)
}
}
is BoardsApiResult.Failure -> {
_uiState.update { it.copy(isMutating = false) }
_events.emit(BoardDetailUiEvent.ShowServerError(result.message))
}
}
}
}
private fun fetchBoardDetail(initial: Boolean, refresh: Boolean = false) {
if (_uiState.value.isMutating) {
return
}
viewModelScope.launch {
_uiState.update {
it.copy(
isInitialLoading = initial && it.boardDetail == null,
isRefreshing = refresh,
fullScreenErrorMessage = if (initial && it.boardDetail == null) null else it.fullScreenErrorMessage,
)
}
when (val result = repository.getBoardDetail(boardId)) {
is BoardsApiResult.Success -> {
_uiState.update {
reconcileWithNewDetail(it, result.value).copy(
isInitialLoading = false,
isRefreshing = false,
fullScreenErrorMessage = null,
)
}
}
is BoardsApiResult.Failure -> {
_uiState.update {
if (it.boardDetail == null) {
it.copy(
isInitialLoading = false,
isRefreshing = false,
fullScreenErrorMessage = result.message,
)
} else {
it.copy(
isInitialLoading = false,
isRefreshing = false,
)
}
}
if (_uiState.value.boardDetail != null) {
_events.emit(BoardDetailUiEvent.ShowServerError(result.message))
}
}
}
}
}
private fun runMutation(
mutation: suspend (Set<String>) -> CardBatchMutationResult,
) {
val preMutation = _uiState.value
val selectedIds = preMutation.selectedCardIds
if (selectedIds.isEmpty()) {
return
}
viewModelScope.launch {
_uiState.update { it.copy(isMutating = true) }
when (val result = mutation(selectedIds)) {
is CardBatchMutationResult.Success -> {
_uiState.update { it.copy(selectedCardIds = emptySet()) }
val reloadFailureMessage = tryReloadDetailAndReconcile()
_uiState.update { it.copy(isMutating = false) }
if (reloadFailureMessage != null) {
_events.emit(
BoardDetailUiEvent.ShowWarning(
"Changes applied, but refresh failed. Pull to refresh.",
),
)
}
}
is CardBatchMutationResult.PartialSuccess -> {
val reloadFailureMessage = tryReloadDetailAndReconcile()
if (reloadFailureMessage == null) {
val visibleIds = allVisibleCardIds(_uiState.value.boardDetail)
_uiState.update {
it.copy(selectedCardIds = result.failedCardIds.intersect(visibleIds))
}
_events.emit(BoardDetailUiEvent.ShowWarning(result.message))
} else {
_uiState.update {
it.copy(
selectedCardIds = preMutation.selectedCardIds,
currentPageIndex = preMutation.currentPageIndex,
)
}
_events.emit(
BoardDetailUiEvent.ShowWarning(
"Some changes were applied, but refresh failed. Pull to refresh.",
),
)
}
_uiState.update { it.copy(isMutating = false) }
}
is CardBatchMutationResult.Failure -> {
_uiState.update {
it.copy(
isMutating = false,
selectedCardIds = preMutation.selectedCardIds,
currentPageIndex = preMutation.currentPageIndex,
)
}
_events.emit(BoardDetailUiEvent.ShowServerError(result.message))
}
}
}
}
private suspend fun tryReloadDetailAndReconcile(): String? {
return when (val result = repository.getBoardDetail(boardId)) {
is BoardsApiResult.Success -> {
_uiState.update { reconcileWithNewDetail(it, result.value) }
null
}
is BoardsApiResult.Failure -> result.message
}
}
private fun toggleCardSelection(cardId: String) {
_uiState.update {
val next = it.selectedCardIds.toMutableSet()
if (!next.add(cardId)) {
next.remove(cardId)
}
it.copy(selectedCardIds = next)
}
}
private fun reconcileWithNewDetail(current: BoardDetailUiState, detail: BoardDetail): BoardDetailUiState {
val clampedPage = clampPageIndex(detail, current.currentPageIndex)
val visibleIds = allVisibleCardIds(detail)
val prunedSelection = current.selectedCardIds.intersect(visibleIds)
val hasEditedList = current.editingListId?.let { id -> detail.lists.any { it.id == id } } ?: false
return current.copy(
boardDetail = detail,
currentPageIndex = clampedPage,
selectedCardIds = prunedSelection,
editingListId = if (hasEditedList) current.editingListId else null,
editingListTitle = if (hasEditedList) current.editingListTitle else "",
)
}
private fun clampPageIndex(detail: BoardDetail?, pageIndex: Int): Int {
val lastIndex = detail?.lists?.lastIndex ?: -1
if (lastIndex < 0) {
return 0
}
return pageIndex.coerceIn(0, lastIndex)
}
private fun allVisibleCardIds(detail: BoardDetail?): Set<String> {
return detail?.lists
.orEmpty()
.flatMap { list -> list.cards }
.map { card -> card.id }
.toSet()
}
class Factory(
private val boardId: String,
private val repository: BoardDetailRepository,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(BoardDetailViewModel::class.java)) {
return BoardDetailViewModel(
boardId = boardId,
repository = BoardDetailRepositoryDataSource(repository),
) as T
}
throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}")
}
}
}