Coverage Summary for Class: SurferTransactionLineKt (com.georgeci.moneysurfer.uikit.components.transaction)

Class Class, % Method, % Branch, % Line, % Instruction, %
SurferTransactionLineKt 100% (1/1) 66.7% (2/3) 68.1% (49/72) 92.4% (85/92) 89.9% (1069/1189)


 package com.georgeci.moneysurfer.uikit.components.transaction
 
 import androidx.compose.foundation.background
 import androidx.compose.foundation.border
 import androidx.compose.foundation.clickable
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.shape.CircleShape
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.material3.Icon
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clip
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.vector.ImageVector
 import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.tooling.preview.Preview
 import androidx.compose.ui.unit.dp
 import com.georgeci.moneysurfer.uikit.components.SurferCategoryPalette
 import com.georgeci.moneysurfer.uikit.icons.SurferIcons
 import com.georgeci.moneysurfer.uikit.preview.SurferComponentPreview
 import com.georgeci.moneysurfer.uikit.semantics.SurferSemantics
 import com.georgeci.moneysurfer.uikit.theme.AppTheme
 
 private val SquircleShape = RoundedCornerShape(percent = 35)
 
 /**
  * Stateless transaction line per the design's transaction line:
  * - 48dp squircle leading tile with optional category-hue ring.
  * - Title (note / merchant) on the first line; optional meta line with a leading colored dot
  *   plus a single string the caller composes (category, account, etc).
  * - Amount stack on the trailing edge with optional date sub-line. Pass
  *   [amountPillBackground] when the row is income — the amount text gets a soft tinted pill;
  *   leave `null` and the row reads bare numerals.
  *
  * Lives in uikit without coupling to feature data — caller maps the domain model to the
  * visual slots and pre-formats the amount string.
  */
 @Composable
 fun SurferTransactionLine(
     icon: ImageVector,
     title: String,
     formattedAmount: String,
     modifier: Modifier = Modifier,
     categoryHueSeed: String? = null,
     meta: String? = null,
     metaDotColor: Color? = null,
     iconTint: Color = AppTheme.materialColors.onSurfaceVariant,
     iconBackground: Color = AppTheme.materialColors.surfaceContainerHighest,
     iconRingColor: Color = Color.Transparent,
     amountColor: Color = AppTheme.materialColors.onSurface,
     amountPillBackground: Color? = null,
     dateLabel: String? = null,
     onClick: (() -> Unit)? = null,
 ) {
     val categorySeed = categoryHueSeed?.takeIf { it.isNotBlank() }
     val categoryTint = categorySeed?.let { SurferCategoryPalette.tintFor(it) }
     val leadingIcon = categorySeed?.let(SurferCategoryPalette::iconFor) ?: icon
     val leadingIconTint = categoryTint ?: iconTint
     val leadingIconBackground = categoryTint?.copy(alpha = 0.18f) ?: iconBackground
 
     Row(
         modifier = modifier
             .fillMaxWidth()
             .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier),
         verticalAlignment = Alignment.CenterVertically,
         horizontalArrangement = Arrangement.spacedBy(14.dp),
     ) {
         Box(
             modifier = Modifier
                 .size(48.dp)
                 .clip(SquircleShape)
                 .background(leadingIconBackground)
                 .then(
                     if (iconRingColor != Color.Transparent) {
                         Modifier.border(1.dp, iconRingColor, SquircleShape)
                     } else {
                         Modifier
                     },
                 ),
             contentAlignment = Alignment.Center,
         ) {
             Icon(
                 imageVector = leadingIcon,
                 contentDescription = SurferSemantics.Decorative,
                 tint = leadingIconTint,
                 modifier = Modifier.size(22.dp),
             )
         }
 
         Column(modifier = Modifier.weight(1f)) {
             Text(
                 text = title,
                 style = AppTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold),
                 color = AppTheme.materialColors.onSurface,
                 maxLines = 1,
                 overflow = TextOverflow.Ellipsis,
             )
             if (!meta.isNullOrBlank()) {
                 Spacer(Modifier.height(2.dp))
                 Row(
                     verticalAlignment = Alignment.CenterVertically,
                     horizontalArrangement = Arrangement.spacedBy(6.dp),
                 ) {
                     if (metaDotColor != null) {
                         Box(
                             modifier = Modifier
                                 .size(6.dp)
                                 .clip(CircleShape)
                                 .background(metaDotColor),
                         )
                     }
                     Text(
                         text = meta,
                         style = AppTheme.typography.bodySmall,
                         color = AppTheme.materialColors.onSurfaceVariant,
                         maxLines = 1,
                         overflow = TextOverflow.Ellipsis,
                     )
                 }
             }
         }
 
         Column(
             horizontalAlignment = Alignment.End,
             verticalArrangement = Arrangement.spacedBy(2.dp),
         ) {
             val amountStyle = AppTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold)
             if (amountPillBackground != null) {
                 Box(
                     modifier = Modifier
                         .clip(RoundedCornerShape(percent = 50))
                         .background(amountPillBackground)
                         .padding(horizontal = 10.dp, vertical = 3.dp),
                 ) {
                     Text(text = formattedAmount, style = amountStyle, color = amountColor)
                 }
             } else {
                 Text(text = formattedAmount, style = amountStyle, color = amountColor)
             }
             if (!dateLabel.isNullOrBlank()) {
                 Text(
                     text = dateLabel,
                     style = AppTheme.typography.labelSmall,
                     color = AppTheme.materialColors.onSurfaceVariant,
                 )
             }
         }
     }
 }
 
 @Preview
 @Composable
 private fun SurferTransactionLinePreview() {
     SurferComponentPreview {
         Column(
             modifier = Modifier.padding(16.dp),
             verticalArrangement = Arrangement.spacedBy(8.dp),
         ) {
             SurferTransactionLine(
                 icon = SurferIcons.Receipt,
                 title = "Lidl — weekly shop",
                 categoryHueSeed = "groceries",
                 meta = "Groceries",
                 formattedAmount = "−€48.20",
                 dateLabel = "Today",
                 onClick = {},
             )
             SurferTransactionLine(
                 icon = SurferIcons.Wallet,
                 title = "Salary",
                 meta = "Income · Everyday",
                 metaDotColor = Color(0xFF2EB46A),
                 formattedAmount = "+€3,200.00",
                 amountColor = Color(0xFF2E9A6A),
                 amountPillBackground = Color(0x332EB46A),
                 dateLabel = "Apr 1",
                 onClick = {},
             )
         }
     }
 }