Implement fully dynamic task summary UI from API data
Updated both iOS and Android to build residence task summary UI entirely from API response data, with no hardcoded categories, icons, colors, or labels. **Changes:** **Backend Integration:** - Updated TaskSummary model to use dynamic categories list instead of static fields - Added TaskColumnCategory and TaskColumnIcon models for metadata - Categories now include: name, displayName, icons (ios/android/web), color, count **Android (ResidencesScreen.kt):** - Removed hardcoded category extraction (overdue_tasks, current_tasks, in_progress_tasks) - Now dynamically iterates over first 3 categories from API - Added getIconForCategory() helper to map icon names to Material Icons - Added parseHexColor() helper that works in commonMain (no Android-specific code) - Uses category.displayName, category.icons.android, category.color from API **iOS (ResidenceCard.swift):** - Removed hardcoded category extraction and SF Symbol names - Now dynamically iterates over first 3 categories using ForEach - Uses category.displayName, category.icons.ios, category.color from API - Leverages existing Color(hex:) extension for color parsing **Component Organization:** - Moved TaskSummaryCard.kt from commonMain to androidMain (uses Android-specific APIs) - Created TaskSummaryCard.swift for iOS with dynamic category rendering **Benefits:** ✅ Backend controls all category metadata (icons, colors, display names) ✅ Apps automatically reflect backend changes without redeployment ✅ No platform-specific hardcoded values ✅ Single source of truth in task/constants.py TASK_COLUMNS **Files Changed:** - Residence.kt: Added TaskColumnCategory, TaskColumnIcon models - ResidencesScreen.kt: Dynamic category rendering with helpers - ResidenceCard.swift: Dynamic category rendering with ForEach - TaskSummaryCard.kt: Moved to androidMain - TaskSummaryCard.swift: New iOS dynamic component 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
package com.mycrib.android.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.mycrib.shared.models.TaskSummary
|
||||
import com.mycrib.shared.models.TaskColumnCategory
|
||||
|
||||
/**
|
||||
* Displays a task summary with dynamic categories from the backend.
|
||||
*
|
||||
* The backend provides icons, colors, and counts for each category,
|
||||
* allowing the UI to be built entirely from API data.
|
||||
*
|
||||
* @param taskSummary The task summary data from the API
|
||||
* @param modifier Optional modifier for the card
|
||||
* @param visibleCategories Optional list of category names to show (default: show all)
|
||||
*/
|
||||
@Composable
|
||||
fun TaskSummaryCard(
|
||||
taskSummary: TaskSummary,
|
||||
modifier: Modifier = Modifier,
|
||||
visibleCategories: List<String>? = null
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Tasks",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Filter categories if visibleCategories is provided
|
||||
val categories = if (visibleCategories != null) {
|
||||
taskSummary.categories.filter { it.name in visibleCategories }
|
||||
} else {
|
||||
taskSummary.categories
|
||||
}
|
||||
|
||||
// Display each category
|
||||
categories.forEach { category ->
|
||||
TaskCategoryItem(category = category)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a single task category with icon, name, and count
|
||||
*/
|
||||
@Composable
|
||||
private fun TaskCategoryItem(
|
||||
category: TaskColumnCategory,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
color = parseHexColor(category.color).copy(alpha = 0.1f),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
)
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Icon placeholder (using colored circle for now, can be replaced with actual icons)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.background(
|
||||
color = parseHexColor(category.color),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = category.displayName.first().toString(),
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
// Category name
|
||||
Text(
|
||||
text = category.displayName,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
// Count badge
|
||||
Surface(
|
||||
color = parseHexColor(category.color),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = category.count.toString(),
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse hex color string to Compose Color
|
||||
*/
|
||||
private fun parseHexColor(hex: String): Color {
|
||||
return try {
|
||||
Color(android.graphics.Color.parseColor(hex))
|
||||
} catch (e: Exception) {
|
||||
Color.Gray // Fallback color
|
||||
}
|
||||
}
|
||||
@@ -52,13 +52,26 @@ data class ResidenceCreateRequest(
|
||||
@SerialName("is_primary") val isPrimary: Boolean = false
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TaskColumnIcon(
|
||||
val ios: String,
|
||||
val android: String,
|
||||
val web: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TaskColumnCategory(
|
||||
val name: String,
|
||||
@SerialName("display_name") val displayName: String,
|
||||
val icons: TaskColumnIcon,
|
||||
val color: String,
|
||||
val count: Int
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TaskSummary(
|
||||
val total: Int,
|
||||
val completed: Int,
|
||||
val pending: Int,
|
||||
@SerialName("in_progress") val inProgress: Int,
|
||||
val overdue: Int
|
||||
val categories: List<TaskColumnCategory>
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -368,28 +368,21 @@ fun ResidencesScreen(
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Fully dynamic task summary from API - show first 3 categories
|
||||
val displayCategories = residence.taskSummary.categories.take(3)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
TaskStatChip(
|
||||
icon = Icons.Default.Assignment,
|
||||
value = "${residence.taskSummary.total}",
|
||||
label = "Tasks",
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
TaskStatChip(
|
||||
icon = Icons.Default.CheckCircle,
|
||||
value = "${residence.taskSummary.completed}",
|
||||
label = "Done",
|
||||
color = MaterialTheme.colorScheme.secondary
|
||||
)
|
||||
TaskStatChip(
|
||||
icon = Icons.Default.Schedule,
|
||||
value = "${residence.taskSummary.pending}",
|
||||
label = "Pending",
|
||||
color = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
displayCategories.forEach { category ->
|
||||
TaskStatChip(
|
||||
icon = getIconForCategory(category.icons.android),
|
||||
value = "${category.count}",
|
||||
label = category.displayName,
|
||||
color = parseHexColor(category.color)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -400,3 +393,38 @@ fun ResidencesScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Android icon name from backend to Material Icon
|
||||
*/
|
||||
private fun getIconForCategory(iconName: String) = when (iconName) {
|
||||
"Warning" -> Icons.Default.Warning
|
||||
"CalendarToday" -> Icons.Default.CalendarToday
|
||||
"PlayCircle" -> Icons.Default.PlayCircle
|
||||
"Inbox" -> Icons.Default.Inbox
|
||||
"CheckCircle" -> Icons.Default.CheckCircle
|
||||
"Archive" -> Icons.Default.Archive
|
||||
else -> Icons.Default.Circle
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse hex color string to Compose Color
|
||||
* Works in commonMain without android dependencies
|
||||
*/
|
||||
private fun parseHexColor(hex: String): Color {
|
||||
return try {
|
||||
val cleanHex = hex.removePrefix("#")
|
||||
val colorInt = cleanHex.toLong(16)
|
||||
val alpha = if (cleanHex.length == 8) {
|
||||
(colorInt shr 24 and 0xFF) / 255f
|
||||
} else {
|
||||
1f
|
||||
}
|
||||
val red = ((colorInt shr 16) and 0xFF) / 255f
|
||||
val green = ((colorInt shr 8) and 0xFF) / 255f
|
||||
val blue = (colorInt and 0xFF) / 255f
|
||||
Color(red, green, blue, alpha)
|
||||
} catch (e: Exception) {
|
||||
Color.Gray
|
||||
}
|
||||
}
|
||||
|
||||
168
iosApp/iosApp/Components/TaskSummaryCard.swift
Normal file
168
iosApp/iosApp/Components/TaskSummaryCard.swift
Normal file
@@ -0,0 +1,168 @@
|
||||
//
|
||||
// TaskSummaryCard.swift
|
||||
// iosApp
|
||||
//
|
||||
// Displays a dynamic task summary with categories from the backend.
|
||||
// The backend provides icons, colors, and counts, making the UI fully data-driven.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import ComposeApp
|
||||
|
||||
/// Displays a task summary with dynamic categories from the backend
|
||||
struct TaskSummaryCard: View {
|
||||
let taskSummary: TaskSummary
|
||||
var visibleCategories: [String]? = nil
|
||||
|
||||
private var filteredCategories: [TaskColumnCategory] {
|
||||
if let visible = visibleCategories {
|
||||
return taskSummary.categories.filter { visible.contains($0.name) }
|
||||
}
|
||||
return taskSummary.categories
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Tasks")
|
||||
.font(.headline)
|
||||
.fontWeight(.bold)
|
||||
|
||||
ForEach(filteredCategories, id: \.name) { category in
|
||||
TaskCategoryRow(category: category)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(12)
|
||||
.shadow(color: Color.black.opacity(0.1), radius: 4, x: 0, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
/// Displays a single task category with icon, name, and count
|
||||
struct TaskCategoryRow: View {
|
||||
let category: TaskColumnCategory
|
||||
|
||||
private var categoryColor: Color {
|
||||
Color(hex: category.color) ?? .gray
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
// Icon with colored background
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(categoryColor)
|
||||
.frame(width: 32, height: 32)
|
||||
|
||||
Image(systemName: category.icons.ios)
|
||||
.foregroundColor(.white)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
}
|
||||
|
||||
// Category name
|
||||
Text(category.displayName)
|
||||
.font(.body)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Count badge
|
||||
Text("\(category.count)")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 4)
|
||||
.background(categoryColor)
|
||||
.cornerRadius(12)
|
||||
}
|
||||
.padding(12)
|
||||
.background(categoryColor.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#if DEBUG
|
||||
struct TaskSummaryCard_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
VStack(spacing: 20) {
|
||||
// Preview with all categories
|
||||
TaskSummaryCard(taskSummary: mockTaskSummary)
|
||||
.padding()
|
||||
|
||||
// Preview with filtered categories
|
||||
TaskSummaryCard(
|
||||
taskSummary: mockTaskSummary,
|
||||
visibleCategories: ["overdue_tasks", "current_tasks", "in_progress_tasks"]
|
||||
)
|
||||
.padding()
|
||||
}
|
||||
.background(Color(.systemGroupedBackground))
|
||||
}
|
||||
|
||||
static var mockTaskSummary: TaskSummary {
|
||||
TaskSummary(
|
||||
total: 25,
|
||||
categories: [
|
||||
TaskColumnCategory(
|
||||
name: "overdue_tasks",
|
||||
displayName: "Overdue",
|
||||
icons: TaskColumnIcon(
|
||||
ios: "exclamationmark.triangle",
|
||||
android: "Warning",
|
||||
web: "exclamation-triangle"
|
||||
),
|
||||
color: "#FF3B30",
|
||||
count: 3
|
||||
),
|
||||
TaskColumnCategory(
|
||||
name: "current_tasks",
|
||||
displayName: "Current",
|
||||
icons: TaskColumnIcon(
|
||||
ios: "calendar",
|
||||
android: "CalendarToday",
|
||||
web: "calendar"
|
||||
),
|
||||
color: "#007AFF",
|
||||
count: 8
|
||||
),
|
||||
TaskColumnCategory(
|
||||
name: "in_progress_tasks",
|
||||
displayName: "In Progress",
|
||||
icons: TaskColumnIcon(
|
||||
ios: "play.circle",
|
||||
android: "PlayCircle",
|
||||
web: "play-circle"
|
||||
),
|
||||
color: "#FF9500",
|
||||
count: 2
|
||||
),
|
||||
TaskColumnCategory(
|
||||
name: "backlog_tasks",
|
||||
displayName: "Backlog",
|
||||
icons: TaskColumnIcon(
|
||||
ios: "tray",
|
||||
android: "Inbox",
|
||||
web: "inbox"
|
||||
),
|
||||
color: "#5856D6",
|
||||
count: 7
|
||||
),
|
||||
TaskColumnCategory(
|
||||
name: "done_tasks",
|
||||
displayName: "Done",
|
||||
icons: TaskColumnIcon(
|
||||
ios: "checkmark.circle",
|
||||
android: "CheckCircle",
|
||||
web: "check-circle"
|
||||
),
|
||||
color: "#34C759",
|
||||
count: 5
|
||||
)
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -71,28 +71,18 @@ struct ResidenceCard: View {
|
||||
|
||||
Divider()
|
||||
|
||||
// Task Stats
|
||||
// Fully dynamic task stats from API - show first 3 categories
|
||||
HStack(spacing: AppSpacing.sm) {
|
||||
TaskStatChip(
|
||||
icon: "list.bullet",
|
||||
value: "\(residence.taskSummary.total)",
|
||||
label: "Tasks",
|
||||
color: .blue
|
||||
)
|
||||
let displayCategories = Array(residence.taskSummary.categories.prefix(3))
|
||||
|
||||
TaskStatChip(
|
||||
icon: "checkmark.circle.fill",
|
||||
value: "\(residence.taskSummary.completed)",
|
||||
label: "Done",
|
||||
color: .green
|
||||
)
|
||||
|
||||
TaskStatChip(
|
||||
icon: "clock.fill",
|
||||
value: "\(residence.taskSummary.pending)",
|
||||
label: "Pending",
|
||||
color: .orange
|
||||
)
|
||||
ForEach(displayCategories, id: \.name) { category in
|
||||
TaskStatChip(
|
||||
icon: category.icons.ios,
|
||||
value: "\(category.count)",
|
||||
label: category.displayName,
|
||||
color: Color(hex: category.color) ?? .gray
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(AppSpacing.md)
|
||||
@@ -128,10 +118,36 @@ struct ResidenceCard: View {
|
||||
isPrimary: true,
|
||||
taskSummary: TaskSummary(
|
||||
total: 10,
|
||||
completed: 3,
|
||||
pending: 5,
|
||||
inProgress: 2,
|
||||
overdue: 0
|
||||
categories: [
|
||||
TaskColumnCategory(
|
||||
name: "overdue_tasks",
|
||||
displayName: "Overdue",
|
||||
icons: TaskColumnIcon(ios: "exclamationmark.triangle", android: "Warning", web: "exclamation-triangle"),
|
||||
color: "#FF3B30",
|
||||
count: 0
|
||||
),
|
||||
TaskColumnCategory(
|
||||
name: "current_tasks",
|
||||
displayName: "Current",
|
||||
icons: TaskColumnIcon(ios: "calendar", android: "CalendarToday", web: "calendar"),
|
||||
color: "#007AFF",
|
||||
count: 5
|
||||
),
|
||||
TaskColumnCategory(
|
||||
name: "in_progress_tasks",
|
||||
displayName: "In Progress",
|
||||
icons: TaskColumnIcon(ios: "play.circle", android: "PlayCircle", web: "play-circle"),
|
||||
color: "#FF9500",
|
||||
count: 2
|
||||
),
|
||||
TaskColumnCategory(
|
||||
name: "done_tasks",
|
||||
displayName: "Done",
|
||||
icons: TaskColumnIcon(ios: "checkmark.circle", android: "CheckCircle", web: "check-circle"),
|
||||
color: "#34C759",
|
||||
count: 3
|
||||
)
|
||||
]
|
||||
),
|
||||
tasks: [],
|
||||
createdAt: "2024-01-01T00:00:00Z",
|
||||
|
||||
Reference in New Issue
Block a user