feat: add board detail viewmodel state and selection logic
This commit is contained in:
@@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user