mobile-report-tables/SKILL.md
Report UI rule for mobile apps: any report with potential for more than 25 rows must render as a table, not cards. Includes decision rules, Android Compose patterns, and iOS SwiftUI patterns.
npx skillsauth add peterbamuhigire/skills-web-dev mobile-report-tablesInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
mobile-report-tables or would be better handled by a more specific companion skill.SKILL.md first, then load only the referenced deep-dive files that are necessary for the task.When a report can exceed 25 rows, it must be rendered as a table, not card lists. This prevents scroll fatigue and preserves scanability for business data. Applies to both Android (Jetpack Compose) and iOS (SwiftUI).
Use for: Reports, analytics lists, financial summaries, inventory reports, audit logs, and any dataset likely to exceed 25 rows.
Do not use: Small datasets (<=25 rows) or highly visual summaries where cards communicate state better.
The project has a reusable ReportTable<T> at core/ui/components/ReportTable.kt:
ReportTable(
columns = listOf(
TableColumn(header = "#", weight = 0.4f) { "#${it.rank}" },
TableColumn(header = "Name", weight = 1.5f) { it.fullName ?: "-" },
TableColumn(header = "Inv", weight = 0.4f) { it.totalInvoices.toString() },
TableColumn(header = "Amount", weight = 1.2f) { "$currency ${fmt.format(it.totalAmount)}" }
),
rows = report.rows,
onRowClick = { /* optional */ },
pageSize = 25
)
Features:
<T> with TableColumn<T> definitions (header, weight, value lambda)surfaceVariant backgroundModifier.weight() for proportional column sizingAll dates in report tables MUST be human-readable. Never display raw API dates like 2026-02-14. Always format to short readable form: d MMM yyyy (e.g., 14 Feb 2026).
val apiDateFmt = remember { SimpleDateFormat("yyyy-MM-dd", Locale.US) }
val displayDateFmt = remember { SimpleDateFormat("d MMM yyyy", Locale.US) }
val formatDate: (String) -> String = { raw ->
try { displayDateFmt.format(apiDateFmt.parse(raw)!!) } catch (_: Exception) { raw }
}
// Usage in TableColumn:
TableColumn("Date", minWidth = 100.dp) { formatDate(it.date) }
TableColumn("Oldest", minWidth = 100.dp) { it.oldestDate?.let { formatDate(it) } ?: "-" }
Every screen that displays reports, statistics, or data MUST support pull-to-refresh.
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MyReportScreen(viewModel: MyViewModel = hiltViewModel()) {
val uiState by viewModel.uiState.collectAsState()
var isRefreshing by remember { mutableStateOf(false) }
LaunchedEffect(uiState.loading) {
if (!uiState.loading) isRefreshing = false
}
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = { isRefreshing = true; viewModel.reload() },
modifier = Modifier.fillMaxSize()
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
// Report content
}
}
}
reload() / refresh() functionPullToRefreshBox (simpler API than the older PullToRefreshContainer)Report screens with tables should use a scrollable Column (not LazyColumn), since ReportTable is not a lazy composable. Wrap in PullToRefreshBox:
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = { isRefreshing = true; viewModel.reload() },
modifier = Modifier.fillMaxSize().padding(paddingValues)
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Filters
// Summary cards
// ReportTable (handles its own pagination)
}
}
struct ReportTableView<T: Identifiable>: View {
let columns: [TableColumnDef<T>]
let rows: [T]
let pageSize: Int
var onRowTap: ((T) -> Void)? = nil
@State private var currentPage = 1
private var totalPages: Int {
max(1, Int(ceil(Double(rows.count) / Double(pageSize))))
}
private var pagedRows: [T] {
let start = (currentPage - 1) * pageSize
let end = min(start + pageSize, rows.count)
return Array(rows[start..<end])
}
var body: some View {
VStack(spacing: 0) {
// Header
HStack(spacing: 0) {
ForEach(columns) { col in
Text(col.header)
.font(.caption)
.fontWeight(.semibold)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 8)
.padding(.horizontal, 4)
}
}
.background(Color(.systemGray6))
Divider()
// Rows
ForEach(pagedRows) { row in
HStack(spacing: 0) {
ForEach(columns) { col in
Text(col.value(row))
.font(.body)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 6)
.padding(.horizontal, 4)
}
}
.contentShape(Rectangle())
.onTapGesture { onRowTap?(row) }
Divider()
}
// Pagination
if totalPages > 1 {
HStack {
Button("Previous") { currentPage -= 1 }
.disabled(currentPage <= 1)
Spacer()
Text("Page \(currentPage) of \(totalPages)")
.font(.caption)
Spacer()
Button("Next") { currentPage += 1 }
.disabled(currentPage >= totalPages)
}
.padding(.vertical, 8)
}
}
}
}
struct TableColumnDef<T>: Identifiable {
let id = UUID()
let header: String
let value: (T) -> String
}
private let apiFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd"
f.locale = Locale(identifier: "en_US_POSIX")
return f
}()
private let displayFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "d MMM yyyy"
return f
}()
func formatDate(_ raw: String) -> String {
guard let date = apiFormatter.date(from: raw) else { return raw }
return displayFormatter.string(from: date)
}
struct MyReportView: View {
@StateObject private var viewModel = ReportViewModel()
var body: some View {
ScrollView {
VStack(spacing: 12) {
// Filters, summary cards, table...
}
.padding(16)
}
.refreshable {
await viewModel.reload()
}
}
}
weight ratios; iOS: use frame(maxWidth:) with proportional values| Column Type | Android Weight | Examples | |-------------|---------------|----------| | Index/Rank | 0.3-0.5f | #, Rank | | Short text | 0.4-0.6f | Code, Qty, Inv | | Name/Description | 1.3-1.5f | Product, Distributor | | Currency amount | 1.0-1.2f | Amount, Balance, Due | | Date | 0.8-1.0f | Date |
When a table needs 5+ columns and cannot fit in portrait:
Android:
Column(Modifier.horizontalScroll(rememberScrollState())) {
ReportTable(columns = ..., rows = ...)
}
iOS:
ScrollView(.horizontal) {
ReportTableView(columns: columns, rows: rows, pageSize: 25)
}
stringResource(R.string.report_col_*) for table headers. Never hardcode header text.NSLocalizedString or String Catalogs for table headers. Never hardcode header text.| Criteria | Use Cards | Use Table | |----------|-----------|-----------| | Max rows <= 25 guaranteed | Yes | Optional | | Max rows > 25 possible | No | Required | | DPCs (5-20 items) | Yes | Optional | | Daily summary (7 days) | Yes | Optional | | Distributor lists | No | Required | | Product lists | No | Required | | Invoice lists | No | Required | | Debtors lists | No | Required | | Top 100 rankings | No | Required |
ReportTable handles pagination internally — no need for ViewModel pagination.ReportTableView handles pagination internally via @State page tracking.yyyy-MM-dd — this is for transport only, never for displayd MMM yyyyMMM d (e.g., Feb 14) for space- if nulld MMM yyyy format, never raw API datesdata-ai
Use when adding AI-powered analytics to a SaaS platform — semantic search over business data, natural language queries, trend detection, anomaly alerts, and AI-generated insights for dashboards. Covers embeddings, NL2SQL, and per-tenant analytics...
data-ai
Design AI-powered analytics dashboards — what metrics to show, how to display AI predictions and confidence, drill-down patterns, KPI cards, trend visualisation, AI Insights panels, export design, and role-based dashboard variants. Invoke when...
development
Use when designing, building, reviewing, or upgrading production software systems that must be secure, performant, maintainable, scalable, and user-centered. Apply before writing specs, code, architecture, APIs, databases, mobile apps, SaaS platforms, or ERP systems.
development
Professional web app UI using commercial templates (Tabler/Bootstrap 5) with strong frontend design direction when needed. Use for CRUD interfaces, dashboards, admin panels with SweetAlert2, DataTables, Flatpickr. Clone seeder-page.php, use...