commit 4ca53cf34ee90a8d0e6de8ebaa5d005b93950a7e Author: Heller Date: Thu Jun 18 23:45:39 2026 +0000 feat: добавил калькулятор на Jetpack Compose с Material 3 Реализовал Android-приложение с ViewModel, базовой арифметикой и обработкой деления на ноль. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7833135 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +*.iml +.gradle +/local.properties +/.idea/ +.DS_Store +/build +/app/build +/captures +.externalNativeBuild +.cxx +local.properties +*.apk +*.ap_ +*.aab +*.dex +*.class +bin/ +gen/ +out/ +release/ diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..106b113 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,72 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") +} + +android { + namespace = "com.heller.calculator" + compileSdk = 34 + + defaultConfig { + applicationId = "com.heller.calculator" + minSdk = 24 + targetSdk = 34 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + compose = true + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + val composeBom = platform("androidx.compose:compose-bom:2024.10.00") + + implementation("androidx.core:core-ktx:1.13.1") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.6") + implementation("androidx.activity:activity-compose:1.9.3") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") + + implementation(composeBom) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-graphics") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + + debugImplementation("androidx.compose.ui:ui-tooling") + debugImplementation("androidx.compose.ui:ui-test-manifest") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..bc19667 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,3 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle.kts. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..e968527 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + diff --git a/app/src/main/java/com/heller/calculator/CalculatorScreen.kt b/app/src/main/java/com/heller/calculator/CalculatorScreen.kt new file mode 100644 index 0000000..97f0d34 --- /dev/null +++ b/app/src/main/java/com/heller/calculator/CalculatorScreen.kt @@ -0,0 +1,153 @@ +package com.heller.calculator + +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.aspectRatio +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.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.heller.calculator.ui.components.CalculatorButton +import com.heller.calculator.ui.components.CalculatorButtonType + +@Composable +fun CalculatorScreen(viewModel: CalculatorViewModel) { + val uiState by viewModel.uiState.collectAsState() + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(uiState.error) { + uiState.error?.let { message -> + snackbarHostState.showSnackbar(message) + } + } + + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { innerPadding -> + Surface( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + color = MaterialTheme.colorScheme.background, + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.SpaceBetween, + ) { + DisplaySection(display = uiState.display) + ButtonGrid(viewModel = viewModel) + } + } + } +} + +@Composable +private fun DisplaySection(display: String) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 24.dp), + horizontalAlignment = Alignment.End, + ) { + Text( + text = display, + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.End, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +private fun ButtonGrid(viewModel: CalculatorViewModel) { + val rows = listOf( + listOf("C" to CalculatorButtonType.Clear, "÷" to CalculatorButtonType.Operator), + listOf("7" to CalculatorButtonType.Digit, "8" to CalculatorButtonType.Digit, "9" to CalculatorButtonType.Digit, "×" to CalculatorButtonType.Operator), + listOf("4" to CalculatorButtonType.Digit, "5" to CalculatorButtonType.Digit, "6" to CalculatorButtonType.Digit, "−" to CalculatorButtonType.Operator), + listOf("1" to CalculatorButtonType.Digit, "2" to CalculatorButtonType.Digit, "3" to CalculatorButtonType.Digit, "+" to CalculatorButtonType.Operator), + listOf("0" to CalculatorButtonType.Digit, "." to CalculatorButtonType.Digit, "=" to CalculatorButtonType.Equals), + ) + + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + CalculatorButton( + label = "C", + type = CalculatorButtonType.Clear, + onClick = { viewModel.onClear() }, + modifier = Modifier + .weight(1f) + .aspectRatio(1f), + ) + Spacer(modifier = Modifier.weight(2f)) + CalculatorButton( + label = "÷", + type = CalculatorButtonType.Operator, + onClick = { viewModel.onOperation(CalculatorOperation.Divide) }, + modifier = Modifier + .weight(1f) + .aspectRatio(1f), + ) + } + + rows.drop(1).forEach { row -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + row.forEach { (label, type) -> + val weight = if (label == "0") 2f else 1f + CalculatorButton( + label = label, + type = type, + onClick = { handleButtonClick(label, viewModel) }, + modifier = Modifier + .weight(weight) + .aspectRatio(if (label == "0") 2.15f else 1f), + ) + } + } + } + Spacer(modifier = Modifier.height(8.dp)) + } +} + +private fun handleButtonClick(label: String, viewModel: CalculatorViewModel) { + when (label) { + "C" -> viewModel.onClear() + "=" -> viewModel.onEquals() + "." -> viewModel.onDecimalPoint() + "+" -> viewModel.onOperation(CalculatorOperation.Add) + "−" -> viewModel.onOperation(CalculatorOperation.Subtract) + "×" -> viewModel.onOperation(CalculatorOperation.Multiply) + "÷" -> viewModel.onOperation(CalculatorOperation.Divide) + else -> label.toIntOrNull()?.let { viewModel.onDigit(it) } + } +} diff --git a/app/src/main/java/com/heller/calculator/CalculatorState.kt b/app/src/main/java/com/heller/calculator/CalculatorState.kt new file mode 100644 index 0000000..17de912 --- /dev/null +++ b/app/src/main/java/com/heller/calculator/CalculatorState.kt @@ -0,0 +1,20 @@ +package com.heller.calculator + +enum class CalculatorOperation(val symbol: String) { + Add("+"), + Subtract("-"), + Multiply("*"), + Divide("/"); + + fun apply(a: Double, b: Double): Double = when (this) { + Add -> a + b + Subtract -> a - b + Multiply -> a * b + Divide -> a / b + } +} + +data class CalculatorUiState( + val display: String = "0", + val error: String? = null, +) diff --git a/app/src/main/java/com/heller/calculator/CalculatorViewModel.kt b/app/src/main/java/com/heller/calculator/CalculatorViewModel.kt new file mode 100644 index 0000000..8048d8b --- /dev/null +++ b/app/src/main/java/com/heller/calculator/CalculatorViewModel.kt @@ -0,0 +1,111 @@ +package com.heller.calculator + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import java.math.BigDecimal +import java.math.RoundingMode + +class CalculatorViewModel : ViewModel() { + + private var storedValue: Double? = null + private var pendingOperation: CalculatorOperation? = null + private var waitingForOperand = false + + private val _uiState = MutableStateFlow(CalculatorUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun onDigit(digit: Int) { + clearErrorIfNeeded() + val current = if (waitingForOperand) { + waitingForOperand = false + "" + } else { + val display = _uiState.value.display + if (display == "0" && !display.contains(".")) "" else display + } + + updateDisplay(current + digit.toString()) + } + + fun onDecimalPoint() { + clearErrorIfNeeded() + if (waitingForOperand) { + waitingForOperand = false + updateDisplay("0.") + return + } + if (!_uiState.value.display.contains(".")) { + updateDisplay(_uiState.value.display + ".") + } + } + + fun onOperation(operation: CalculatorOperation) { + clearErrorIfNeeded() + val currentValue = _uiState.value.display.toDoubleOrNull() ?: return + + if (storedValue != null && pendingOperation != null && !waitingForOperand) { + val result = compute(storedValue!!, pendingOperation!!, currentValue) + if (result == null) return + storedValue = result + updateDisplay(formatResult(result)) + } else { + storedValue = currentValue + } + + pendingOperation = operation + waitingForOperand = true + } + + fun onEquals() { + clearErrorIfNeeded() + val operation = pendingOperation ?: return + val first = storedValue ?: return + val second = _uiState.value.display.toDoubleOrNull() ?: return + + val result = compute(first, operation, second) ?: return + storedValue = null + pendingOperation = null + waitingForOperand = true + updateDisplay(formatResult(result)) + } + + fun onClear() { + storedValue = null + pendingOperation = null + waitingForOperand = false + _uiState.value = CalculatorUiState() + } + + private fun compute(a: Double, operation: CalculatorOperation, b: Double): Double? { + if (operation == CalculatorOperation.Divide && b == 0.0) { + storedValue = null + pendingOperation = null + waitingForOperand = false + _uiState.value = CalculatorUiState( + display = "0", + error = "Cannot divide by zero", + ) + return null + } + + return operation.apply(a, b) + } + + private fun formatResult(value: Double): String { + val bd = BigDecimal.valueOf(value).setScale(10, RoundingMode.HALF_UP).stripTrailingZeros() + return bd.toPlainString() + } + + private fun clearErrorIfNeeded() { + _uiState.update { state -> + if (state.error != null) state.copy(error = null) else state + } + } + + private fun updateDisplay(display: String) { + _uiState.update { it.copy(display = display) } + } +} diff --git a/app/src/main/java/com/heller/calculator/MainActivity.kt b/app/src/main/java/com/heller/calculator/MainActivity.kt new file mode 100644 index 0000000..0eb9247 --- /dev/null +++ b/app/src/main/java/com/heller/calculator/MainActivity.kt @@ -0,0 +1,21 @@ +package com.heller.calculator + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.lifecycle.viewmodel.compose.viewModel +import com.heller.calculator.ui.theme.CalculatorComposeTheme + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + CalculatorComposeTheme { + val viewModel: CalculatorViewModel = viewModel() + CalculatorScreen(viewModel = viewModel) + } + } + } +} diff --git a/app/src/main/java/com/heller/calculator/ui/components/CalculatorButton.kt b/app/src/main/java/com/heller/calculator/ui/components/CalculatorButton.kt new file mode 100644 index 0000000..af046ed --- /dev/null +++ b/app/src/main/java/com/heller/calculator/ui/components/CalculatorButton.kt @@ -0,0 +1,65 @@ +package com.heller.calculator.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +enum class CalculatorButtonType { + Digit, + Operator, + Equals, + Clear, +} + +@Composable +fun CalculatorButton( + label: String, + type: CalculatorButtonType, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val containerColor = when (type) { + CalculatorButtonType.Digit -> MaterialTheme.colorScheme.surfaceVariant + CalculatorButtonType.Operator -> MaterialTheme.colorScheme.primaryContainer + CalculatorButtonType.Equals -> MaterialTheme.colorScheme.primary + CalculatorButtonType.Clear -> MaterialTheme.colorScheme.errorContainer + } + + val contentColor = when (type) { + CalculatorButtonType.Digit -> MaterialTheme.colorScheme.onSurfaceVariant + CalculatorButtonType.Operator -> MaterialTheme.colorScheme.onPrimaryContainer + CalculatorButtonType.Equals -> MaterialTheme.colorScheme.onPrimary + CalculatorButtonType.Clear -> MaterialTheme.colorScheme.onErrorContainer + } + + Button( + onClick = onClick, + modifier = modifier, + shape = RoundedCornerShape(24.dp), + colors = ButtonDefaults.buttonColors( + containerColor = containerColor, + contentColor = contentColor, + ), + elevation = ButtonDefaults.buttonElevation(defaultElevation = 2.dp), + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + fontSize = 24.sp, + style = MaterialTheme.typography.bodyLarge, + ) + } + } +} diff --git a/app/src/main/java/com/heller/calculator/ui/theme/Color.kt b/app/src/main/java/com/heller/calculator/ui/theme/Color.kt new file mode 100644 index 0000000..7c6d4ef --- /dev/null +++ b/app/src/main/java/com/heller/calculator/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package com.heller.calculator.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6750A4) +val PurpleGrey40 = Color(0xFF625B71) +val Pink40 = Color(0xFF7D5260) diff --git a/app/src/main/java/com/heller/calculator/ui/theme/Theme.kt b/app/src/main/java/com/heller/calculator/ui/theme/Theme.kt new file mode 100644 index 0000000..ba91510 --- /dev/null +++ b/app/src/main/java/com/heller/calculator/ui/theme/Theme.kt @@ -0,0 +1,60 @@ +package com.heller.calculator.ui.theme + +import android.app.Activity +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.runtime.SideEffect +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80, +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40, +) + +@Composable +fun CalculatorComposeTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = true, + 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 view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.surface.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme + } + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content, + ) +} diff --git a/app/src/main/java/com/heller/calculator/ui/theme/Type.kt b/app/src/main/java/com/heller/calculator/ui/theme/Type.kt new file mode 100644 index 0000000..e9179cf --- /dev/null +++ b/app/src/main/java/com/heller/calculator/ui/theme/Type.kt @@ -0,0 +1,24 @@ +package com.heller.calculator.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 + +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp, + ), + headlineLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Light, + fontSize = 48.sp, + lineHeight = 56.sp, + letterSpacing = 0.sp, + ), +) 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..a365abf --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..6d9618c --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..8f0f342 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #6750A4 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..91b584c --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Calculator + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..27a2e38 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,4 @@ + + +