From de59cc8f470279180c0ffbd766e03c3b097450af Mon Sep 17 00:00:00 2001 From: FazriA <202310715082@mhs.ubharajaya.ac.id> Date: Fri, 7 Nov 2025 20:47:13 +0700 Subject: [PATCH] UTS-202310715082-FazriAbdurrahman --- .../java/com/example/tiptime/MainActivity.kt | 289 +++++++++++------- .../com/example/tiptime/ui/theme/Color.kt | 119 ++++---- app/src/main/res/values/strings.xml | 39 ++- 3 files changed, 257 insertions(+), 190 deletions(-) diff --git a/app/src/main/java/com/example/tiptime/MainActivity.kt b/app/src/main/java/com/example/tiptime/MainActivity.kt index d0fdd80..cc7b8c8 100644 --- a/app/src/main/java/com/example/tiptime/MainActivity.kt +++ b/app/src/main/java/com/example/tiptime/MainActivity.kt @@ -1,18 +1,3 @@ -/* - * 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 @@ -21,7 +6,9 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.annotation.DrawableRes import androidx.annotation.StringRes +import androidx.compose.foundation.Canvas 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 @@ -30,17 +17,18 @@ 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.size import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.layout.wrapContentWidth 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.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults 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 @@ -48,14 +36,20 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke 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.cos +import kotlin.math.sin class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -65,8 +59,9 @@ class MainActivity : ComponentActivity() { TipTimeTheme { Surface( modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background ) { - TipTimeLayout() + BmiCalculatorScreen() } } } @@ -74,68 +69,60 @@ 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("175") } // Default for preview + var weightInput by remember { mutableStateOf("70") } // Default for preview + var ageInput by remember { mutableStateOf("25") } - 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 heightCm = heightInput.toDoubleOrNull() ?: 0.0 + val weightKg = weightInput.toDoubleOrNull() ?: 0.0 + + val bmi = calculateBMI(heightCm, weightKg) Column( modifier = Modifier .statusBarsPadding() - .padding(horizontal = 40.dp) + .padding(horizontal = 24.dp) .verticalScroll(rememberScrollState()) .safeDrawingPadding(), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + verticalArrangement = Arrangement.Top ) { + Spacer(modifier = Modifier.height(20.dp)) Text( text = stringResource(R.string.calculate_tip), - modifier = Modifier - .padding(bottom = 16.dp, top = 40.dp) - .align(alignment = Alignment.Start) + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.primary ) + Spacer(modifier = Modifier.height(24.dp)) + 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(), + value = heightInput, + onValueChanged = { heightInput = it }, ) + Spacer(modifier = Modifier.height(16.dp)) 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(), + value = weightInput, + onValueChanged = { weightInput = it }, ) - 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(16.dp)) + EditNumberField( + label = R.string.age, + leadingIcon = R.drawable.number, + value = ageInput, + onValueChanged = { ageInput = it }, + imeAction = ImeAction.Done ) + Spacer(modifier = Modifier.height(32.dp)) - Spacer(modifier = Modifier.height(150.dp)) + if (bmi > 0) { + ResultScreen(bmi = bmi, heightCm = heightCm, weightKg = weightKg) + } + Spacer(modifier = Modifier.height(20.dp)) } } @@ -143,72 +130,162 @@ fun TipTimeLayout() { fun EditNumberField( @StringRes label: Int, @DrawableRes leadingIcon: Int, - keyboardOptions: KeyboardOptions, value: String, onValueChanged: (String) -> Unit, - modifier: Modifier = Modifier + imeAction: ImeAction = ImeAction.Next ) { - TextField( + OutlinedTextField( value = value, singleLine = true, - leadingIcon = { Icon(painter = painterResource(id = leadingIcon), null) }, - modifier = modifier, + modifier = Modifier.fillMaxWidth(), onValueChange = onValueChanged, label = { Text(stringResource(label)) }, - keyboardOptions = keyboardOptions + leadingIcon = { Icon(painter = painterResource(id = leadingIcon), null) }, + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Number, + imeAction = imeAction + ), + shape = RoundedCornerShape(16.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline + ) ) } @Composable -fun RoundTheTipRow( - roundUp: Boolean, - onRoundUpChanged: (Boolean) -> Unit, - modifier: Modifier = Modifier -) { - Row( - modifier = modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically +fun ResultScreen(bmi: Double, heightCm: Double, weightKg: Double) { + val category = getBmiCategory(bmi) + val (healthyWeightMin, healthyWeightMax) = calculateHealthyWeightRange(heightCm) + val bmiPrime = calculateBmiPrime(bmi) + val ponderalIndex = calculatePonderalIndex(heightCm, weightKg) + + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally ) { - Text(text = stringResource(R.string.use_usc)) - Switch( - modifier = Modifier - .fillMaxWidth() - .wrapContentWidth(Alignment.End), - checked = roundUp, - onCheckedChange = onRoundUpChanged + Text( + text = stringResource(R.string.result_title), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.bmi_value_category, bmi, category), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(24.dp)) + + BmiGaugeChart(bmi = bmi) + + Spacer(modifier = Modifier.height(24.dp)) + + Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.Start) { + ResultInfoLine(label = stringResource(R.string.healthy_bmi_range)) + ResultInfoLine(label = stringResource(R.string.healthy_weight_for_height, healthyWeightMin, healthyWeightMax)) + ResultInfoLine(label = stringResource(R.string.bmi_prime, bmiPrime)) + ResultInfoLine(label = stringResource(R.string.ponderal_index, ponderalIndex)) + } + } +} + +@Composable +fun ResultInfoLine(label: String) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(text = "• ", color = MaterialTheme.colorScheme.onSurface) + Text(text = label, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface) + } + Spacer(modifier = Modifier.height(4.dp)) +} + +@Composable +fun BmiGaugeChart(bmi: Double) { + val bmiMin = 16.0 + val bmiMax = 40.0 + + val colors = listOf( + Color(0xFFD32F2F), // Underweight + Color(0xFF388E3C), // Normal + Color(0xFFFBC02D), // Overweight + Color(0xFFC62828) // Obesity + ) + + val onSurfaceColor = MaterialTheme.colorScheme.onSurface + + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(280.dp)) { + Canvas(modifier = Modifier.fillMaxSize()) { + val strokeWidth = 30.dp.toPx() + val center = Offset(size.width / 2, size.height / 2) + val radius = (size.minDimension / 2) - strokeWidth + + val scale = 180f / (bmiMax - bmiMin).toFloat() + val underweightSweep = (18.5f - bmiMin.toFloat()) * scale + val normalSweep = (25f - 18.5f) * scale + val overweightSweep = (30f - 25f) * scale + val obesitySweep = (bmiMax.toFloat() - 30f) * scale + + drawArc(colors[0], 180f, underweightSweep, false, style = Stroke(width = strokeWidth)) + drawArc(colors[1], 180f + underweightSweep, normalSweep, false, style = Stroke(width = strokeWidth)) + drawArc(colors[2], 180f + underweightSweep + normalSweep, overweightSweep, false, style = Stroke(width = strokeWidth)) + drawArc(colors[3], 180f + underweightSweep + normalSweep + overweightSweep, obesitySweep, false, style = Stroke(width = strokeWidth)) + + val angle = 180 + (180 * ((bmi.coerceIn(bmiMin, bmiMax) - bmiMin) / (bmiMax - bmiMin))).toFloat() + val needleLength = radius - 10.dp.toPx() + val needleEndX = center.x + needleLength * cos(Math.toRadians(angle.toDouble())).toFloat() + val needleEndY = center.y + needleLength * sin(Math.toRadians(angle.toDouble())).toFloat() + + drawLine(onSurfaceColor, center, Offset(needleEndX, needleEndY), 3.dp.toPx(), StrokeCap.Round) + drawCircle(onSurfaceColor, 5.dp.toPx(), center) + } + + Text( + text = "BMI = %.1f".format(bmi), + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.Bold, + color = onSurfaceColor ) } } -/** - * 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) +private fun calculateBMI(heightCm: Double, weightKg: Double): Double { + if (heightCm <= 0 || weightKg <= 0) return 0.0 + val heightInMeters = heightCm / 100.0 + return weightKg / (heightInMeters * heightInMeters) } -/** - * 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 getBmiCategory(bmi: Double): String { + return when { + bmi < 18.5 -> "Underweight" + bmi < 25 -> "Normal" + bmi < 30 -> "Overweight" + else -> "Obese" } - return NumberFormat.getNumberInstance().format(bmi) } -@Preview(showBackground = true) + +private fun calculateHealthyWeightRange(heightCm: Double): Pair { + if (heightCm <= 0) return 0.0 to 0.0 + val heightInMeters = heightCm / 100.0 + val minWeight = 18.5 * heightInMeters * heightInMeters + val maxWeight = 24.9 * heightInMeters * heightInMeters + return minWeight to maxWeight +} + +private fun calculateBmiPrime(bmi: Double): Double { + return if (bmi > 0) bmi / 25.0 else 0.0 +} + +private fun calculatePonderalIndex(heightCm: Double, weightKg: Double): Double { + if (heightCm <= 0 || weightKg <= 0) return 0.0 + val heightInMeters = heightCm / 100.0 + return weightKg / (heightInMeters * heightInMeters * heightInMeters) +} + +@Preview(showBackground = true, widthDp = 380) @Composable -fun TipTimeLayoutPreview() { +fun BmiCalculatorScreenPreview() { TipTimeTheme { - TipTimeLayout() + BmiCalculatorScreen() } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/tiptime/ui/theme/Color.kt b/app/src/main/java/com/example/tiptime/ui/theme/Color.kt index bc21042..e56ccd8 100644 --- a/app/src/main/java/com/example/tiptime/ui/theme/Color.kt +++ b/app/src/main/java/com/example/tiptime/ui/theme/Color.kt @@ -1,78 +1,71 @@ -/* - * 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.ui.theme import androidx.compose.ui.graphics.Color -val md_theme_light_primary = Color(0xFF984061) -val md_theme_light_onPrimary = Color(0xFFFFFFFF) -val md_theme_light_primaryContainer = Color(0xFFFFD9E2) -val md_theme_light_onPrimaryContainer = Color(0xFF3E001D) -val md_theme_light_secondary = Color(0xFF754B9C) -val md_theme_light_onSecondary = Color(0xFFFFFFFF) -val md_theme_light_secondaryContainer = Color(0xFFF1DBFF) -val md_theme_light_onSecondaryContainer = Color(0xFF2D0050) -val md_theme_light_tertiary = Color(0xFF984060) -val md_theme_light_onTertiary = Color(0xFFFFFFFF) -val md_theme_light_tertiaryContainer = Color(0xFFFFD9E2) -val md_theme_light_onTertiaryContainer = Color(0xFF3E001D) +// Custom Army Green & Brown Palette +val armyGreen = Color(0xFF4B5320) +val darkBrown = Color(0xFF5C4033) +val lightCream = Color(0xFFF5F5DC) +val tan = Color(0xFFD2B48C) + +// Material 3 Light Theme Colors +val md_theme_light_primary = armyGreen +val md_theme_light_onPrimary = lightCream +val md_theme_light_primaryContainer = tan +val md_theme_light_onPrimaryContainer = darkBrown +val md_theme_light_secondary = darkBrown +val md_theme_light_onSecondary = lightCream +val md_theme_light_secondaryContainer = tan +val md_theme_light_onSecondaryContainer = darkBrown +val md_theme_light_tertiary = tan +val md_theme_light_onTertiary = darkBrown +val md_theme_light_tertiaryContainer = armyGreen +val md_theme_light_onTertiaryContainer = lightCream val md_theme_light_error = Color(0xFFBA1A1A) val md_theme_light_errorContainer = Color(0xFFFFDAD6) val md_theme_light_onError = Color(0xFFFFFFFF) val md_theme_light_onErrorContainer = Color(0xFF410002) -val md_theme_light_background = Color(0xFFFAFCFF) -val md_theme_light_onBackground = Color(0xFF001F2A) -val md_theme_light_surface = Color(0xFFFAFCFF) -val md_theme_light_onSurface = Color(0xFF001F2A) -val md_theme_light_surfaceVariant = Color(0xFFF2DDE2) -val md_theme_light_onSurfaceVariant = Color(0xFF514347) -val md_theme_light_outline = Color(0xFF837377) -val md_theme_light_inverseOnSurface = Color(0xFFE1F4FF) -val md_theme_light_inverseSurface = Color(0xFF003547) -val md_theme_light_inversePrimary = Color(0xFFFFB0C8) -val md_theme_light_surfaceTint = Color(0xFF984061) -val md_theme_light_outlineVariant = Color(0xFFD5C2C6) +val md_theme_light_background = lightCream +val md_theme_light_onBackground = darkBrown +val md_theme_light_surface = lightCream +val md_theme_light_onSurface = darkBrown +val md_theme_light_surfaceVariant = tan +val md_theme_light_onSurfaceVariant = darkBrown +val md_theme_light_outline = armyGreen +val md_theme_light_inverseOnSurface = lightCream +val md_theme_light_inverseSurface = darkBrown +val md_theme_light_inversePrimary = lightCream +val md_theme_light_surfaceTint = armyGreen +val md_theme_light_outlineVariant = tan val md_theme_light_scrim = Color(0xFF000000) -val md_theme_dark_primary = Color(0xFFFFB0C8) -val md_theme_dark_onPrimary = Color(0xFF5E1133) -val md_theme_dark_primaryContainer = Color(0xFF7B2949) -val md_theme_dark_onPrimaryContainer = Color(0xFFFFD9E2) -val md_theme_dark_secondary = Color(0xFFDEB7FF) -val md_theme_dark_onSecondary = Color(0xFF44196A) -val md_theme_dark_secondaryContainer = Color(0xFF5C3382) -val md_theme_dark_onSecondaryContainer = Color(0xFFF1DBFF) -val md_theme_dark_tertiary = Color(0xFFFFB1C7) -val md_theme_dark_onTertiary = Color(0xFF5E1132) -val md_theme_dark_tertiaryContainer = Color(0xFF7B2948) -val md_theme_dark_onTertiaryContainer = Color(0xFFFFD9E2) +// Material 3 Dark Theme Colors +val md_theme_dark_primary = tan +val md_theme_dark_onPrimary = darkBrown +val md_theme_dark_primaryContainer = armyGreen +val md_theme_dark_onPrimaryContainer = lightCream +val md_theme_dark_secondary = tan +val md_theme_dark_onSecondary = darkBrown +val md_theme_dark_secondaryContainer = armyGreen +val md_theme_dark_onSecondaryContainer = lightCream +val md_theme_dark_tertiary = lightCream +val md_theme_dark_onTertiary = armyGreen +val md_theme_dark_tertiaryContainer = darkBrown +val md_theme_dark_onTertiaryContainer = lightCream val md_theme_dark_error = Color(0xFFFFB4AB) val md_theme_dark_errorContainer = Color(0xFF93000A) val md_theme_dark_onError = Color(0xFF690005) val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6) -val md_theme_dark_background = Color(0xFF001F2A) -val md_theme_dark_onBackground = Color(0xFFBFE9FF) -val md_theme_dark_surface = Color(0xFF001F2A) -val md_theme_dark_onSurface = Color(0xFFBFE9FF) -val md_theme_dark_surfaceVariant = Color(0xFF514347) -val md_theme_dark_onSurfaceVariant = Color(0xFFD5C2C6) -val md_theme_dark_outline = Color(0xFF9E8C90) -val md_theme_dark_inverseOnSurface = Color(0xFF001F2A) -val md_theme_dark_inverseSurface = Color(0xFFBFE9FF) -val md_theme_dark_inversePrimary = Color(0xFF984061) -val md_theme_dark_surfaceTint = Color(0xFFFFB0C8) -val md_theme_dark_outlineVariant = Color(0xFF514347) +val md_theme_dark_background = darkBrown +val md_theme_dark_onBackground = lightCream +val md_theme_dark_surface = darkBrown +val md_theme_dark_onSurface = lightCream +val md_theme_dark_surfaceVariant = armyGreen +val md_theme_dark_onSurfaceVariant = lightCream +val md_theme_dark_outline = tan +val md_theme_dark_inverseOnSurface = darkBrown +val md_theme_dark_inverseSurface = lightCream +val md_theme_dark_inversePrimary = armyGreen +val md_theme_dark_surfaceTint = tan +val md_theme_dark_outlineVariant = armyGreen val md_theme_dark_scrim = Color(0xFF000000) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 04e6db2..ba60414 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,25 +1,22 @@ - BMI Calculator - Calculate BMI - Tinggi Badan - Berat Badan - Gunakan Unit USC (lbs/in)? - BMI Anda: %s - Kategori: %s + BMI Calculator + Tinggi Badan (cm) + Berat Badan (kg) + Umur + + + Result + BMI = %.1f kg/m² (%s) + Healthy BMI range: 18.5 kg/m² - 25 kg/m² + Healthy weight for your height: %.1f kg - %.1f kg + BMI Prime: %.2f + Ponderal Index: %.1f kg/m³ + + + Underweight + Normal + Overweight + Obesity