diff --git a/README.md b/README.md index 08d4aa4..9627ecf 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,38 @@ -Kalkulator BMI -=============== +# Kalkulator BMI -Silahkan kembangkan aplikasi ini untuk melakukan perhitungan BMI +Aplikasi Android sederhana untuk menghitung Indeks Massa Tubuh (IMT) atau _Body Mass Index_ (BMI), dibuat dengan Kotlin dan Jetpack Compose. -Petunjuk lebih detil dapat dibaca di -https://docs.google.com/document/d/1iGiC0Bg3Bdcd2Maq45TYkCDUkZ5Ql51E/edit?rtpof=true +## Deskripsi -Starter dimodifikasi dan terinspirasi dari: -https://developer.android.com/codelabs/basic-android-compose-calculate-tip#0 \ No newline at end of file +Aplikasi ini memungkinkan pengguna untuk menghitung BMI mereka dengan memasukkan tinggi dan berat badan. Aplikasi mendukung dua sistem pengukuran: + +1. **Sistem Internasional (SI)**: Menggunakan kilogram (kg) untuk berat dan sentimeter (cm) untuk tinggi. +2. **US Customary (USC)**: Menggunakan pon (lbs) untuk berat dan inci (in) untuk tinggi. + +Setelah menghitung, aplikasi akan menampilkan hasil BMI dan mengklasifikasikannya ke dalam salah satu dari empat kategori: +* **Kekurangan Berat Badan** +* **Normal** +* **Kelebihan Berat Badan** +* **Obesitas** + +## Fitur Utama + +* **Perhitungan BMI Akurat**: Mengimplementasikan formula standar BMI untuk kedua sistem unit. +* **Antarmuka Modern**: Dibuat dengan Jetpack Compose, menampilkan input field yang bersih dan kartu hasil yang dinamis. +* **Umpan Balik Visual**: Kartu hasil berubah warna sesuai dengan kategori BMI untuk memberikan indikasi visual yang cepat dan jelas. +* **Pilihan Unit Fleksibel**: Pengguna dapat dengan mudah beralih antara sistem SI dan USC. +* **Validasi Sederhana**: Menangani input kosong untuk mencegah error saat perhitungan. +* **Kode Modular**: Kode dipecah menjadi beberapa komponen Composable yang dapat digunakan kembali (`BmiCalculatorScreen`, `EditNumberField`, `BmiResultCard`). +* **Unit Testing**: Dilengkapi dengan unit test untuk memverifikasi logika perhitungan BMI dan penentuan kategori. + +## Teknologi yang Digunakan + +* **Bahasa Pemrograman**: Kotlin +* **UI Toolkit**: Jetpack Compose +* **Arsitektur**: Mengikuti prinsip-prinsip dasar _state management_ di Compose dengan _unidirectional data flow_. +* **Asisten AI**: Proyek ini dikembangkan dengan bantuan **Gemini**, sebuah model bahasa besar dari Google, untuk pembuatan kode, refactoring, dokumentasi, dan debugging. + +## Dibuat Oleh + +* **Nama**: Yosep Gamaliel Mulia +* **NPM**: 202310715105 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e897dec..e78afa0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -20,9 +20,9 @@ diff --git a/app/src/main/java/com/example/tiptime/MainActivity.kt b/app/src/main/java/com/example/tiptime/MainActivity.kt index d0fdd80..ee47cf4 100644 --- a/app/src/main/java/com/example/tiptime/MainActivity.kt +++ b/app/src/main/java/com/example/tiptime/MainActivity.kt @@ -1,3 +1,4 @@ + /* * Copyright (C) 2023 The Android Open Source Project * @@ -13,6 +14,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +// NPM: 202310715105 +// Nama: Yosep Gamaliel Mulia package com.example.tiptime import android.os.Bundle @@ -21,52 +24,38 @@ 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 -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.safeDrawingPadding -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.material3.* +import androidx.compose.runtime.* 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.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign 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 androidx.compose.ui.unit.sp +import com.example.tiptime.ui.theme.BmiCalculatorTheme class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) setContent { - TipTimeTheme { + BmiCalculatorTheme { Surface( modifier = Modifier.fillMaxSize(), ) { - TipTimeLayout() + BmiCalculatorScreen() } } } @@ -74,68 +63,90 @@ class MainActivity : ComponentActivity() { } @Composable -fun TipTimeLayout() { - var amountInput by remember { mutableStateOf("") } - var tipInput by remember { mutableStateOf("") } - var roundUp by remember { mutableStateOf(false) } +fun BmiCalculatorScreen() { + var heightInput by remember { mutableStateOf("") } + var weightInput by remember { mutableStateOf("") } + var useUscUnits by remember { mutableStateOf(false) } + var bmiResult by remember { mutableStateOf?>(null) } - val BmiHeight = amountInput.toDoubleOrNull() ?: 0.0 - val BmiWeight = tipInput.toDoubleOrNull() ?: 0.0 - val bmi = calculateBMI(BmiHeight, BmiWeight, roundUp) - val category = calculateBMICategory(BmiHeight, BmiWeight, roundUp) + val height = heightInput.toDoubleOrNull() + val weight = weightInput.toDoubleOrNull() + + val heightLabel = if (useUscUnits) R.string.height_in else R.string.height_cm + val weightLabel = if (useUscUnits) R.string.weight_lbs else R.string.weight_kg Column( modifier = Modifier .statusBarsPadding() - .padding(horizontal = 40.dp) + .fillMaxWidth() .verticalScroll(rememberScrollState()) - .safeDrawingPadding(), + .safeDrawingPadding() + .padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + verticalArrangement = Arrangement.spacedBy(16.dp) ) { Text( - text = stringResource(R.string.calculate_tip), - 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 + text = stringResource(R.string.bmi_calculator_title), + style = MaterialTheme.typography.displaySmall, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() ) - Spacer(modifier = Modifier.height(150.dp)) + Spacer(modifier = Modifier.height(16.dp)) + + // Input Fields + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + EditNumberField( + label = heightLabel, + leadingIcon = R.drawable.height, + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Next + ), + value = heightInput, + onValueChanged = { heightInput = it; bmiResult = null }, + modifier = Modifier.fillMaxWidth(), + ) + EditNumberField( + label = weightLabel, + leadingIcon = R.drawable.weight, + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done + ), + value = weightInput, + onValueChanged = { weightInput = it; bmiResult = null }, + modifier = Modifier.fillMaxWidth(), + ) + UnitSelectionRow( + useUsc = useUscUnits, + onUscChanged = { useUscUnits = it; bmiResult = null }, + ) + } + + Button( + onClick = { + if (height != null && weight != null) { + val bmi = calculateBmi(height, weight, useUscUnits) + val category = getBmiCategory(bmi) + bmiResult = Pair(bmi, category) + } + }, + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF50AADD)), + modifier = Modifier + .fillMaxWidth() + .height(50.dp) + ) { + Text( + stringResource(R.string.calculate_bmi_button), + fontSize = 18.sp + ) + } + + // Result Display + bmiResult?.let { (bmi, category) -> + BmiResultCard(bmi = bmi, category = category) + } } } @@ -151,64 +162,114 @@ 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 + keyboardOptions = keyboardOptions, + shape = MaterialTheme.shapes.medium, + colors = TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent + ) ) } @Composable -fun RoundTheTipRow( - roundUp: Boolean, - onRoundUpChanged: (Boolean) -> Unit, +fun UnitSelectionRow( + useUsc: Boolean, + onUscChanged: (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)) + Text(text = stringResource(R.string.use_usc_units), style = MaterialTheme.typography.bodyLarge) Switch( - modifier = Modifier - .fillMaxWidth() - .wrapContentWidth(Alignment.End), - checked = roundUp, - onCheckedChange = onRoundUpChanged + checked = useUsc, + onCheckedChange = onUscChanged ) } } -/** - * Calculates the BMI - * - * Catatan: tambahkan unit test untuk kalkulasi BMI ini - */ -private fun calculateBMI(BmiHeight: Double, BmiWeight: Double = 15.0, roundUp: Boolean): String { - var bmi = BmiWeight / 100 * BmiHeight - if (roundUp) { - bmi = kotlin.math.ceil(bmi) +@Composable +fun BmiResultCard(bmi: Double, category: String, modifier: Modifier = Modifier) { + val categoryColor = getCategoryColor(category) + Column( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(categoryColor) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = stringResource(R.string.your_bmi), + style = MaterialTheme.typography.titleMedium, + color = Color.White + ) + Text( + text = bmi.format(1), + style = MaterialTheme.typography.displayLarge, + color = Color.White, + modifier = Modifier.padding(vertical = 8.dp) + ) + Text( + text = category, + style = MaterialTheme.typography.headlineSmall, + color = Color.White + ) } - 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) +private fun getCategoryColor(category: String): Color { + return when (category) { + "Kekurangan Berat Badan" -> Color(0xFF3D5AFE) // Blue + "Normal" -> Color(0xFF00C853) // Green + "Kelebihan Berat Badan" -> Color(0xFFFFAB00) // Amber + "Obesitas" -> Color(0xFFD50000) // Red + else -> Color.Gray } - return NumberFormat.getNumberInstance().format(bmi) } + + +internal fun calculateBmi(height: Double, weight: Double, useUscUnits: Boolean): Double { + return if (height > 0 && weight > 0) { + if (useUscUnits) { + 703 * weight / (height * height) + } else { + val heightInMeters = height / 100 + weight / (heightInMeters * heightInMeters) + } + } else { + 0.0 + } +} + +internal fun getBmiCategory(bmi: Double): String { + return when { + bmi < 18.5 -> "Kekurangan Berat Badan" + bmi < 25 -> "Normal" + bmi < 30 -> "Kelebihan Berat Badan" + else -> "Obesitas" + } +} + +private fun Double.format(digits: Int) = "%.${digits}f".format(this) + @Preview(showBackground = true) @Composable -fun TipTimeLayoutPreview() { - TipTimeTheme { - TipTimeLayout() +fun BmiCalculatorScreenPreview() { + BmiCalculatorTheme { + BmiCalculatorScreen() } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/tiptime/ui/theme/BmiCalculatorTheme.kt b/app/src/main/java/com/example/tiptime/ui/theme/BmiCalculatorTheme.kt new file mode 100644 index 0000000..62928ce --- /dev/null +++ b/app/src/main/java/com/example/tiptime/ui/theme/BmiCalculatorTheme.kt @@ -0,0 +1,13 @@ +package com.example.tiptime.ui.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable + +@Composable +fun BmiCalculatorTheme( + content: @Composable () -> Unit +) { + MaterialTheme( + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/res/drawable/height.xml b/app/src/main/res/drawable/height.xml new file mode 100644 index 0000000..496078a --- /dev/null +++ b/app/src/main/res/drawable/height.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher.xml b/app/src/main/res/drawable/ic_launcher.xml new file mode 100644 index 0000000..8d1d8f2 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/weight.xml b/app/src/main/res/drawable/weight.xml new file mode 100644 index 0000000..93b7bf6 --- /dev/null +++ b/app/src/main/res/drawable/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..ac59a20 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,25 +1,16 @@ - - BMI Calculator - Calculate BMI - Tinggi Badan - Berat Badan - Gunakan Unit USC (lbs/in)? - BMI Anda: %s - Kategori: %s - + Kalkulator BMI + Kalkulator BMI + Tinggi (inci) + Tinggi (cm) + Berat (pon) + Berat (kg) + Gunakan unit USC + BMI Anda: %s + Kategori: %s + Tinggi + Berat + Hitung BMI + BMI Anda + \ No newline at end of file