diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f786714..84129d9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,19 +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. - */ - plugins { id("com.android.application") id("org.jetbrains.kotlin.android") @@ -21,12 +5,13 @@ plugins { } android { - compileSdk = 35 + namespace = "com.example.tiptime" + compileSdk = 34 defaultConfig { applicationId = "com.example.tiptime" minSdk = 24 - targetSdk = 35 + targetSdk = 34 versionCode = 1 versionName = "1.0" @@ -46,11 +31,11 @@ android { } } compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 } kotlinOptions { - jvmTarget = JavaVersion.VERSION_17.toString() + jvmTarget = "1.8" } buildFeatures { compose = true @@ -60,26 +45,23 @@ android { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } - namespace = "com.example.tiptime" } dependencies { - - implementation(platform("androidx.compose:compose-bom:2024.12.01")) - implementation("androidx.activity:activity-compose:1.9.3") - implementation("androidx.compose.material3:material3") + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2") + implementation("androidx.activity:activity-compose:1.8.2") + implementation(platform("androidx.compose:compose-bom:2023.08.00")) implementation("androidx.compose.ui:ui") - implementation("androidx.compose.ui:ui-tooling") + implementation("androidx.compose.ui:ui-graphics") implementation("androidx.compose.ui:ui-tooling-preview") - implementation("androidx.core:core-ktx:1.15.0") - implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") - + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") testImplementation("junit:junit:4.13.2") - - androidTestImplementation(platform("androidx.compose:compose-bom:2024.12.01")) + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") + androidTestImplementation(platform("androidx.compose:compose-bom:2023.08.00")) androidTestImplementation("androidx.compose.ui:ui-test-junit4") - androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") - androidTestImplementation("androidx.test.ext:junit:1.2.1") - + debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-test-manifest") } diff --git a/app/src/main/java/com/example/tiptime/MainActivity.kt b/app/src/main/java/com/example/tiptime/MainActivity.kt index d0fdd80..71f8235 100644 --- a/app/src/main/java/com/example/tiptime/MainActivity.kt +++ b/app/src/main/java/com/example/tiptime/MainActivity.kt @@ -1,214 +1,373 @@ -/* - * 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. - */ +// NAMA: HADI PRAKOSO +// NPM: 2110010323 + 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.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.Canvas +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.material.icons.Icons +import androidx.compose.material.icons.filled.DateRange +import androidx.compose.material.icons.filled.LineWeight +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.SwapVert +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.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.platform.LocalDensity +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 androidx.compose.ui.unit.sp import com.example.tiptime.ui.theme.TipTimeTheme -import java.text.NumberFormat +import java.util.Locale +import kotlin.math.cos +import kotlin.math.pow +import kotlin.math.sin class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { - enableEdgeToEdge() super.onCreate(savedInstanceState) setContent { - TipTimeTheme { - Surface( - modifier = Modifier.fillMaxSize(), - ) { - TipTimeLayout() + TipTimeTheme(darkTheme = true) { + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + BmiCalculatorScreen() } } } } } -@Composable -fun TipTimeLayout() { - var amountInput by remember { mutableStateOf("") } - var tipInput by remember { mutableStateOf("") } - var roundUp by remember { mutableStateOf(false) } +data class BmiFullResult( + val bmi: Double, + val category: String, + val healthyBmiRange: String = "18.5 - 25.0", + val healthyWeightRange: String, + val bmiPrime: Double, + val ponderalIndex: Double +) - val BmiHeight = amountInput.toDoubleOrNull() ?: 0.0 - val BmiWeight = tipInput.toDoubleOrNull() ?: 0.0 - val bmi = calculateBMI(BmiHeight, BmiWeight, roundUp) - val category = calculateBMICategory(BmiHeight, BmiWeight, roundUp) +@Composable +fun BmiCalculatorScreen() { + var heightInput by remember { mutableStateOf("") } + var weightInput by remember { mutableStateOf("") } + var ageInput by remember { mutableStateOf("") } + var selectedGender by remember { mutableStateOf("Laki-laki") } + var useMetricUnits by remember { mutableStateOf(true) } + var bmiResult by remember { mutableStateOf(null) } + var validationError by remember { mutableStateOf(null) } + + fun resetFields() { + heightInput = "" + weightInput = "" + ageInput = "" + selectedGender = "Laki-laki" + useMetricUnits = true + bmiResult = null + validationError = null + } Column( modifier = Modifier - .statusBarsPadding() - .padding(horizontal = 40.dp) - .verticalScroll(rememberScrollState()) - .safeDrawingPadding(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + .fillMaxSize() + .padding(16.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally ) { - 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("Kalkulator BMI", style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(8.dp)) + Text("by Hadi Prakoso", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary) + Spacer(Modifier.height(24.dp)) + + AnimatedVisibility(visible = bmiResult != null) { + bmiResult?.let { ResultDisplay(it) } + } + + if (bmiResult != null) { + Spacer(Modifier.height(24.dp)) + } + + InputPanel( + heightInput = heightInput, onHeightChange = { heightInput = it; validationError = null }, + weightInput = weightInput, onWeightChange = { weightInput = it; validationError = null }, + ageInput = ageInput, onAgeChange = { ageInput = it; validationError = null }, + selectedGender = selectedGender, onGenderSelect = { selectedGender = it }, + useMetric = useMetricUnits, onUnitChange = { useMetricUnits = it } ) - Spacer(modifier = Modifier.height(150.dp)) + validationError?.let { + Text( + text = it, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 8.dp) + ) + } + + Spacer(Modifier.height(24.dp)) + + ActionButtons( + onCalculate = { + val height = heightInput.toDoubleOrNull() + val weight = weightInput.toDoubleOrNull() + val age = ageInput.toIntOrNull() + + // Input Validation + when { + height == null || weight == null || age == null -> { + validationError = "Semua kolom (Umur, Tinggi, Berat) harus diisi dengan angka." + bmiResult = null + } + age <= 0 || age > 120 -> { + validationError = "Umur tidak wajar." + bmiResult = null + } + (useMetricUnits && (height <= 50 || height > 270)) || (!useMetricUnits && (height <= 20 || height > 108)) -> { + validationError = "Tinggi badan tidak wajar." + bmiResult = null + } + (useMetricUnits && (weight <= 20 || weight > 500)) || (!useMetricUnits && (weight <= 45 || weight > 1100)) -> { + validationError = "Berat badan tidak wajar." + bmiResult = null + } + else -> { + validationError = null + bmiResult = calculateBmi(weight, height, useMetricUnits) + } + } + }, + onReset = { resetFields() } + ) } } @Composable -fun EditNumberField( - @StringRes label: Int, - @DrawableRes leadingIcon: Int, - keyboardOptions: KeyboardOptions, - value: String, - onValueChanged: (String) -> Unit, - modifier: Modifier = Modifier +fun ResultDisplay(result: BmiFullResult) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) + ) { + Column(Modifier.padding(24.dp).fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + Text("Hasil", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(16.dp)) + BmiGauge(bmi = result.bmi) + Spacer(Modifier.height(16.dp)) + Text("BMI = ${String.format("%.1f", result.bmi)} kg/m² (${result.category})", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + Spacer(Modifier.height(24.dp)) + ResultDetails(result) + } + } +} + +@Composable +fun BmiGauge(bmi: Double) { + val minBmi = 15.0 + val maxBmi = 40.0 + val clampedBmi = bmi.coerceIn(minBmi, maxBmi) + val progress = if (bmi > 0) ((clampedBmi - minBmi) / (maxBmi - minBmi)).toFloat() else 0f + val angle = 180f + progress * 180f + val animatedAngle by animateFloatAsState(targetValue = angle, label = "BMI Angle") + + val density = LocalDensity.current + val gaugeWidth = with(density) { 25.dp.toPx() } + val underweightColor = Color(0xFFD32F2F) + val normalColor = MaterialTheme.colorScheme.primary + val overweightColor = Color(0xFFFFEB3B) + val obeseColor = Color(0xFFB71C1C) + val onSurfaceColor = MaterialTheme.colorScheme.onSurface + val surfaceColor = MaterialTheme.colorScheme.surface + + Box(modifier = Modifier.size(280.dp).padding(20.dp), contentAlignment = Alignment.Center) { + Canvas(modifier = Modifier.fillMaxSize()) { + val center = Offset(size.width / 2, size.height / 2) + val radius = (size.minDimension / 2) - gaugeWidth / 2 + val bmiRange = maxBmi - minBmi + val underweightSweep = ((18.5 - minBmi) / bmiRange * 180f).toFloat() + val normalSweep = ((25.0 - 18.5) / bmiRange * 180f).toFloat() + val overweightSweep = ((30.0 - 25.0) / bmiRange * 180f).toFloat() + val obeseSweep = ((maxBmi - 30.0) / bmiRange * 180f).toFloat() + + drawArc(underweightColor, 180f, underweightSweep, false, style = Stroke(gaugeWidth, cap = StrokeCap.Butt)) + drawArc(normalColor, 180f + underweightSweep, normalSweep, false, style = Stroke(gaugeWidth, cap = StrokeCap.Butt)) + drawArc(overweightColor, 180f + underweightSweep + normalSweep, overweightSweep, false, style = Stroke(gaugeWidth, cap = StrokeCap.Butt)) + drawArc(obeseColor, 180f + underweightSweep + normalSweep + overweightSweep, obeseSweep, false, style = Stroke(gaugeWidth, cap = StrokeCap.Butt)) + + val angleRad = Math.toRadians(animatedAngle.toDouble()).toFloat() + val needleLength = radius + val endOffset = Offset(center.x + needleLength * cos(angleRad), center.y + needleLength * sin(angleRad)) + + drawLine(onSurfaceColor, center, endOffset, 5.dp.toPx(), StrokeCap.Round) + drawCircle(onSurfaceColor, radius = 10.dp.toPx(), center = center) + drawCircle(surfaceColor, radius = 5.dp.toPx(), center = center) + } + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(text = "BMI", style = MaterialTheme.typography.bodyLarge) + Text( + text = if (bmi > 0) String.format(Locale.getDefault(), "%.1f", bmi) else "-", + style = MaterialTheme.typography.displayMedium.copy(fontWeight = FontWeight.Bold, color = onSurfaceColor) + ) + } + } +} + +@Composable +fun ResultDetails(result: BmiFullResult) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + Text("Rentang BMI sehat: ${result.healthyBmiRange} kg/m²", style = MaterialTheme.typography.bodyLarge) + Text("Berat badan sehat untuk tinggi Anda: ${result.healthyWeightRange}", style = MaterialTheme.typography.bodyLarge) + Text("BMI Prime: ${String.format("%.2f", result.bmiPrime)}", style = MaterialTheme.typography.bodyLarge) + Text("Indeks Ponderal: ${String.format("%.1f", result.ponderalIndex)} kg/m³", style = MaterialTheme.typography.bodyLarge) + } +} + +@Composable +fun InputPanel( + heightInput: String, onHeightChange: (String) -> Unit, + weightInput: String, onWeightChange: (String) -> Unit, + ageInput: String, onAgeChange: (String) -> Unit, + selectedGender: String, onGenderSelect: (String) -> Unit, + useMetric: Boolean, onUnitChange: (Boolean) -> Unit ) { - TextField( + val heightLabel = if (useMetric) "Tinggi (cm)" else "Tinggi (in)" + val weightLabel = if (useMetric) "Berat (kg)" else "Berat (lbs)" + + Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) { + UnitToggle(useMetric = useMetric, onUnitChange = onUnitChange) + GenderSelector(selectedGender, onOptionSelected = { onGenderSelect(it) }) + BmiTextField(label = "Umur", value = ageInput, onValueChange = onAgeChange, icon = Icons.Default.DateRange) + BmiTextField(label = heightLabel, value = heightInput, onValueChange = onHeightChange, icon = Icons.Default.SwapVert) + BmiTextField(label = weightLabel, value = weightInput, onValueChange = onWeightChange, icon = Icons.Default.LineWeight) + } +} + +@Composable +fun ActionButtons(onCalculate: () -> Unit, onReset: () -> Unit) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Button(onClick = onCalculate, modifier = Modifier.weight(1f).height(50.dp), shape = RoundedCornerShape(16.dp)) { + Text("Hitung BMI", fontSize = 16.sp, fontWeight = FontWeight.Bold) + } + OutlinedButton(onClick = onReset, modifier = Modifier.weight(1f).height(50.dp), shape = RoundedCornerShape(16.dp)) { + Text("Ulang", fontSize = 16.sp) + } + } +} + +@Composable +fun UnitToggle(useMetric: Boolean, onUnitChange: (Boolean) -> Unit) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { + Text("Metric", color = if (useMetric) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant) + Switch(checked = !useMetric, onCheckedChange = { onUnitChange(!it) }, modifier = Modifier.padding(horizontal = 8.dp)) + Text("USC", color = if (!useMetric) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +@Composable +fun GenderSelector(selectedOption: String, onOptionSelected: (String) -> Unit) { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + val options = listOf("Laki-laki", "Perempuan") + options.forEach { gender -> + val isSelected = selectedOption == gender + Button( + onClick = { onOptionSelected(gender) }, + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors( + containerColor = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant, + contentColor = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface + ), + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Default.Person, contentDescription = gender, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text(gender) + } + } + } +} + +@Composable +fun BmiTextField(label: String, value: String, onValueChange: (String) -> Unit, icon: androidx.compose.ui.graphics.vector.ImageVector) { + OutlinedTextField( value = value, - singleLine = true, - leadingIcon = { Icon(painter = painterResource(id = leadingIcon), null) }, - modifier = modifier, - onValueChange = onValueChanged, - label = { Text(stringResource(label)) }, - keyboardOptions = keyboardOptions + onValueChange = onValueChange, + label = { Text(label) }, + leadingIcon = { Icon(icon, contentDescription = label) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Next), + shape = RoundedCornerShape(12.dp), + modifier = Modifier.fillMaxWidth() ) } -@Composable -fun RoundTheTipRow( - roundUp: Boolean, - onRoundUpChanged: (Boolean) -> Unit, - modifier: Modifier = Modifier -) { - Row( - modifier = modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Text(text = stringResource(R.string.use_usc)) - Switch( - modifier = Modifier - .fillMaxWidth() - .wrapContentWidth(Alignment.End), - checked = roundUp, - onCheckedChange = onRoundUpChanged - ) +// --- Calculation Logic --- +fun calculateBmi(weight: Double, height: Double, useMetric: Boolean): BmiFullResult? { + if (height <= 0 || weight <= 0) return null + + val bmi = if (useMetric) { + val heightInMeters = height / 100 + weight / heightInMeters.pow(2) + } else { + (weight * 703) / height.pow(2) + } + + val category = determineBmiCategory(bmi) + val heightInMeters = if (useMetric) height / 100 else height * 0.0254 + val minHealthyWeight = 18.5 * heightInMeters.pow(2) + val maxHealthyWeight = 25.0 * heightInMeters.pow(2) + + val healthyWeightRangeString = if (useMetric) { + String.format("%.1f kg - %.1f kg", minHealthyWeight, maxHealthyWeight) + } else { + val minHealthyWeightLbs = minHealthyWeight * 2.20462 + val maxHealthyWeightLbs = maxHealthyWeight * 2.20462 + String.format("%.1f lbs - %.1f lbs", minHealthyWeightLbs, maxHealthyWeightLbs) + } + + val bmiPrime = bmi / 25.0 + val weightInKg = if (useMetric) weight else weight * 0.453592 + val ponderalIndex = weightInKg / heightInMeters.pow(3) + + return BmiFullResult( + bmi = bmi, + category = category, + healthyWeightRange = healthyWeightRangeString, + bmiPrime = bmiPrime, + ponderalIndex = ponderalIndex + ) +} + +fun determineBmiCategory(bmi: Double): String { + return when { + bmi < 18.5 -> "Berat badan kurang" + bmi < 25 -> "Normal" + bmi < 30 -> "Berat badan lebih" + else -> "Obesitas" } } -/** - * 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 - */ - -private fun calculateBMICategory(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) -} -@Preview(showBackground = true) +@Preview(showBackground = true, widthDp = 360, heightDp = 800) @Composable -fun TipTimeLayoutPreview() { - TipTimeTheme { - TipTimeLayout() +fun BmiCalculatorScreenPreview() { + TipTimeTheme(darkTheme = true) { + 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..88d33e8 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,67 @@ -/* - * 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) +// COMPLETE Material 3 Color Palette (Purple Theme) + +// Light Theme Colors +val md_theme_light_primary = Color(0xFF6750A4) 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_primaryContainer = Color(0xFFEADDFF) +val md_theme_light_onPrimaryContainer = Color(0xFF21005D) +val md_theme_light_secondary = Color(0xFF625B71) 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_secondaryContainer = Color(0xFFE8DEF8) +val md_theme_light_onSecondaryContainer = Color(0xFF1D192B) +val md_theme_light_tertiary = Color(0xFF7D5260) val md_theme_light_onTertiary = Color(0xFFFFFFFF) -val md_theme_light_tertiaryContainer = Color(0xFFFFD9E2) -val md_theme_light_onTertiaryContainer = Color(0xFF3E001D) -val md_theme_light_error = Color(0xFFBA1A1A) -val md_theme_light_errorContainer = Color(0xFFFFDAD6) +val md_theme_light_tertiaryContainer = Color(0xFFFFD8E4) +val md_theme_light_onTertiaryContainer = Color(0xFF31111D) +val md_theme_light_error = Color(0xFFB3261E) 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_errorContainer = Color(0xFFF9DEDC) +val md_theme_light_onErrorContainer = Color(0xFF410E0B) +val md_theme_light_background = Color(0xFFFFFBFE) +val md_theme_light_onBackground = Color(0xFF1C1B1F) +val md_theme_light_surface = Color(0xFFFFFBFE) +val md_theme_light_onSurface = Color(0xFF1C1B1F) +val md_theme_light_surfaceVariant = Color(0xFFE7E0EC) +val md_theme_light_onSurfaceVariant = Color(0xFF49454F) +val md_theme_light_outline = Color(0xFF79747E) +val md_theme_light_inverseOnSurface = Color(0xFFF4EFF4) +val md_theme_light_inverseSurface = Color(0xFF313033) +val md_theme_light_inversePrimary = Color(0xFFD0BCFF) +val md_theme_light_surfaceTint = Color(0xFF6750A4) +val md_theme_light_outlineVariant = Color(0xFFCAC4D0) 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) -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) +// Dark Theme Colors +val md_theme_dark_primary = Color(0xFFD0BCFF) +val md_theme_dark_onPrimary = Color(0xFF381E72) +val md_theme_dark_primaryContainer = Color(0xFF4F378B) +val md_theme_dark_onPrimaryContainer = Color(0xFFEADDFF) +val md_theme_dark_secondary = Color(0xFFCCC2DC) +val md_theme_dark_onSecondary = Color(0xFF332D41) +val md_theme_dark_secondaryContainer = Color(0xFF4A4458) +val md_theme_dark_onSecondaryContainer = Color(0xFFE8DEF8) +val md_theme_dark_tertiary = Color(0xFFEFB8C8) +val md_theme_dark_onTertiary = Color(0xFF492532) +val md_theme_dark_tertiaryContainer = Color(0xFF633B48) +val md_theme_dark_onTertiaryContainer = Color(0xFFFFD8E4) +val md_theme_dark_error = Color(0xFFF2B8B5) +val md_theme_dark_onError = Color(0xFF601410) +val md_theme_dark_errorContainer = Color(0xFF8C1D18) +val md_theme_dark_onErrorContainer = Color(0xFFF9DEDC) +val md_theme_dark_background = Color(0xFF1C1B1F) +val md_theme_dark_onBackground = Color(0xFFE6E1E5) +val md_theme_dark_surface = Color(0xFF1C1B1F) +val md_theme_dark_onSurface = Color(0xFFE6E1E5) +val md_theme_dark_surfaceVariant = Color(0xFF49454F) +val md_theme_dark_onSurfaceVariant = Color(0xFFCAC4D0) +val md_theme_dark_outline = Color(0xFF938F99) +val md_theme_dark_inverseOnSurface = Color(0xFF1C1B1F) +val md_theme_dark_inverseSurface = Color(0xFFE6E1E5) +val md_theme_dark_inversePrimary = Color(0xFF6750A4) +val md_theme_dark_surfaceTint = Color(0xFFD0BCFF) +val md_theme_dark_outlineVariant = Color(0xFF49454F) val md_theme_dark_scrim = Color(0xFF000000) diff --git a/app/src/main/java/com/example/tiptime/ui/theme/Font.kt b/app/src/main/java/com/example/tiptime/ui/theme/Font.kt new file mode 100644 index 0000000..25ac1e7 --- /dev/null +++ b/app/src/main/java/com/example/tiptime/ui/theme/Font.kt @@ -0,0 +1,11 @@ +package com.example.tiptime.ui.theme + +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import com.example.tiptime.R + +val Poppins = FontFamily( + Font(R.font.poppins_regular, FontWeight.Normal), + Font(R.font.poppins_bold, FontWeight.Bold) +) diff --git a/app/src/main/java/com/example/tiptime/ui/theme/Theme.kt b/app/src/main/java/com/example/tiptime/ui/theme/Theme.kt index b8c9adb..c83292b 100644 --- a/app/src/main/java/com/example/tiptime/ui/theme/Theme.kt +++ b/app/src/main/java/com/example/tiptime/ui/theme/Theme.kt @@ -1,29 +1,41 @@ -/* - * 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 android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.dynamicDarkColorScheme -import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.graphics.Color + +private val DarkColorScheme = darkColorScheme( + primary = Color(0xFF00C853), // A vibrant green for primary actions + onPrimary = Color.Black, + primaryContainer = Color(0xFF009624), + onPrimaryContainer = Color.White, + secondary = Color(0xFF00BFA5), // A teal for secondary elements + onSecondary = Color.Black, + secondaryContainer = Color(0xFF008E76), + onSecondaryContainer = Color.White, + tertiary = Color(0xFF00B8D4), // A cyan for accents + onTertiary = Color.Black, + tertiaryContainer = Color(0xFF008394), + onTertiaryContainer = Color.White, + background = Color(0xFF121212), // A very dark background + onBackground = Color.White, + surface = Color(0xFF1E1E1E), // A slightly lighter surface for cards + onSurface = Color.White, + surfaceVariant = Color(0xFF424242), + onSurfaceVariant = Color.White, + outline = Color(0xFF9E9E9E), + inverseOnSurface = Color.White, + inverseSurface = Color(0xFF212121), + inversePrimary = Color(0xFF00C853), + error = Color(0xFFD32F2F), + onError = Color.White, + errorContainer = Color(0xFFB71C1C), + onErrorContainer = Color.White +) + private val LightColorScheme = lightColorScheme( primary = md_theme_light_primary, @@ -39,8 +51,8 @@ private val LightColorScheme = lightColorScheme( tertiaryContainer = md_theme_light_tertiaryContainer, onTertiaryContainer = md_theme_light_onTertiaryContainer, error = md_theme_light_error, - errorContainer = md_theme_light_errorContainer, onError = md_theme_light_onError, + errorContainer = md_theme_light_errorContainer, onErrorContainer = md_theme_light_onErrorContainer, background = md_theme_light_background, onBackground = md_theme_light_onBackground, @@ -50,66 +62,20 @@ private val LightColorScheme = lightColorScheme( onSurfaceVariant = md_theme_light_onSurfaceVariant, outline = md_theme_light_outline, inverseOnSurface = md_theme_light_inverseOnSurface, - inverseSurface = md_theme_light_inverseSurface, - inversePrimary = md_theme_light_inversePrimary, - surfaceTint = md_theme_light_surfaceTint, - outlineVariant = md_theme_light_outlineVariant, - scrim = md_theme_light_scrim, -) - -private val DarkColorScheme = darkColorScheme( - primary = md_theme_dark_primary, - onPrimary = md_theme_dark_onPrimary, - primaryContainer = md_theme_dark_primaryContainer, - onPrimaryContainer = md_theme_dark_onPrimaryContainer, - secondary = md_theme_dark_secondary, - onSecondary = md_theme_dark_onSecondary, - secondaryContainer = md_theme_dark_secondaryContainer, - onSecondaryContainer = md_theme_dark_onSecondaryContainer, - tertiary = md_theme_dark_tertiary, - onTertiary = md_theme_dark_onTertiary, - tertiaryContainer = md_theme_dark_tertiaryContainer, - onTertiaryContainer = md_theme_dark_onTertiaryContainer, - error = md_theme_dark_error, - errorContainer = md_theme_dark_errorContainer, - onError = md_theme_dark_onError, - onErrorContainer = md_theme_dark_onErrorContainer, - background = md_theme_dark_background, - onBackground = md_theme_dark_onBackground, - surface = md_theme_dark_surface, - onSurface = md_theme_dark_onSurface, - surfaceVariant = md_theme_dark_surfaceVariant, - onSurfaceVariant = md_theme_dark_onSurfaceVariant, - outline = md_theme_dark_outline, - inverseOnSurface = md_theme_dark_inverseOnSurface, - inverseSurface = md_theme_dark_inverseSurface, - inversePrimary = md_theme_dark_inversePrimary, - surfaceTint = md_theme_dark_surfaceTint, - outlineVariant = md_theme_dark_outlineVariant, - scrim = md_theme_dark_scrim, +inverseSurface = md_theme_light_inverseSurface, +inversePrimary = md_theme_light_inversePrimary, ) @Composable fun TipTimeTheme( darkTheme: Boolean = isSystemInDarkTheme(), - // Dynamic color is available on Android 12+ - // Dynamic color in this app is turned off for learning purposes - dynamicColor: Boolean = false, 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 - } + val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme MaterialTheme( colorScheme = colorScheme, typography = Typography, content = content ) -} +} \ No newline at end of file diff --git a/app/src/main/java/com/example/tiptime/ui/theme/Type.kt b/app/src/main/java/com/example/tiptime/ui/theme/Type.kt index a4cf6e6..e23c87f 100644 --- a/app/src/main/java/com/example/tiptime/ui/theme/Type.kt +++ b/app/src/main/java/com/example/tiptime/ui/theme/Type.kt @@ -1,33 +1,36 @@ -/* - * 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.material3.Typography import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp // Set of Material typography styles to start with val Typography = Typography( - displaySmall = TextStyle( - fontFamily = FontFamily.Default, + // Override default text styles to use Poppins + headlineMedium = TextStyle( + fontFamily = Poppins, fontWeight = FontWeight.Bold, - fontSize = 36.sp, - lineHeight = 44.sp, - letterSpacing = 0.sp, + fontSize = 28.sp + ), + titleMedium = TextStyle( + fontFamily = Poppins, + fontWeight = FontWeight.Normal, + fontSize = 16.sp + ), + bodyLarge = TextStyle( + fontFamily = Poppins, + fontWeight = FontWeight.Normal, + fontSize = 16.sp + ), + bodyMedium = TextStyle( + fontFamily = Poppins, + fontWeight = FontWeight.Normal, + fontSize = 14.sp + ), + displayMedium = TextStyle( + fontFamily = Poppins, + fontWeight = FontWeight.Bold, + fontSize = 57.sp ) ) diff --git a/app/src/main/res/font/poppins_bold.ttf b/app/src/main/res/font/poppins_bold.ttf new file mode 100644 index 0000000..00559ee Binary files /dev/null and b/app/src/main/res/font/poppins_bold.ttf differ diff --git a/app/src/main/res/font/poppins_regular.ttf b/app/src/main/res/font/poppins_regular.ttf new file mode 100644 index 0000000..9f0c71b Binary files /dev/null and b/app/src/main/res/font/poppins_regular.ttf differ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 04e6db2..3561870 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,25 +1,14 @@ - BMI Calculator - Calculate BMI - Tinggi Badan - Berat Badan - Gunakan Unit USC (lbs/in)? - BMI Anda: %s - Kategori: %s + BMI Calculator + Age + Height (m/cm) + Height (in) + Weight (kg) + Weight (lbs) + SI Units + USC Units + Your BMI + Awaiting input 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..703cf7d --- /dev/null +++ b/app/src/test/java/com/example/tiptime/BmiCalculatorTest.kt @@ -0,0 +1,69 @@ +// NAMA: HADI PRAKOSO +// NPM: 202310715312 + +package com.example.tiptime + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test + +class BmiCalculatorTest { + + @Test + fun calculateBmi_metric_normal() { + val weight = 70.0 // kg + val height = 175.0 // cm + val result = calculateBmi(weight, height, useMetric = true) + + // Pastikan hasil tidak null sebelum melakukan assert pada nilainya + assertNotNull(result) + if (result != null) { + // BMI untuk 70kg dan 175cm adalah ~22.9 + assertEquals(22.9, result.bmi, 0.1) + assertEquals("Normal", result.category) + } + } + + @Test + fun calculateBmi_usc_overweight() { + val weight = 180.0 // lbs + val height = 68.0 // inches + val result = calculateBmi(weight, height, useMetric = false) + + assertNotNull(result) + if (result != null) { + assertEquals(27.4, result.bmi, 0.1) + assertEquals("Berat badan lebih", result.category) + } + } + + @Test + fun determineBmiCategory_underweight() { + val category = determineBmiCategory(18.4) + assertEquals("Berat badan kurang", category) + } + + @Test + fun determineBmiCategory_normal() { + val category = determineBmiCategory(22.0) + assertEquals("Normal", category) + } + + @Test + fun determineBmiCategory_overweight() { + val category = determineBmiCategory(27.0) + assertEquals("Berat badan lebih", category) + } + + @Test + fun determineBmiCategory_obesity() { + val category = determineBmiCategory(31.0) + assertEquals("Obesitas", category) + } + + @Test + fun calculateBmi_zeroHeight_returnsNull() { + val result = calculateBmi(70.0, 0.0, useMetric = true) + assertEquals(null, result) + } +} \ No newline at end of file