mirror of
https://github.com/pawelorzech/Fuzzel.git
synced 2026-01-29 19:54:30 +00:00
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:
parent
101bf72250
commit
6c0b502630
5 changed files with 237 additions and 64 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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() })
|
||||
}
|
||||
} 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())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Reference in a new issue