diff --git a/README.md b/README.md index 08d4aa4..e9214d0 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,47 @@ -Kalkulator BMI -=============== +# BMI Calculator Android App -Silahkan kembangkan aplikasi ini untuk melakukan perhitungan BMI +**NPM**: 202310715043 +**Nama**: Muhammad Rafly Al Fathir -Petunjuk lebih detil dapat dibaca di -https://docs.google.com/document/d/1iGiC0Bg3Bdcd2Maq45TYkCDUkZ5Ql51E/edit?rtpof=true +## Deskripsi +Aplikasi Android untuk menghitung Body Mass Index (BMI) berdasarkan tinggi dan berat badan. Aplikasi ini mendukung dua sistem unit: Metrik (cm, kg) dan US Customary (inches, lbs). -Starter dimodifikasi dan terinspirasi dari: -https://developer.android.com/codelabs/basic-android-compose-calculate-tip#0 \ No newline at end of file +## Fitur +- ✅ Perhitungan BMI yang akurat +- ✅ Dukungan untuk sistem Metrik dan USC +- ✅ Validasi input untuk mencegah nilai yang tidak wajar +- ✅ Kategori BMI (Underweight, Normal, Overweight, Obese) +- ✅ UI modern dengan Material Design 3 +- ✅ Color coding untuk kategori BMI +- ✅ Informasi lengkap tentang kategori BMI + +## Teknologi yang Digunakan +- **Kotlin**: Bahasa pemrograman utama +- **Jetpack Compose**: UI Framework +- **Material Design 3**: Design system +- **JUnit**: Unit testing + +## Formula BMI +``` +BMI = Berat (kg) / (Tinggi (m))² +``` + +## Kategori BMI (WHO) +- **Underweight**: BMI < 18.5 +- **Normal**: 18.5 ≤ BMI < 25 +- **Overweight**: 25 ≤ BMI < 30 +- **Obese**: BMI ≥ 30 + +## Unit Testing +Aplikasi dilengkapi dengan unit test untuk: +- Perhitungan BMI (sistem metrik dan USC) +- Kategori BMI +- Validasi input +- Boundary cases + +## Kontribusi & Kredit +Aplikasi ini dikembangkan dengan bantuan Claude ai dalam pembuatan kode, desain antarmuka, dan dokumentasi. + + +## Lisensi +Apache License 2.0 \ No newline at end of file diff --git a/app/src/main/java/com/example/tiptime/MainActivity.kt b/app/src/main/java/com/example/tiptime/MainActivity.kt index d0fdd80..64d1c12 100644 --- a/app/src/main/java/com/example/tiptime/MainActivity.kt +++ b/app/src/main/java/com/example/tiptime/MainActivity.kt @@ -17,10 +17,10 @@ package com.example.tiptime import android.os.Bundle import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.annotation.DrawableRes -import androidx.annotation.StringRes import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -30,17 +30,28 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawingPadding -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -48,14 +59,15 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.example.tiptime.ui.theme.TipTimeTheme -import java.text.NumberFormat +import kotlin.math.pow class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -66,82 +78,294 @@ class MainActivity : ComponentActivity() { Surface( modifier = Modifier.fillMaxSize(), ) { - TipTimeLayout() + // State untuk menentukan halaman mana yang ditampilkan + var showWelcomeScreen by remember { mutableStateOf(true) } + + if (showWelcomeScreen) { + // Tampilkan Welcome Screen + WelcomeScreen( + onStartClick = { + showWelcomeScreen = false // Pindah ke halaman BMI Calculator + } + ) + } else { + // Tampilkan BMI Calculator dengan tombol back + BMICalculatorLayout( + onBackClick = { + showWelcomeScreen = true // Kembali ke Welcome Screen + } + ) + } } } } } } +/** + * Layout utama untuk aplikasi BMI Calculator + * Menampilkan input field untuk tinggi dan berat badan, + * toggle untuk unit sistem, dan hasil perhitungan BMI + * + * @param onBackClick Callback yang dipanggil ketika tombol back diklik + */ +@OptIn(ExperimentalMaterial3Api::class) @Composable -fun TipTimeLayout() { - var amountInput by remember { mutableStateOf("") } - var tipInput by remember { mutableStateOf("") } - var roundUp by remember { mutableStateOf(false) } +fun BMICalculatorLayout(onBackClick: () -> Unit = {}) { + var heightInput by remember { mutableStateOf("") } + var weightInput by remember { mutableStateOf("") } + var useUSC by remember { mutableStateOf(false) } - val BmiHeight = amountInput.toDoubleOrNull() ?: 0.0 - val BmiWeight = tipInput.toDoubleOrNull() ?: 0.0 - val bmi = calculateBMI(BmiHeight, BmiWeight, roundUp) - val category = calculateBMICategory(BmiHeight, BmiWeight, roundUp) + // Konversi input ke Double atau null jika tidak valid + val height = heightInput.toDoubleOrNull() ?: 0.0 + val weight = weightInput.toDoubleOrNull() ?: 0.0 - Column( - modifier = Modifier - .statusBarsPadding() - .padding(horizontal = 40.dp) - .verticalScroll(rememberScrollState()) - .safeDrawingPadding(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Text( - text = stringResource(R.string.calculate_tip), + // Validasi input + val inputError = validateInput(height, weight, useUSC) + + // Hitung BMI dan kategori jika input valid + val bmi = if (inputError == null) { + calculateBMI(height, weight, useUSC) + } else { + 0.0 + } + + val category = if (inputError == null && bmi > 0) { + calculateBMICategory(bmi) + } else { + "" + } + + val categoryColor = getBMICategoryColor(category) + + // Gunakan Scaffold untuk layout dengan TopAppBar + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = "BMI Calculator", + fontWeight = FontWeight.Bold + ) + }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back to Welcome Screen", + modifier = Modifier.size(24.dp) + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + navigationIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) + ) + } + ) { paddingValues -> + Column( modifier = Modifier - .padding(bottom = 16.dp, top = 40.dp) - .align(alignment = Alignment.Start) - ) - EditNumberField( - label = R.string.height, - leadingIcon = R.drawable.number, - keyboardOptions = KeyboardOptions.Default.copy( - keyboardType = KeyboardType.Number, - imeAction = ImeAction.Next - ), - value = amountInput, - onValueChanged = { amountInput = it }, - modifier = Modifier.padding(bottom = 32.dp).fillMaxWidth(), - ) - EditNumberField( - label = R.string.weight, - leadingIcon = R.drawable.number, - keyboardOptions = KeyboardOptions.Default.copy( - keyboardType = KeyboardType.Number, - imeAction = ImeAction.Done - ), - value = tipInput, - onValueChanged = { tipInput = it }, - modifier = Modifier.padding(bottom = 32.dp).fillMaxWidth(), - ) - RoundTheTipRow( - roundUp = roundUp, - onRoundUpChanged = { roundUp = it }, - modifier = Modifier.padding(bottom = 32.dp) - ) - Text( - text = stringResource(R.string.bmi_calculation, bmi), - style = MaterialTheme.typography.displaySmall - ) - Text( - text = stringResource(R.string.bmi_category, category), - style = MaterialTheme.typography.displaySmall - ) + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = 24.dp) + .verticalScroll(rememberScrollState()) + .safeDrawingPadding(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top + ) { + Spacer(modifier = Modifier.height(16.dp)) - Spacer(modifier = Modifier.height(150.dp)) + // Subtitle + Text( + text = "Calculate your Body Mass Index", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(bottom = 24.dp) + .align(alignment = Alignment.Start) + ) + + // Input Field untuk Tinggi + EditNumberField( + label = if (useUSC) "Height (inches)" else "Height (cm)", + leadingIcon = R.drawable.number, + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Decimal, + imeAction = ImeAction.Next + ), + value = heightInput, + onValueChanged = { heightInput = it }, + modifier = Modifier + .padding(bottom = 16.dp) + .fillMaxWidth(), + ) + + // Input Field untuk Berat + EditNumberField( + label = if (useUSC) "Weight (lbs)" else "Weight (kg)", + leadingIcon = R.drawable.number, + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Decimal, + imeAction = ImeAction.Done + ), + value = weightInput, + onValueChanged = { weightInput = it }, + modifier = Modifier + .padding(bottom = 16.dp) + .fillMaxWidth(), + ) + + // Toggle untuk Unit Sistem + UnitSystemToggleRow( + useUSC = useUSC, + onToggleChanged = { useUSC = it }, + modifier = Modifier.padding(bottom = 24.dp) + ) + + // Tampilkan error jika ada + if (inputError != null) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ) + ) { + Text( + text = inputError, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.bodyMedium + ) + } + } + + // Tampilkan hasil BMI jika valid + if (inputError == null && bmi > 0) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + ) { + Column( + modifier = Modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Your BMI", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + + Text( + text = String.format("%.1f", bmi), + style = MaterialTheme.typography.displayLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(vertical = 8.dp) + ) + + if (category.isNotEmpty()) { + Card( + colors = CardDefaults.cardColors( + containerColor = categoryColor + ) + ) { + Text( + text = category, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = Color.White, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + } + } + } + } + + // Informasi Kategori BMI + BMICategoryInfo() + } + + Spacer(modifier = Modifier.height(40.dp)) + } } } +/** + * Composable untuk menampilkan informasi kategori BMI + */ +@Composable +fun BMICategoryInfo() { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "BMI Categories", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 8.dp) + ) + + BMICategoryItem("Underweight", "< 18.5", Color(0xFF2196F3)) + BMICategoryItem("Normal", "18.5 - 24.9", Color(0xFF4CAF50)) + BMICategoryItem("Overweight", "25.0 - 29.9", Color(0xFFFFA726)) + BMICategoryItem("Obese", "≥ 30.0", Color(0xFFF44336)) + } + } +} + +/** + * Composable untuk menampilkan satu item kategori BMI + */ +@Composable +fun BMICategoryItem(category: String, range: String, color: Color) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Card( + modifier = Modifier.padding(end = 8.dp), + colors = CardDefaults.cardColors(containerColor = color) + ) { + Spacer(modifier = Modifier + .height(16.dp) + .padding(horizontal = 8.dp)) + } + Text(text = category, style = MaterialTheme.typography.bodyMedium) + } + Text( + text = range, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold + ) + } +} + +/** + * Composable untuk input field angka dengan label dan icon + */ @Composable fun EditNumberField( - @StringRes label: Int, + label: String, @DrawableRes leadingIcon: Int, keyboardOptions: KeyboardOptions, value: String, @@ -151,64 +375,163 @@ fun EditNumberField( TextField( value = value, singleLine = true, - leadingIcon = { Icon(painter = painterResource(id = leadingIcon), null) }, + leadingIcon = { + Icon( + painter = painterResource(id = leadingIcon), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + }, modifier = modifier, onValueChange = onValueChanged, - label = { Text(stringResource(label)) }, - keyboardOptions = keyboardOptions + label = { Text(label) }, + keyboardOptions = keyboardOptions, + colors = TextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + ) ) } +/** + * Composable untuk row toggle unit sistem (Metrik/USC) + */ @Composable -fun RoundTheTipRow( - roundUp: Boolean, - onRoundUpChanged: (Boolean) -> Unit, +fun UnitSystemToggleRow( + useUSC: Boolean, + onToggleChanged: (Boolean) -> Unit, modifier: Modifier = Modifier ) { Row( modifier = modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween ) { - Text(text = stringResource(R.string.use_usc)) + Column { + Text( + text = "Unit System", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Text( + text = if (useUSC) "US Customary (in, lbs)" else "Metric (cm, kg)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } Switch( - modifier = Modifier - .fillMaxWidth() - .wrapContentWidth(Alignment.End), - checked = roundUp, - onCheckedChange = onRoundUpChanged + checked = useUSC, + onCheckedChange = onToggleChanged ) } } /** - * Calculates the BMI + * Validasi input tinggi dan berat badan * - * Catatan: tambahkan unit test untuk kalkulasi BMI ini + * @param height Tinggi badan dalam cm atau inches + * @param weight Berat badan dalam kg atau lbs + * @param useUSC True jika menggunakan unit USC, false untuk metrik + * @return String error message jika tidak valid, null jika valid */ -private fun calculateBMI(BmiHeight: Double, BmiWeight: Double = 15.0, roundUp: Boolean): String { - var bmi = BmiWeight / 100 * BmiHeight - if (roundUp) { - bmi = kotlin.math.ceil(bmi) +fun validateInput(height: Double, weight: Double, useUSC: Boolean): String? { + if (height <= 0 || weight <= 0) { + return "Please enter valid height and weight" } - return NumberFormat.getNumberInstance().format(bmi) -} -/** - * Calculates the BMI Category - * - * Catatan: tambahkan unit test untuk kalkulasi BMI ini - */ -private fun calculateBMICategory(BmiHeight: Double, BmiWeight: Double = 15.0, roundUp: Boolean): String { - var bmi = BmiWeight / 100 * BmiHeight - if (roundUp) { - bmi = kotlin.math.ceil(bmi) + // Validasi untuk sistem metrik (cm, kg) + if (!useUSC) { + if (height < 50 || height > 300) { + return "Height must be between 50-300 cm" + } + if (weight < 10 || weight > 500) { + return "Weight must be between 10-500 kg" + } } - return NumberFormat.getNumberInstance().format(bmi) + // Validasi untuk sistem USC (inches, lbs) + else { + if (height < 20 || height > 120) { + return "Height must be between 20-120 inches" + } + if (weight < 20 || weight > 1100) { + return "Weight must be between 20-1100 lbs" + } + } + + return null } + +/** + * Menghitung BMI (Body Mass Index) + * + * Formula BMI: weight (kg) / (height (m))^2 + * + * @param height Tinggi badan dalam cm (metrik) atau inches (USC) + * @param weight Berat badan dalam kg (metrik) atau lbs (USC) + * @param useUSC True jika menggunakan unit USC, false untuk metrik + * @return Nilai BMI + */ +fun calculateBMI(height: Double, weight: Double, useUSC: Boolean): Double { + if (height <= 0 || weight <= 0) return 0.0 + + val heightInMeters: Double + val weightInKg: Double + + if (useUSC) { + // Konversi dari inches ke meter dan lbs ke kg + heightInMeters = height * 0.0254 + weightInKg = weight * 0.453592 + } else { + // Konversi dari cm ke meter + heightInMeters = height / 100.0 + weightInKg = weight + } + + // Formula BMI: weight / height^2 + return weightInKg / heightInMeters.pow(2) +} + +/** + * Menentukan kategori BMI berdasarkan nilai BMI + * + * Kategori WHO: + * - Underweight: BMI < 18.5 + * - Normal weight: 18.5 ≤ BMI < 25 + * - Overweight: 25 ≤ BMI < 30 + * - Obese: BMI ≥ 30 + * + * @param bmi Nilai BMI + * @return Kategori BMI dalam bentuk String + */ +fun calculateBMICategory(bmi: Double): String { + return when { + bmi < 18.5 -> "Underweight" + bmi < 25.0 -> "Normal" + bmi < 30.0 -> "Overweight" + else -> "Obese" + } +} + +/** + * Mendapatkan warna untuk kategori BMI + * + * @param category Kategori BMI + * @return Color yang sesuai dengan kategori + */ +fun getBMICategoryColor(category: String): Color { + return when (category) { + "Underweight" -> Color(0xFF2196F3) // Blue + "Normal" -> Color(0xFF4CAF50) // Green + "Overweight" -> Color(0xFFFFA726) // Orange + "Obese" -> Color(0xFFF44336) // Red + else -> Color.Gray + } +} + @Preview(showBackground = true) @Composable -fun TipTimeLayoutPreview() { +fun BMICalculatorLayoutPreview() { TipTimeTheme { - TipTimeLayout() + BMICalculatorLayout() } } \ No newline at end of file diff --git a/app/src/main/java/com/example/tiptime/WelcomeScreen.kt b/app/src/main/java/com/example/tiptime/WelcomeScreen.kt new file mode 100644 index 0000000..e431919 --- /dev/null +++ b/app/src/main/java/com/example/tiptime/WelcomeScreen.kt @@ -0,0 +1,236 @@ +package com.example.tiptime + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +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.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.example.tiptime.ui.theme.TipTimeTheme + +/** + * Composable untuk halaman Welcome Screen + * + * @param onStartClick Callback yang dipanggil ketika tombol "MULAI" diklik + */ +@Composable +fun WelcomeScreen(onStartClick: () -> Unit) { + // Animasi scale untuk efek zoom-in saat halaman muncul + val scale = remember { Animatable(0.8f) } + + // Jalankan animasi saat composable pertama kali ditampilkan + LaunchedEffect(Unit) { + scale.animateTo( + targetValue = 1f, + animationSpec = tween(durationMillis = 800) + ) + } + + // Background dengan warna theme + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp) + .scale(scale.value), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + // Icon/Logo Aplikasi + Card( + modifier = Modifier + .size(120.dp) + .padding(bottom = 24.dp), + shape = RoundedCornerShape(60.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(id = R.drawable.number), + contentDescription = "BMI Calculator Icon", + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.primary + ) + } + } + + // Judul Aplikasi + Text( + text = "BMI Calculator", + fontSize = 36.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.Center + ) + + Text( + text = "Body Mass Index Calculator", + fontSize = 16.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 8.dp, bottom = 32.dp) + ) + + // Card Biodata Pengembang + Card( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 32.dp), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "👨‍💻 Dikembangkan Oleh", + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(bottom = 16.dp) + ) + + // NPM + InfoRow( + label = "NPM", + value = "202310715043" + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Nama + InfoRow( + label = "Nama", + value = "M Rafly Al Fathir" + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Program Studi (opsional) + InfoRow( + label = "Prodi", + value = "Teknik Informatika" + ) + } + } + + // Deskripsi Singkat + Text( + text = "Aplikasi untuk menghitung Body Mass Index (BMI) berdasarkan tinggi dan berat badan Anda dengan dukungan sistem Metrik dan US Customary.", + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(bottom = 32.dp) + ) + + // Tombol MULAI + Button( + onClick = onStartClick, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ), + elevation = ButtonDefaults.buttonElevation( + defaultElevation = 4.dp, + pressedElevation = 8.dp + ) + ) { + Text( + text = "MULAI", + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.2.sp + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Copyright + Text( + text = "© 2024 BMI Calculator App", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + textAlign = TextAlign.Center + ) + } + } +} + +/** + * Composable untuk menampilkan baris informasi (label: value) + */ +@Composable +fun InfoRow(label: String, value: String) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = label, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Medium + ) + Text( + text = value, + fontSize = 18.sp, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 4.dp) + ) + } +} + +@Preview(showBackground = true) +@Composable +fun WelcomeScreenPreview() { + TipTimeTheme { + WelcomeScreen(onStartClick = {}) + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_height.xml b/app/src/main/res/drawable/ic_height.xml new file mode 100644 index 0000000..a7bd381 --- /dev/null +++ b/app/src/main/res/drawable/ic_height.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_weight.xml b/app/src/main/res/drawable/ic_weight.xml new file mode 100644 index 0000000..5920585 --- /dev/null +++ b/app/src/main/res/drawable/ic_weight.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 04e6db2..05341a5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,25 +1,18 @@ - - BMI Calculator - Calculate BMI - Tinggi Badan - Berat Badan - Gunakan Unit USC (lbs/in)? - BMI Anda: %s - Kategori: %s - + BMI Calculator + Calculate your Body Mass Index + + + Height (cm) + Height (inches) + Weight (kg) + Weight (lbs) + + + Unit System + + + Your BMI + BMI Categories + \ No newline at end of file diff --git a/app/src/test/java/com/example/tiptime/BMICalculatorTest.kt b/app/src/test/java/com/example/tiptime/BMICalculatorTest.kt new file mode 100644 index 0000000..92abe46 --- /dev/null +++ b/app/src/test/java/com/example/tiptime/BMICalculatorTest.kt @@ -0,0 +1,338 @@ +/** + * Unit tests untuk BMI Calculator + * + * NPM: [ISI NPM ANDA] + * Nama: [ISI NAMA ANDA] + * + * Deskripsi: File ini berisi unit test untuk menguji fungsi-fungsi + * perhitungan BMI, kategori BMI, dan validasi input. + * + * Cara menjalankan test: + * 1. Di Android Studio, klik kanan pada file ini + * 2. Pilih "Run 'BMICalculatorTest'" + * Atau jalankan via terminal: ./gradlew test + */ + +package com.example.tiptime + +import org.junit.Test +import org.junit.Assert.* + +/** + * Unit Test Class untuk BMI Calculator + * Menguji semua fungsi kalkulasi dan validasi + */ +class BMICalculatorTest { + + /** + * Test 1: Menguji perhitungan BMI dengan sistem metrik (cm, kg) + * Input: Tinggi 170 cm, Berat 70 kg + * Expected Output: BMI = 24.22 + */ + @Test + fun calculateBMI_metricSystem_returnsCorrectValue() { + // Arrange (Persiapan) + val height = 170.0 + val weight = 70.0 + val useUSC = false + + // Act (Eksekusi) + val result = calculateBMI(height, weight, useUSC) + + // Assert (Verifikasi) + // BMI = 70 / (1.7 * 1.7) = 24.22 + assertEquals(24.22, result, 0.01) + } + + /** + * Test 2: Menguji perhitungan BMI dengan sistem USC (inches, lbs) + * Input: Tinggi 67 inches, Berat 154 lbs + * Expected Output: BMI ≈ 24.12 + */ + @Test + fun calculateBMI_uscSystem_returnsCorrectValue() { + val height = 67.0 + val weight = 154.0 + val useUSC = true + + val result = calculateBMI(height, weight, useUSC) + + // Konversi: 67 in = 1.7018 m, 154 lbs = 69.85 kg + // BMI = 69.85 / (1.7018)^2 = 24.12 + assertEquals(24.12, result, 0.1) + } + + /** + * Test 3: Menguji edge case - tinggi = 0 + * Input: Tinggi 0 cm + * Expected Output: BMI = 0 (untuk menghindari division by zero) + */ + @Test + fun calculateBMI_zeroHeight_returnsZero() { + val result = calculateBMI(0.0, 70.0, false) + assertEquals(0.0, result, 0.01) + } + + /** + * Test 4: Menguji edge case - berat = 0 + * Input: Berat 0 kg + * Expected Output: BMI = 0 + */ + @Test + fun calculateBMI_zeroWeight_returnsZero() { + val result = calculateBMI(170.0, 0.0, false) + assertEquals(0.0, result, 0.01) + } + + /** + * Test 5: Menguji kategori BMI - Underweight + * Input: BMI = 17.5 + * Expected Output: "Underweight" + */ + @Test + fun calculateBMICategory_underweight_returnsCorrectCategory() { + val bmi = 17.5 + val result = calculateBMICategory(bmi) + assertEquals("Underweight", result) + } + + /** + * Test 6: Menguji kategori BMI - Normal + * Input: BMI = 22.0 + * Expected Output: "Normal" + */ + @Test + fun calculateBMICategory_normal_returnsCorrectCategory() { + val bmi = 22.0 + val result = calculateBMICategory(bmi) + assertEquals("Normal", result) + } + + /** + * Test 7: Menguji kategori BMI - Overweight + * Input: BMI = 27.5 + * Expected Output: "Overweight" + */ + @Test + fun calculateBMICategory_overweight_returnsCorrectCategory() { + val bmi = 27.5 + val result = calculateBMICategory(bmi) + assertEquals("Overweight", result) + } + + /** + * Test 8: Menguji kategori BMI - Obese + * Input: BMI = 32.0 + * Expected Output: "Obese" + */ + @Test + fun calculateBMICategory_obese_returnsCorrectCategory() { + val bmi = 32.0 + val result = calculateBMICategory(bmi) + assertEquals("Obese", result) + } + + /** + * Test 9: Menguji boundary case - BMI = 18.5 (batas Normal) + * Input: BMI = 18.5 + * Expected Output: "Normal" + */ + @Test + fun calculateBMICategory_boundaryCase_18point5_returnsNormal() { + val bmi = 18.5 + val result = calculateBMICategory(bmi) + assertEquals("Normal", result) + } + + /** + * Test 10: Menguji boundary case - BMI = 25.0 (batas Overweight) + * Input: BMI = 25.0 + * Expected Output: "Overweight" + */ + @Test + fun calculateBMICategory_boundaryCase_25_returnsOverweight() { + val bmi = 25.0 + val result = calculateBMICategory(bmi) + assertEquals("Overweight", result) + } + + /** + * Test 11: Menguji boundary case - BMI = 30.0 (batas Obese) + * Input: BMI = 30.0 + * Expected Output: "Obese" + */ + @Test + fun calculateBMICategory_boundaryCase_30_returnsObese() { + val bmi = 30.0 + val result = calculateBMICategory(bmi) + assertEquals("Obese", result) + } + + /** + * Test 12: Menguji validasi input yang valid (metrik) + * Input: Tinggi 170 cm, Berat 70 kg + * Expected Output: null (tidak ada error) + */ + @Test + fun validateInput_validMetricInput_returnsNull() { + val result = validateInput(170.0, 70.0, false) + assertNull(result) + } + + /** + * Test 13: Menguji validasi input yang valid (USC) + * Input: Tinggi 67 inches, Berat 154 lbs + * Expected Output: null (tidak ada error) + */ + @Test + fun validateInput_validUSCInput_returnsNull() { + val result = validateInput(67.0, 154.0, true) + assertNull(result) + } + + /** + * Test 14: Menguji validasi input - tinggi = 0 + * Input: Tinggi 0 cm + * Expected Output: error message (not null) + */ + @Test + fun validateInput_zeroHeight_returnsError() { + val result = validateInput(0.0, 70.0, false) + assertNotNull(result) + } + + /** + * Test 15: Menguji validasi input - berat = 0 + * Input: Berat 0 kg + * Expected Output: error message (not null) + */ + @Test + fun validateInput_zeroWeight_returnsError() { + val result = validateInput(170.0, 0.0, false) + assertNotNull(result) + } + + /** + * Test 16: Menguji validasi input - tinggi terlalu rendah (metrik) + * Input: Tinggi 40 cm (tidak realistis) + * Expected Output: error message (not null) + */ + @Test + fun validateInput_heightTooLowMetric_returnsError() { + val result = validateInput(40.0, 70.0, false) + assertNotNull(result) + } + + /** + * Test 17: Menguji validasi input - tinggi terlalu tinggi (metrik) + * Input: Tinggi 350 cm (tidak realistis) + * Expected Output: error message (not null) + */ + @Test + fun validateInput_heightTooHighMetric_returnsError() { + val result = validateInput(350.0, 70.0, false) + assertNotNull(result) + } + + /** + * Test 18: Menguji validasi input - berat terlalu rendah (metrik) + * Input: Berat 5 kg (tidak realistis) + * Expected Output: error message (not null) + */ + @Test + fun validateInput_weightTooLowMetric_returnsError() { + val result = validateInput(170.0, 5.0, false) + assertNotNull(result) + } + + /** + * Test 19: Menguji validasi input - berat terlalu tinggi (metrik) + * Input: Berat 600 kg (tidak realistis) + * Expected Output: error message (not null) + */ + @Test + fun validateInput_weightTooHighMetric_returnsError() { + val result = validateInput(170.0, 600.0, false) + assertNotNull(result) + } + + /** + * Test 20: Menguji perhitungan BMI dengan nilai real world + * Input: Tinggi 165 cm, Berat 60 kg + * Expected Output: BMI = 22.04 + */ + @Test + fun calculateBMI_realWorldExample1_returnsCorrectValue() { + val height = 165.0 + val weight = 60.0 + val useUSC = false + + val result = calculateBMI(height, weight, useUSC) + + // BMI = 60 / (1.65 * 1.65) = 22.04 + assertEquals(22.04, result, 0.01) + } + + /** + * Test 21: Menguji perhitungan BMI dengan nilai real world lainnya + * Input: Tinggi 180 cm, Berat 85 kg + * Expected Output: BMI = 26.23 + */ + @Test + fun calculateBMI_realWorldExample2_returnsCorrectValue() { + val height = 180.0 + val weight = 85.0 + val useUSC = false + + val result = calculateBMI(height, weight, useUSC) + + // BMI = 85 / (1.8 * 1.8) = 26.23 + assertEquals(26.23, result, 0.01) + } + + /** + * Test 22: Menguji validasi input dengan nilai boundary - tinggi minimum valid + * Input: Tinggi 50 cm (batas minimum) + * Expected Output: null (valid) + */ + @Test + fun validateInput_minimumHeightMetric_returnsNull() { + val result = validateInput(50.0, 70.0, false) + assertNull(result) + } + + /** + * Test 23: Menguji validasi input dengan nilai boundary - tinggi maksimum valid + * Input: Tinggi 300 cm (batas maksimum) + * Expected Output: null (valid) + */ + @Test + fun validateInput_maximumHeightMetric_returnsNull() { + val result = validateInput(300.0, 70.0, false) + assertNull(result) + } + + /** + * Test 24: Menguji kategori BMI dengan nilai sangat rendah + * Input: BMI = 10.0 + * Expected Output: "Underweight" + */ + @Test + fun calculateBMICategory_veryLowBMI_returnsUnderweight() { + val bmi = 10.0 + val result = calculateBMICategory(bmi) + assertEquals("Underweight", result) + } + + /** + * Test 25: Menguji kategori BMI dengan nilai sangat tinggi + * Input: BMI = 50.0 + * Expected Output: "Obese" + */ + @Test + fun calculateBMICategory_veryHighBMI_returnsObese() { + val bmi = 50.0 + val result = calculateBMICategory(bmi) + assertEquals("Obese", result) + } +} \ No newline at end of file