diff --git a/app/src/main/java/com/example/tiptime/MainActivity.kt b/app/src/main/java/com/example/tiptime/MainActivity.kt index d0fdd80..716dc23 100644 --- a/app/src/main/java/com/example/tiptime/MainActivity.kt +++ b/app/src/main/java/com/example/tiptime/MainActivity.kt @@ -1,214 +1,314 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ package com.example.tiptime import android.os.Bundle import androidx.activity.ComponentActivity 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.rememberScrollState +import androidx.compose.foundation.layout.* 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.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.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.example.tiptime.ui.theme.TipTimeTheme import java.text.NumberFormat +/** + * BMI Calculator Application + * + * NPM: [202310715051] + * Nama: [Dendi Yogia Pratama] + */ + class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { - enableEdgeToEdge() super.onCreate(savedInstanceState) + enableEdgeToEdge() setContent { TipTimeTheme { Surface( modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background ) { - TipTimeLayout() + BMICalculatorLayout() } } } } } +/** + * Data class untuk menyimpan hasil kalkulasi BMI + */ +data class BMIResult( + val bmiValue: Double, + val category: String, + val isValid: Boolean = true, + val errorMessage: String = "" +) + +/** + * Composable untuk switch pemilihan unit sistem (USC/SI) + */ @Composable -fun TipTimeLayout() { - var amountInput by remember { mutableStateOf("") } - var tipInput by remember { mutableStateOf("") } - var roundUp 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) - - Column( - modifier = Modifier - .statusBarsPadding() - .padding(horizontal = 40.dp) - .verticalScroll(rememberScrollState()) - .safeDrawingPadding(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - 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 - ) - - Spacer(modifier = Modifier.height(150.dp)) - } -} - -@Composable -fun EditNumberField( - @StringRes label: Int, - @DrawableRes leadingIcon: Int, - keyboardOptions: KeyboardOptions, - value: String, - onValueChanged: (String) -> Unit, - modifier: Modifier = Modifier -) { - TextField( - value = value, - singleLine = true, - leadingIcon = { Icon(painter = painterResource(id = leadingIcon), null) }, - modifier = modifier, - onValueChange = onValueChanged, - label = { Text(stringResource(label)) }, - keyboardOptions = keyboardOptions - ) -} - -@Composable -fun RoundTheTipRow( - roundUp: Boolean, - onRoundUpChanged: (Boolean) -> Unit, +fun UnitSystemSwitchRow( + useUSC: Boolean, + onUseUSCChanged: (Boolean) -> Unit, modifier: Modifier = Modifier ) { Row( modifier = modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { - Text(text = stringResource(R.string.use_usc)) + Text(text = "Use USC Units (lbs/inches)") Switch( modifier = Modifier .fillMaxWidth() .wrapContentWidth(Alignment.End), - checked = roundUp, - onCheckedChange = onRoundUpChanged + checked = useUSC, + onCheckedChange = onUseUSCChanged ) } } /** - * 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) - } - return NumberFormat.getNumberInstance().format(bmi) -} -/** - * Calculates the BMI Category - * - * Catatan: tambahkan unit test untuk kalkulasi BMI ini + * Main BMI Calculator Layout */ +@Composable +fun BMICalculatorLayout() { + var heightInput by remember { mutableStateOf("") } + var weightInput by remember { mutableStateOf("") } + var useUSC by remember { mutableStateOf(false) } + var bmiResult by remember { mutableStateOf(null) } -private fun calculateBMICategory(BmiHeight: Double, BmiWeight: Double = 15.0, roundUp: Boolean): String { - var bmi = BmiWeight / 100 * BmiHeight - if (roundUp) { - bmi = kotlin.math.ceil(bmi) + Column( + modifier = Modifier + .padding(40.dp) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "BMI Calculator", + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(bottom = 16.dp) + ) + + // Unit System Switch + UnitSystemSwitchRow( + useUSC = useUSC, + onUseUSCChanged = { useUSC = it }, + modifier = Modifier.padding(vertical = 8.dp) + ) + + // Height Input + OutlinedTextField( + value = heightInput, + onValueChange = { heightInput = it }, + label = { + Text(if (useUSC) "Height (inches)" else "Height (meters)") + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + // Weight Input + OutlinedTextField( + value = weightInput, + onValueChange = { weightInput = it }, + label = { + Text(if (useUSC) "Weight (lbs)" else "Weight (kg)") + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + // Calculate Button + Button( + onClick = { + val height = heightInput.toDoubleOrNull() + val weight = weightInput.toDoubleOrNull() + + if (height != null && weight != null) { + bmiResult = calculateBMI(height, weight, useUSC) + } else { + bmiResult = BMIResult( + bmiValue = 0.0, + category = "", + isValid = false, + errorMessage = "Please enter valid numbers" + ) + } + }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp) + ) { + Text("Calculate BMI") + } + + // Display Result + bmiResult?.let { result -> + Card( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + colors = CardDefaults.cardColors( + containerColor = if (result.isValid) + MaterialTheme.colorScheme.primaryContainer + else + MaterialTheme.colorScheme.errorContainer + ) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + if (result.isValid) { + Text( + text = "Your BMI: ${formatBMIResult(result)}", + style = MaterialTheme.typography.titleLarge + ) + Text( + text = "Category: ${result.category}", + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(top = 8.dp) + ) + + // BMI Info + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = getBMICategoryInfo(result.category), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } else { + Text( + text = "Error: ${result.errorMessage}", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.error + ) + } + } + } + } } - return NumberFormat.getNumberInstance().format(bmi) } + +/** + * Memvalidasi input tinggi dan berat badan + */ +fun validateBMIInput(height: Double, weight: Double, useUSC: Boolean): Pair { + if (!useUSC) { + // SI Units validation + when { + height <= 0 -> return Pair(false, "Tinggi harus lebih dari 0") + height < 0.5 -> return Pair(false, "Tinggi terlalu rendah (minimum 0.5m)") + height > 3.0 -> return Pair(false, "Tinggi tidak wajar (maksimum 3.0m)") + weight <= 0 -> return Pair(false, "Berat harus lebih dari 0") + weight < 20 -> return Pair(false, "Berat terlalu rendah (minimum 20kg)") + weight > 500 -> return Pair(false, "Berat tidak wajar (maksimum 500kg)") + } + } else { + // USC Units validation + when { + height <= 0 -> return Pair(false, "Height must be greater than 0") + height < 20 -> return Pair(false, "Height too low (minimum 20 inches)") + height > 120 -> return Pair(false, "Height unrealistic (maximum 120 inches)") + weight <= 0 -> return Pair(false, "Weight must be greater than 0") + weight < 44 -> return Pair(false, "Weight too low (minimum 44 lbs)") + weight > 1100 -> return Pair(false, "Weight unrealistic (maximum 1100 lbs)") + } + } + + return Pair(true, "") +} + +/** + * Menghitung BMI berdasarkan sistem unit yang dipilih + * + * Formula USC: BMI = 703 × (weight in lbs / height² in inches) + * Formula SI: BMI = weight in kg / height² in meters + */ +fun calculateBMI(height: Double, weight: Double, useUSC: Boolean): BMIResult { + // Validasi input + val (isValid, errorMessage) = validateBMIInput(height, weight, useUSC) + + if (!isValid) { + return BMIResult( + bmiValue = 0.0, + category = "", + isValid = false, + errorMessage = errorMessage + ) + } + + // Kalkulasi BMI + val bmi = if (useUSC) { + // USC: BMI = 703 × (weight in lbs / height² in inches) + 703.0 * (weight / (height * height)) + } else { + // SI: BMI = weight in kg / height² in meters + weight / (height * height) + } + + // Tentukan kategori BMI + val category = getBMICategory(bmi) + + return BMIResult( + bmiValue = bmi, + category = category, + isValid = true, + errorMessage = "" + ) +} + +/** + * Menentukan kategori BMI berdasarkan standar WHO + */ +fun getBMICategory(bmi: Double): String { + return when { + bmi < 18.5 -> "Underweight" + bmi < 25.0 -> "Normal weight" + bmi < 30.0 -> "Overweight" + else -> "Obese" + } +} + +/** + * Memberikan informasi tambahan tentang kategori BMI + */ +fun getBMICategoryInfo(category: String): String { + return when (category) { + "Underweight" -> "BMI < 18.5: Berat badan kurang" + "Normal weight" -> "BMI 18.5-24.9: Berat badan ideal" + "Overweight" -> "BMI 25.0-29.9: Kelebihan berat badan" + "Obese" -> "BMI ≥ 30.0: Obesitas" + else -> "" + } +} + +/** + * Format BMI result untuk ditampilkan + */ +fun formatBMIResult(result: BMIResult): String { + return if (result.isValid) { + NumberFormat.getNumberInstance().apply { + minimumFractionDigits = 1 + maximumFractionDigits = 1 + }.format(result.bmiValue) + } else { + result.errorMessage + } +} + @Preview(showBackground = true) @Composable -fun TipTimeLayoutPreview() { +fun BMICalculatorPreview() { TipTimeTheme { - TipTimeLayout() + BMICalculatorLayout() } } \ No newline at end of file