diff --git a/app/src/main/java/com/fizzy/android/data/api/FizzyApiService.kt b/app/src/main/java/com/fizzy/android/data/api/FizzyApiService.kt index c641052..f3f4327 100644 --- a/app/src/main/java/com/fizzy/android/data/api/FizzyApiService.kt +++ b/app/src/main/java/com/fizzy/android/data/api/FizzyApiService.kt @@ -76,7 +76,10 @@ interface FizzyApiService { // ==================== Cards ==================== @GET("cards.json") - suspend fun getCards(@Query("board_ids[]") boardId: String? = null): Response + suspend fun getCards( + @Query("board_ids[]") boardId: String? = null, + @Query("indexed_by") indexedBy: String? = null + ): Response @GET("cards/{cardNumber}.json") suspend fun getCard(@Path("cardNumber") cardNumber: Int): Response diff --git a/app/src/main/java/com/fizzy/android/data/repository/CardRepositoryImpl.kt b/app/src/main/java/com/fizzy/android/data/repository/CardRepositoryImpl.kt index da9a400..f594ede 100644 --- a/app/src/main/java/com/fizzy/android/data/repository/CardRepositoryImpl.kt +++ b/app/src/main/java/com/fizzy/android/data/repository/CardRepositoryImpl.kt @@ -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> { - val result = ApiResult.from { - apiService.getCards(boardId) + override suspend fun getBoardCards(boardId: String): ApiResult> = 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() + + if (activeResponse.isSuccessful) { + activeResponse.body()?.let { cards -> + Log.d(TAG, "getBoardCards: ${cards.size} active cards") + allCards.addAll(cards.map { it.toDomain() }) + } + } else { + Log.e(TAG, "getBoardCards active error: ${activeResponse.code()} - ${activeResponse.message()}") } - 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) + + 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()}") } - return result.map { response -> - response.map { it.toDomain() }.sortedBy { it.position } + + 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()) } } diff --git a/app/src/main/java/com/fizzy/android/feature/boards/BoardListViewModel.kt b/app/src/main/java/com/fizzy/android/feature/boards/BoardListViewModel.kt index 1d356ba..2ee482c 100644 --- a/app/src/main/java/com/fizzy/android/feature/boards/BoardListViewModel.kt +++ b/app/src/main/java/com/fizzy/android/feature/boards/BoardListViewModel.kt @@ -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): List = 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) } } } diff --git a/app/src/main/java/com/fizzy/android/feature/kanban/KanbanScreen.kt b/app/src/main/java/com/fizzy/android/feature/kanban/KanbanScreen.kt index 636be83..77cc120 100644 --- a/app/src/main/java/com/fizzy/android/feature/kanban/KanbanScreen.kt +++ b/app/src/main/java/com/fizzy/android/feature/kanban/KanbanScreen.kt @@ -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,41 +286,44 @@ private fun KanbanColumn( } } - Box { - IconButton(onClick = { showMenu = true }) { - Icon( - Icons.Default.MoreVert, - contentDescription = "Column options", - modifier = Modifier.size(20.dp) - ) - } + // Hide menu for all virtual columns + if (!isVirtualColumn) { + Box { + IconButton(onClick = { showMenu = true }) { + Icon( + Icons.Default.MoreVert, + contentDescription = "Column options", + modifier = Modifier.size(20.dp) + ) + } - DropdownMenu( - expanded = showMenu, - onDismissRequest = { showMenu = false } - ) { - DropdownMenuItem( - text = { Text("Edit") }, - leadingIcon = { Icon(Icons.Default.Edit, contentDescription = null) }, - onClick = { - showMenu = false - onEditColumn() - } - ) - DropdownMenuItem( - text = { Text("Delete") }, - leadingIcon = { - Icon( - Icons.Default.Delete, - contentDescription = null, - tint = MaterialTheme.colorScheme.error - ) - }, - onClick = { - showMenu = false - onDeleteColumn() - } - ) + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false } + ) { + DropdownMenuItem( + text = { Text("Edit") }, + leadingIcon = { Icon(Icons.Default.Edit, contentDescription = null) }, + onClick = { + showMenu = false + onEditColumn() + } + ) + DropdownMenuItem( + text = { Text("Delete") }, + leadingIcon = { + Icon( + Icons.Default.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + }, + onClick = { + showMenu = false + onDeleteColumn() + } + ) + } } } } @@ -307,16 +345,18 @@ private fun KanbanColumn( } } - // Add Card Button - TextButton( - onClick = onAddCard, - modifier = Modifier - .fillMaxWidth() - .padding(8.dp) - ) { - Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(modifier = Modifier.width(4.dp)) - Text("Add Card") + // Add Card Button (hide for all virtual columns) + if (!isVirtualColumn) { + TextButton( + onClick = onAddCard, + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) { + Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text("Add Card") + } } } } diff --git a/app/src/main/java/com/fizzy/android/feature/kanban/KanbanViewModel.kt b/app/src/main/java/com/fizzy/android/feature/kanban/KanbanViewModel.kt index f5e6f83..9cc9f0c 100644 --- a/app/src/main/java/com/fizzy/android/feature/kanban/KanbanViewModel.kt +++ b/app/src/main/java/com/fizzy/android/feature/kanban/KanbanViewModel.kt @@ -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, cards: List): List { 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() + 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() {