Skip to main content

Overview

MiTensión uses Jetpack Compose for building declarative, reactive UI components. The app follows Material Design 3 principles with custom theming for a modern, accessible blood pressure tracking experience.

Jetpack Compose

Declarative UI framework for Android

Material 3

Modern design system with dynamic colors

Custom Components

Reusable composables for MiTensión

Theme Configuration

Color Scheme

MiTensión defines color palettes for light and dark themes:
// Color.kt
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)

val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
private val LightColorScheme = lightColorScheme(
    primary = Purple40,
    secondary = PurpleGrey40,
    tertiary = Pink40
)
Colors:
  • Primary: #6650a4 (Purple 40)
  • Secondary: #625b71 (Purple Grey 40)
  • Tertiary: #7D5260 (Pink 40)

Theme Composable

The main theme composable supports dynamic colors on Android 12+:
@Composable
fun MiTensionTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    dynamicColor: Boolean = true,
    content: @Composable () -> Unit
) {
    val colorScheme = when {
        dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
            val context = LocalContext.current
            if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
        }
        darkTheme -> DarkColorScheme
        else -> LightColorScheme
    }
    
    MaterialTheme(
        colorScheme = colorScheme,
        typography = Typography,
        content = content
    )
}
Features:
  • Automatic dark/light theme detection
  • Dynamic color support (Android 12+)
  • Follows system theme preferences
  • Custom color schemes for older Android versions
Dynamic colors extract the color scheme from the user’s wallpaper on Android 12+.

Screen Components

MedicionScreen

The main measurement input screen:
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MedicionScreen(onNavigateToCalendario: () -> Unit) {
    val context = LocalContext.current
    val medicionDao = remember { AppDatabase.getDatabase(context).medicionDao() }
    val repository = remember { MedicionRepository(medicionDao) }
    val factory = remember { MedicionViewModelFactory(repository, errorViewModel) }
    val viewModel: MedicionViewModel = viewModel(factory = factory)
    
    val uiState by viewModel.uiState
    var mostrarPopupSistolica by remember { mutableStateOf(false) }
    var mostrarPopupDiastolica by remember { mutableStateOf(false) }
    
    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text(tituloCompleto) },
                colors = TopAppBarDefaults.topAppBarColors(
                    containerColor = MaterialTheme.colorScheme.primaryContainer,
                    titleContentColor = MaterialTheme.colorScheme.primary
                )
            )
        }
    ) { paddingValues ->
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(paddingValues)
                .padding(16.dp),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.spacedBy(24.dp)
        ) {
            TensionDisplay(
                label = stringResource(id = R.string.tension_alta_label),
                valor = uiState.sistolica,
                onClick = { mostrarPopupSistolica = true }
            )
            
            TensionDisplay(
                label = stringResource(id = R.string.tension_baja_label),
                valor = uiState.diastolica,
                onClick = { mostrarPopupDiastolica = true }
            )
            
            Spacer(modifier = Modifier.weight(1f))
            
            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.spacedBy(16.dp)
            ) {
                Button(
                    onClick = { viewModel.guardarMedicion(...) },
                    modifier = Modifier.weight(3f).height(60.dp)
                ) {
                    Text(stringResource(id = R.string.guardar))
                }
                Button(
                    onClick = { onNavigateToCalendario() },
                    modifier = Modifier.weight(3f).height(60.dp)
                ) {
                    Text(stringResource(id = R.string.ver_calendario))
                }
            }
        }
    }
}
Key Elements:
1

Scaffold with TopAppBar

Material 3 Scaffold provides consistent layout structure with app bar
2

Column Layout

Vertical arrangement with centered alignment and 24dp spacing
3

TensionDisplay Components

Custom composables for displaying and editing blood pressure values
4

Button Row

Two equally-weighted buttons for saving and navigation

TensionDisplay Component

Reusable component for displaying blood pressure values:
@Composable
fun TensionDisplay(label: String, valor: String, onClick: () -> Unit) {
    Column(horizontalAlignment = Alignment.CenterHorizontally) {
        Text(
            text = label,
            style = MaterialTheme.typography.headlineSmall
        )
        Spacer(modifier = Modifier.height(8.dp))
        Box(
            modifier = Modifier
                .fillMaxWidth(0.7f)
                .height(120.dp)
                .background(
                    MaterialTheme.colorScheme.surfaceVariant, 
                    MaterialTheme.shapes.large
                )
                .clickable(onClick = onClick)
                .padding(horizontal = 16.dp),
            contentAlignment = Alignment.Center
        ) {
            Text(
                text = if (valor.isEmpty()) 
                    stringResource(id = R.string.pulsa_para_anadir) 
                else valor,
                style = MaterialTheme.typography.headlineMedium.copy(
                    fontWeight = FontWeight.Bold
                ),
                color = if (valor.isEmpty()) 
                    MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) 
                else MaterialTheme.colorScheme.primary
            )
        }
    }
}
Component Features:

Layout

  • Column with centered alignment
  • Label text with headlineSmall style
  • 8dp spacing between label and value box

Value Box

  • 70% screen width
  • 120dp fixed height
  • Rounded corners (large shape)
  • Surface variant background

Interaction

  • Clickable to open input dialog
  • Visual feedback on tap
  • Placeholder text when empty

Styling

  • Bold headline medium typography
  • Primary color for values
  • Muted color (60% alpha) for placeholder

TensionInputDialog Component

Modal dialog for numeric input:
@Composable
fun TensionInputDialog(
    titulo: String,
    valorInicial: String,
    onDismiss: () -> Unit,
    onConfirm: (String) -> Unit
) {
    var text by remember {
        mutableStateOf(
            TextFieldValue(
                text = valorInicial,
                selection = TextRange(valorInicial.length)
            )
        )
    }
    val focusManager = LocalFocusManager.current
    val focusRequester = remember { FocusRequester() }
    
    Dialog(onDismissRequest = onDismiss) {
        Card(
            modifier = Modifier.fillMaxWidth(),
            shape = MaterialTheme.shapes.large
        ) {
            Column(
                modifier = Modifier.padding(24.dp),
                horizontalAlignment = Alignment.CenterHorizontally
            ) {
                Text(titulo, style = MaterialTheme.typography.headlineSmall)
                Spacer(modifier = Modifier.height(16.dp))
                OutlinedTextField(
                    value = text,
                    onValueChange = { newValue ->
                        if (newValue.text.length <= 3 && newValue.text.all { it.isDigit() }) {
                            text = newValue
                        }
                    },
                    modifier = Modifier.focusRequester(focusRequester),
                    keyboardOptions = KeyboardOptions(
                        keyboardType = KeyboardType.Number,
                        imeAction = ImeAction.Done
                    ),
                    keyboardActions = KeyboardActions(
                        onDone = {
                            onConfirm(text.text)
                            focusManager.clearFocus()
                        }
                    ),
                    singleLine = true,
                    textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center)
                )
                Spacer(modifier = Modifier.height(24.dp))
                Button(
                    onClick = {
                        onConfirm(text.text)
                        focusManager.clearFocus()
                    },
                    modifier = Modifier.fillMaxWidth().height(48.dp)
                ) {
                    Text(stringResource(id = R.string.confirmar))
                }
            }
        }
    }
    
    LaunchedEffect(Unit) {
        focusRequester.requestFocus()
    }
}
Dialog Features:
  • Maximum 3 digits allowed
  • Numeric input only (filters non-digits)
  • Center-aligned text
  • Auto-selects all text on open
LaunchedEffect(Unit) ensures the text field receives focus when the dialog opens.

Material 3 Components

Scaffold

Provides consistent layout structure:
Scaffold(
    topBar = { TopAppBar(...) },
    floatingActionButton = { FloatingActionButton(...) },
    content = { paddingValues ->
        // Content here
    }
)
Benefits:
  • Automatic padding for system bars
  • Consistent app bar positioning
  • Built-in FAB support
  • Material motion and elevation

TopAppBar

TopAppBar(
    title = { Text("Medición - Mañana 1/3") },
    colors = TopAppBarDefaults.topAppBarColors(
        containerColor = MaterialTheme.colorScheme.primaryContainer,
        titleContentColor = MaterialTheme.colorScheme.primary
    )
)
Styling:
  • Primary container background
  • Primary color text
  • Material 3 elevation
  • Supports navigation icons and actions

Buttons

Button(
    onClick = { /* Action */ },
    modifier = Modifier
        .weight(3f)
        .height(60.dp)
) {
    Text(
        "Guardar",
        style = MaterialTheme.typography.bodyLarge
    )
}
Button Variants:

Filled Button

Primary action button with solid background

Outlined Button

Secondary action with border outline

Text Button

Tertiary action with no background

OutlinedTextField

OutlinedTextField(
    value = text,
    onValueChange = { newValue -> text = newValue },
    keyboardOptions = KeyboardOptions(
        keyboardType = KeyboardType.Number,
        imeAction = ImeAction.Done
    ),
    singleLine = true
)
Features:
  • Material 3 outline style
  • Custom keyboard types
  • IME action support
  • Label and placeholder support
  • Error state handling

Typography

Material 3 provides a rich typography scale:
Text(
    text = "Tensión Alta",
    style = MaterialTheme.typography.headlineSmall
)
Scale:
  • headlineLarge - 32sp
  • headlineMedium - 28sp
  • headlineSmall - 24sp

Layout Patterns

Column with Spacing

Column(
    modifier = Modifier
        .fillMaxSize()
        .padding(16.dp),
    horizontalAlignment = Alignment.CenterHorizontally,
    verticalArrangement = Arrangement.spacedBy(24.dp)
) {
    // Children spaced 24dp apart
}

Row with Weights

Row(
    modifier = Modifier.fillMaxWidth(),
    horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
    Button(
        modifier = Modifier.weight(1f),
        onClick = { /* ... */ }
    ) { Text("Button 1") }
    
    Button(
        modifier = Modifier.weight(1f),
        onClick = { /* ... */ }
    ) { Text("Button 2") }
}

Box with Alignment

Box(
    modifier = Modifier
        .fillMaxWidth()
        .height(120.dp),
    contentAlignment = Alignment.Center
) {
    Text("Centered Content")
}

State Management

Remember and MutableState

var mostrarPopup by remember { mutableStateOf(false) }

if (mostrarPopup) {
    TensionInputDialog(
        onDismiss = { mostrarPopup = false },
        onConfirm = { valor ->
            viewModel.onSistolicaChanged(valor)
            mostrarPopup = false
        }
    )
}

Collecting Flow

val uiState by viewModel.uiState

LaunchedEffect(key1 = true) {
    viewModel.evento.collectLatest { evento ->
        when (evento) {
            is MedicionViewModel.UiEvento.MostrarMensaje -> {
                Toast.makeText(context, evento.mensaje, Toast.LENGTH_LONG).show()
            }
            is MedicionViewModel.UiEvento.GuardadoConExito -> {
                Toast.makeText(context, evento.mensaje, Toast.LENGTH_SHORT).show()
                viewModel.onGuardadoExitoso()
            }
        }
    }
}
Use collectAsState() for UI state and collectLatest in LaunchedEffect for one-time events.

Modifiers

Common modifier patterns in MiTensión:

Size

Modifier
    .fillMaxWidth()
    .fillMaxHeight()
    .fillMaxSize()
    .size(48.dp)
    .width(200.dp)
    .height(60.dp)

Padding

Modifier
    .padding(16.dp)
    .padding(horizontal = 24.dp)
    .padding(vertical = 16.dp)
    .padding(
        start = 16.dp,
        top = 8.dp
    )

Background

Modifier
    .background(
        MaterialTheme.colorScheme.surface,
        MaterialTheme.shapes.large
    )
    .background(Color.Red)

Interaction

Modifier
    .clickable { /* Action */ }
    .focusRequester(focusRequester)
    .focusable()

Accessibility

Content Descriptions

Icon(
    imageVector = Icons.Default.Add,
    contentDescription = stringResource(id = R.string.add_measurement)
)

Semantic Properties

Modifier.semantics {
    contentDescription = "Systolic blood pressure: 120"
    role = Role.Button
}

Focus Management

val focusRequester = remember { FocusRequester() }

OutlinedTextField(
    modifier = Modifier.focusRequester(focusRequester),
    // ...
)

LaunchedEffect(Unit) {
    focusRequester.requestFocus()
}

Animation

AnimatedVisibility

AnimatedVisibility(visible = isVisible) {
    Text("This text animates in and out")
}

Animated Content

AnimatedContent(targetState = currentScreen) { screen ->
    when (screen) {
        Screen.Medicion -> MedicionScreen()
        Screen.Calendario -> CalendarioScreen()
    }
}

Best Practices

State Hoisting

  • Keep composables stateless when possible
  • Hoist state to lowest common ancestor
  • Pass state down, events up
  • Use ViewModels for business logic

Recomposition

  • Use remember for expensive operations
  • Avoid creating new objects in composition
  • Use key for stable identity
  • Profile with Layout Inspector

Resources

  • Use stringResource() for all text
  • Support multiple languages
  • Use dimensionResource() for sizes
  • Leverage string formatting

Theming

  • Always use MaterialTheme.colorScheme
  • Respect system dark mode
  • Use semantic color names
  • Test in light and dark themes

Testing Composables

UI Tests

@Test
fun testTensionDisplay_showsValue() {
    composeTestRule.setContent {
        MiTensionTheme {
            TensionDisplay(
                label = "Sistólica",
                valor = "120",
                onClick = {}
            )
        }
    }
    
    composeTestRule
        .onNodeWithText("120")
        .assertIsDisplayed()
}

@Test
fun testTensionDisplay_clickable() {
    var clicked = false
    
    composeTestRule.setContent {
        TensionDisplay(
            label = "Sistólica",
            valor = "120",
            onClick = { clicked = true }
        )
    }
    
    composeTestRule
        .onNodeWithText("120")
        .performClick()
    
    assertTrue(clicked)
}

Performance Tips

1

Use derivedStateOf

For computed values that depend on other state:
val isValid by remember {
    derivedStateOf {
        sistolica.isNotBlank() && diastolica.isNotBlank()
    }
}
2

Avoid Unnecessary Recomposition

Use stable types and avoid lambdas with captures:
@Stable
data class ButtonState(val enabled: Boolean, val text: String)
3

Use LazyColumn for Lists

Instead of Column with many items:
LazyColumn {
    items(mediciones) { medicion ->
        MedicionItem(medicion)
    }
}

Architecture

Learn about the MVVM architecture pattern

Data Model

Explore the Room database schema

Build docs developers (and LLMs) love