feat: добавил калькулятор на Jetpack Compose с Material 3

Реализовал Android-приложение с ViewModel, базовой арифметикой и обработкой деления на ноль.
This commit is contained in:
Heller
2026-06-18 23:45:39 +00:00
commit 4ca53cf34e
26 changed files with 1004 additions and 0 deletions

72
app/build.gradle.kts Normal file
View File

@@ -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")
}

3
app/proguard-rules.pro vendored Normal file
View File

@@ -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.

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@drawable/ic_launcher"
android:supportsRtl="true"
android:theme="@style/Theme.CalculatorCompose">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.CalculatorCompose">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -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) }
}
}

View File

@@ -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,
)

View File

@@ -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<CalculatorUiState> = _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) }
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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,
)
}
}
}

View File

@@ -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)

View File

@@ -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,
)
}

View File

@@ -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,
),
)

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="#6750A4"
android:pathData="M0,0h48v48h-48z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M14,12h6v24h-6zM28,12h6v24h-6zM14,22h20v4h-20z" />
</vector>

View File

@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#6750A4"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M32,28h12v52h-12zM64,28h12v52h-12zM32,48h44v12h-44z" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#6750A4</color>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Calculator</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.CalculatorCompose" parent="android:Theme.Material.Light.NoActionBar" />
</resources>