Add missing NOT NOW and DONE swimlanes to Kanban view

- Add indexed_by parameter to FizzyApiService.getCards() to fetch cards
  by status (all, closed, not_now)
- Modify CardRepositoryImpl to fetch all card states in parallel and
  combine results
- Update KanbanViewModel.distributeCardsToColumns() to create virtual
  swimlanes for NOT NOW, Triage, and DONE - always visible
- Add distinct styling for virtual swimlanes in KanbanScreen:
  - NOT NOW: purple color, Schedule icon
  - Triage: orange color, Inbox icon
  - DONE: green color, CheckCircle icon
- Hide Edit/Delete menu and Add Card button for virtual columns
This commit is contained in:
Paweł Orzech 2026-01-19 09:30:18 +00:00
parent 101bf72250
commit 6c0b502630
No known key found for this signature in database
5 changed files with 237 additions and 64 deletions

View file

@ -76,7 +76,10 @@ interface FizzyApiService {
// ==================== Cards ====================
@GET("cards.json")
suspend fun getCards(@Query("board_ids[]") boardId: String? = null): Response<CardsResponse>
suspend fun getCards(
@Query("board_ids[]") boardId: String? = null,
@Query("indexed_by") indexedBy: String? = null
): Response<CardsResponse>
@GET("cards/{cardNumber}.json")
suspend fun getCard(@Path("cardNumber") cardNumber: Int): Response<CardResponse>

View file

@ -8,6 +8,8 @@ import com.fizzy.android.domain.model.Card
import com.fizzy.android.domain.model.Comment
import com.fizzy.android.domain.model.Step
import com.fizzy.android.domain.repository.CardRepository
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import java.time.LocalDate
import javax.inject.Inject
import javax.inject.Singleton
@ -19,18 +21,59 @@ class CardRepositoryImpl @Inject constructor(
private val apiService: FizzyApiService
) : CardRepository {
override suspend fun getBoardCards(boardId: String): ApiResult<List<Card>> {
val result = ApiResult.from {
apiService.getCards(boardId)
override suspend fun getBoardCards(boardId: String): ApiResult<List<Card>> = coroutineScope {
Log.d(TAG, "getBoardCards: Fetching all card states for board $boardId")
// Fetch all 3 types of cards in parallel
val activeDeferred = async { apiService.getCards(boardId, "all") }
val closedDeferred = async { apiService.getCards(boardId, "closed") }
val notNowDeferred = async { apiService.getCards(boardId, "not_now") }
val activeResponse = activeDeferred.await()
val closedResponse = closedDeferred.await()
val notNowResponse = notNowDeferred.await()
Log.d(TAG, "getBoardCards responses - active: ${activeResponse.isSuccessful}, closed: ${closedResponse.isSuccessful}, notNow: ${notNowResponse.isSuccessful}")
// Combine all cards
val allCards = mutableListOf<Card>()
if (activeResponse.isSuccessful) {
activeResponse.body()?.let { cards ->
Log.d(TAG, "getBoardCards: ${cards.size} active cards")
allCards.addAll(cards.map { it.toDomain() })
}
Log.d(TAG, "getBoardCards result: $result")
when (result) {
is ApiResult.Success -> Log.d(TAG, "getBoardCards success: ${result.data.size} cards")
is ApiResult.Error -> Log.e(TAG, "getBoardCards error: ${result.code} - ${result.message}")
is ApiResult.Exception -> Log.e(TAG, "getBoardCards exception", result.throwable)
} else {
Log.e(TAG, "getBoardCards active error: ${activeResponse.code()} - ${activeResponse.message()}")
}
return result.map { response ->
response.map { it.toDomain() }.sortedBy { it.position }
if (closedResponse.isSuccessful) {
closedResponse.body()?.let { cards ->
Log.d(TAG, "getBoardCards: ${cards.size} closed cards")
allCards.addAll(cards.map { it.toDomain() })
}
} else {
Log.e(TAG, "getBoardCards closed error: ${closedResponse.code()} - ${closedResponse.message()}")
}
if (notNowResponse.isSuccessful) {
notNowResponse.body()?.let { cards ->
Log.d(TAG, "getBoardCards: ${cards.size} not_now cards")
allCards.addAll(cards.map { it.toDomain() })
}
} else {
Log.e(TAG, "getBoardCards not_now error: ${notNowResponse.code()} - ${notNowResponse.message()}")
}
// Deduplicate by ID and sort
val uniqueCards = allCards.distinctBy { it.id }.sortedBy { it.position }
Log.d(TAG, "getBoardCards: Total ${uniqueCards.size} unique cards")
// Return success if at least active cards were fetched
if (activeResponse.isSuccessful) {
ApiResult.Success(uniqueCards)
} else {
ApiResult.Error(activeResponse.code(), activeResponse.message())
}
}

View file

@ -5,8 +5,12 @@ import androidx.lifecycle.viewModelScope
import com.fizzy.android.core.network.ApiResult
import com.fizzy.android.domain.model.Board
import com.fizzy.android.domain.repository.BoardRepository
import com.fizzy.android.domain.repository.CardRepository
import com.fizzy.android.domain.repository.NotificationRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -34,6 +38,7 @@ sealed class BoardListEvent {
@HiltViewModel
class BoardListViewModel @Inject constructor(
private val boardRepository: BoardRepository,
private val cardRepository: CardRepository,
private val notificationRepository: NotificationRepository
) : ViewModel() {
@ -54,10 +59,12 @@ class BoardListViewModel @Inject constructor(
private fun observeBoards() {
viewModelScope.launch {
boardRepository.observeBoards().collect { boards ->
// Fetch stats for boards from the flow
val boardsWithStats = fetchBoardStats(boards)
_uiState.update { state ->
state.copy(
boards = boards,
filteredBoards = filterBoards(boards, state.searchQuery)
boards = boardsWithStats,
filteredBoards = filterBoards(boardsWithStats, state.searchQuery)
)
}
}
@ -70,7 +77,16 @@ class BoardListViewModel @Inject constructor(
when (val result = boardRepository.getBoards()) {
is ApiResult.Success -> {
_uiState.update { it.copy(isLoading = false) }
val boards = result.data
// Fetch stats for each board in parallel
val boardsWithStats = fetchBoardStats(boards)
_uiState.update { state ->
state.copy(
isLoading = false,
boards = boardsWithStats,
filteredBoards = filterBoards(boardsWithStats, state.searchQuery)
)
}
}
is ApiResult.Error -> {
_uiState.update {
@ -92,6 +108,19 @@ class BoardListViewModel @Inject constructor(
}
}
private suspend fun fetchBoardStats(boards: List<Board>): List<Board> = coroutineScope {
boards.map { board ->
async {
val columnsResult = boardRepository.getColumns(board.id)
val cardsResult = cardRepository.getBoardCards(board.id)
board.copy(
columnsCount = (columnsResult as? ApiResult.Success)?.data?.size ?: 0,
cardsCount = (cardsResult as? ApiResult.Success)?.data?.size ?: 0
)
}
}.awaitAll()
}
fun refresh() {
viewModelScope.launch {
_uiState.update { it.copy(isRefreshing = true) }
@ -99,6 +128,18 @@ class BoardListViewModel @Inject constructor(
boardRepository.refreshBoards()
notificationRepository.getNotifications()
// Re-fetch stats for updated boards
val currentBoards = _uiState.value.boards
if (currentBoards.isNotEmpty()) {
val boardsWithStats = fetchBoardStats(currentBoards)
_uiState.update { state ->
state.copy(
boards = boardsWithStats,
filteredBoards = filterBoards(boardsWithStats, state.searchQuery)
)
}
}
_uiState.update { it.copy(isRefreshing = false) }
}
}

View file

@ -212,6 +212,28 @@ private fun KanbanColumn(
) {
var showMenu by remember { mutableStateOf(false) }
// Determine column type and styling
val isTriageColumn = column.id.isEmpty()
val isNotNowColumn = column.id == "__not_now__"
val isDoneColumn = column.id == "__done__"
val isVirtualColumn = isTriageColumn || isNotNowColumn || isDoneColumn
// Colors for different column types
val columnColor = when {
isNotNowColumn -> Color(0xFF8B5CF6) // Purple for Not Now
isTriageColumn -> Color(0xFFF97316) // Orange for Triage
isDoneColumn -> Color(0xFF22C55E) // Green for Done
else -> MaterialTheme.colorScheme.onSurface
}
// Icons for virtual columns
val columnIcon = when {
isNotNowColumn -> Icons.Default.Schedule
isTriageColumn -> Icons.Default.Inbox
isDoneColumn -> Icons.Default.CheckCircle
else -> null
}
Card(
modifier = Modifier
.width(300.dp)
@ -233,15 +255,28 @@ private fun KanbanColumn(
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
columnIcon?.let { icon ->
Icon(
icon,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = columnColor
)
Spacer(modifier = Modifier.width(6.dp))
}
Text(
text = column.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
fontWeight = FontWeight.SemiBold,
color = if (isVirtualColumn) columnColor else MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.width(8.dp))
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)
color = if (isVirtualColumn)
columnColor.copy(alpha = 0.2f)
else
MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)
) {
Text(
text = column.cards.size.toString(),
@ -251,6 +286,8 @@ private fun KanbanColumn(
}
}
// Hide menu for all virtual columns
if (!isVirtualColumn) {
Box {
IconButton(onClick = { showMenu = true }) {
Icon(
@ -289,6 +326,7 @@ private fun KanbanColumn(
}
}
}
}
// Cards List
LazyColumn(
@ -307,7 +345,8 @@ private fun KanbanColumn(
}
}
// Add Card Button
// Add Card Button (hide for all virtual columns)
if (!isVirtualColumn) {
TextButton(
onClick = onAddCard,
modifier = Modifier
@ -320,6 +359,7 @@ private fun KanbanColumn(
}
}
}
}
}
@Composable

View file

@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
import com.fizzy.android.core.network.ApiResult
import com.fizzy.android.domain.model.Board
import com.fizzy.android.domain.model.Card
import com.fizzy.android.domain.model.CardStatus
import com.fizzy.android.domain.model.Column
import com.fizzy.android.domain.repository.BoardRepository
import com.fizzy.android.domain.repository.CardRepository
@ -147,16 +148,61 @@ class KanbanViewModel @Inject constructor(
private fun distributeCardsToColumns(columns: List<Column>, cards: List<Card>): List<Column> {
Log.d(TAG, "distributeCardsToColumns: ${cards.size} cards, ${columns.size} columns")
Log.d(TAG, "Column IDs: ${columns.map { "${it.name}=${it.id}" }}")
Log.d(TAG, "Card columnIds: ${cards.map { "${it.title}${it.columnId}" }}")
Log.d(TAG, "Card statuses: ${cards.map { "${it.title}${it.status}" }}")
val cardsByColumn = cards.groupBy { it.columnId }
Log.d(TAG, "Cards grouped by column: ${cardsByColumn.mapValues { it.value.map { c -> c.title } }}")
// Group cards by status
val deferredCards = cards.filter { it.status == CardStatus.DEFERRED }.sortedBy { it.position }
val closedCards = cards.filter { it.status == CardStatus.CLOSED }.sortedBy { it.position }
val activeCards = cards.filter { it.status == CardStatus.ACTIVE || it.status == CardStatus.TRIAGED }
return columns.map { column ->
// Group active cards by column
val triageCards = activeCards.filter { it.columnId.isEmpty() }.sortedBy { it.position }
val cardsByColumn = activeCards.filter { it.columnId.isNotEmpty() }.groupBy { it.columnId }
Log.d(TAG, "Cards by status - deferred: ${deferredCards.size}, triage: ${triageCards.size}, in columns: ${cardsByColumn.values.sumOf { it.size }}, closed: ${closedCards.size}")
val result = mutableListOf<Column>()
val defaultBoardId = columns.firstOrNull()?.boardId ?: boardId
// NOT NOW swimlane (deferred cards) - always visible, position -2
result += Column(
id = "__not_now__",
name = "Not Now",
position = -2,
boardId = defaultBoardId,
cards = deferredCards
)
Log.d(TAG, "Added NOT NOW swimlane with ${deferredCards.size} cards")
// Triage swimlane (active cards without column) - always visible, position -1
result += Column(
id = "",
name = "Triage",
position = -1,
boardId = defaultBoardId,
cards = triageCards
)
Log.d(TAG, "Added Triage swimlane with ${triageCards.size} cards")
// User-created columns with active cards
val columnsWithCards = columns.map { column ->
val columnCards = cardsByColumn[column.id]?.sortedBy { it.position } ?: emptyList()
Log.d(TAG, "Column '${column.name}' (${column.id}): ${columnCards.size} cards")
column.copy(cards = columnCards)
}
result += columnsWithCards
// DONE swimlane (closed cards) - always visible, position at end
result += Column(
id = "__done__",
name = "Done",
position = Int.MAX_VALUE,
boardId = defaultBoardId,
cards = closedCards
)
Log.d(TAG, "Added DONE swimlane with ${closedCards.size} cards")
return result
}
fun refresh() {