diff --git a/app/build.gradle b/app/build.gradle index f22e7497..14874732 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.androidApplication) alias(libs.plugins.kotlinAndroid) + alias(libs.plugins.kotlinParcelize) } android { @@ -27,8 +28,8 @@ android { sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } - kotlinOptions { - jvmTarget = '17' + kotlin { + jvmToolchain(17) } buildFeatures { viewBinding true diff --git a/app/src/main/java/otus/homework/customview/MainActivity.kt b/app/src/main/java/otus/homework/customview/MainActivity.kt index 78cb9448..fe61fe99 100644 --- a/app/src/main/java/otus/homework/customview/MainActivity.kt +++ b/app/src/main/java/otus/homework/customview/MainActivity.kt @@ -1,11 +1,33 @@ package otus.homework.customview -import androidx.appcompat.app.AppCompatActivity import android.os.Bundle +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import otus.homework.customview.data.Transaction +import otus.homework.customview.databinding.ActivityMainBinding class MainActivity : AppCompatActivity() { + + private lateinit var binding: ActivityMainBinding + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.activity_main) + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + + binding.pieChartView.setTransactions(parseTransactions()) + + binding.pieChartView.setOnCategoryClickListener { category -> + Toast.makeText(this, category.name, Toast.LENGTH_SHORT).show() + } + } + + private fun parseTransactions(): List { + val json = resources.openRawResource(R.raw.payload) + .bufferedReader().use { it.readText() } + val type = object : TypeToken>() {}.type + return Gson().fromJson(json, type) } } \ No newline at end of file diff --git a/app/src/main/java/otus/homework/customview/data/CategoryColor.kt b/app/src/main/java/otus/homework/customview/data/CategoryColor.kt new file mode 100644 index 00000000..575f4a0b --- /dev/null +++ b/app/src/main/java/otus/homework/customview/data/CategoryColor.kt @@ -0,0 +1,18 @@ +package otus.homework.customview.data + +import androidx.core.graphics.toColorInt + +enum class CategoryColor(val colorCode: Int) { + MAROON("#800000".toColorInt()), // ~red + SADDLE_BROWN("#804000".toColorInt()), // ~red - yellow (orange) + OLIVE("#808000".toColorInt()), // ~yellow + BILBAO("#408000".toColorInt()), // ~yellow - green (chartreuse) + GREEN("#008000".toColorInt()), // green + SALEM("#008040".toColorInt()), // ~green-blue (cyan) + TEAL("#008080".toColorInt()), // ~light blue + DARK_CERULEAN("#004080".toColorInt()), // ~medium blue + NAVY("#000080".toColorInt()), // ~dark blue + INDIGO("#400080".toColorInt()), // dark blue - violet (indigo) + PURPLE("#800080".toColorInt()), // ~violet + TYRIAN_PURPLE("#800040".toColorInt()), // ~violet - red (magenta) +} diff --git a/app/src/main/java/otus/homework/customview/data/CategorySlice.kt b/app/src/main/java/otus/homework/customview/data/CategorySlice.kt new file mode 100644 index 00000000..50fd1666 --- /dev/null +++ b/app/src/main/java/otus/homework/customview/data/CategorySlice.kt @@ -0,0 +1,14 @@ +package otus.homework.customview.data + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class CategorySlice( + val name: String, + val color: Int, + val amount: Int, + val percentage: Float, + val startAngle: Float, + val sweepAngle: Float, +) : Parcelable diff --git a/app/src/main/java/otus/homework/customview/data/Transaction.kt b/app/src/main/java/otus/homework/customview/data/Transaction.kt new file mode 100644 index 00000000..2c23ff61 --- /dev/null +++ b/app/src/main/java/otus/homework/customview/data/Transaction.kt @@ -0,0 +1,13 @@ +package otus.homework.customview.data + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class Transaction( + val id: Int, + val name: String, + val amount: Int, + val category: String, + val time: Long, +) : Parcelable diff --git a/app/src/main/java/otus/homework/customview/presentation/PieChartView.kt b/app/src/main/java/otus/homework/customview/presentation/PieChartView.kt new file mode 100644 index 00000000..3f2b26cf --- /dev/null +++ b/app/src/main/java/otus/homework/customview/presentation/PieChartView.kt @@ -0,0 +1,217 @@ +package otus.homework.customview.presentation + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.RectF +import android.os.Bundle +import android.os.Parcelable +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View +import androidx.core.graphics.withSave +import otus.homework.customview.data.CategoryColor +import otus.homework.customview.data.CategorySlice +import otus.homework.customview.data.Transaction +import kotlin.math.atan2 +import kotlin.math.sqrt + +class PieChartView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, +) : View(context, attrs) { + + private val slicePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + } + + private val separatorPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = 5f + color = Color.WHITE + } + + private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.BLACK + textAlign = Paint.Align.CENTER + textSize = 80f + } + + private var rectF = RectF() + private var centerX = 0f + private var centerY = 0f + private var radius = 0f + + private var transactions = listOf() + private var slices = mutableListOf() + private var selectedSlice: CategorySlice? = null + + private var onCategoryClickListener: ((CategorySlice) -> Unit)? = null + + fun setOnCategoryClickListener(listener: (CategorySlice) -> Unit) { + onCategoryClickListener = listener + } + + fun setTransactions(items: List) { + transactions = items + processData() + invalidate() + } + + private fun processData() { + slices.clear() + + if (transactions.isEmpty()) { + return + } + + // Group transactions by category and calculate totals + val groupedTransactions = transactions.groupBy { it.category } + .mapValues { entry -> entry.value.sumOf { transaction -> transaction.amount } } + + val totalAmount = groupedTransactions.values.sum().toFloat() + + // Start pie from top + var currentAngle = -90f + + // Create category pie slices with color based on category index + slices = groupedTransactions.entries + .sortedByDescending { it.value } + .mapIndexed { index, entry -> + val percentage = entry.value / totalAmount + val sweepAngle = percentage * 360f + currentAngle += sweepAngle + CategorySlice( + name = entry.key, + amount = entry.value, + percentage = percentage * 100, // Human-readable + color = CategoryColor.entries[index % CategoryColor.entries.size].colorCode, + startAngle = currentAngle - sweepAngle, + sweepAngle = sweepAngle, + ) + }.toMutableList() + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + + // Explicit width measurement example + val widthMode = MeasureSpec.getMode(widthMeasureSpec) + val widthSize = MeasureSpec.getSize(widthMeasureSpec) + + val desiredWidth = (resources.displayMetrics.density * 360).toInt() + + val width = when (widthMode) { + MeasureSpec.EXACTLY -> widthSize + MeasureSpec.AT_MOST -> minOf(desiredWidth, widthSize) + else -> desiredWidth // MeasureSpec.UNSPECIFIED + } + + // Simplified height measurement example + val minHeight = suggestedMinimumHeight + paddingTop + paddingBottom + + // Combined result + setMeasuredDimension(width, resolveSize(minHeight, heightMeasureSpec)) + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + + val padding = 24f + centerX = w / 2f + centerY = h / 2f + radius = (minOf(w, h) / 2f) - padding + + rectF.set( + centerX - radius, + centerY - radius, + centerX + radius, + centerY + radius + ) + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + + if (slices.isEmpty()) { + canvas.drawText("No data available", centerX, centerY, textPaint) + return + } + + slices.forEach { slice -> + if (slice == selectedSlice) slicePaint.color = Color.BLACK + else slicePaint.color = slice.color + canvas.withSave { + drawArc( + rectF, + slice.startAngle, + slice.sweepAngle, + true, + slicePaint + ) + drawArc( + rectF, + slice.startAngle, + slice.sweepAngle, + true, + separatorPaint + ) + } + } + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + if (event.action == MotionEvent.ACTION_DOWN) { + val slice = getSliceAtPoint(event.x, event.y) + if (slice != null) { + selectedSlice = slice + selectedSlice?.let { + onCategoryClickListener?.invoke(it) + invalidate() + } + } + return true + } + return super.onTouchEvent(event) + } + + private fun getSliceAtPoint(x: Float, y: Float): CategorySlice? { + val dx = x - centerX + val dy = y - centerY + val distance = sqrt(dx * dx + dy * dy) + + if (distance > radius) return null + + var angle = Math.toDegrees(atan2(dy.toDouble(), dx.toDouble())).toFloat() + if (angle < 0) angle += 360f + angle = (angle + 90) % 360 // Adjust for starting angle + + var currentAngle = 0f + slices.forEach { slice -> + if (angle >= currentAngle && angle < (currentAngle + slice.sweepAngle)) { + return slice + } + currentAngle += slice.sweepAngle + } + return null + } + + override fun onSaveInstanceState(): Parcelable { + return Bundle().apply { + putParcelable("superState", super.onSaveInstanceState()) + putParcelable("selectedSlice", selectedSlice) + putParcelableArrayList("transactions", ArrayList(transactions)) + } + } + + override fun onRestoreInstanceState(state: Parcelable?) { + if (state is Bundle) { + selectedSlice = state.getParcelable("selectedSlice") + val items = state.getParcelableArrayList("transactions") + items?.let { setTransactions(it) } + super.onRestoreInstanceState(state.getParcelable("superState")) + } else { + super.onRestoreInstanceState(state) + } + } +} diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 79ae6993..400f0ce1 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -8,12 +8,29 @@ tools:context=".MainActivity"> + + + app:layout_constraintDimensionRatio="1:1" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + tools:background="@color/black" /> \ No newline at end of file diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml index 8a06b665..bd47080e 100644 --- a/app/src/main/res/values-night/themes.xml +++ b/app/src/main/res/values-night/themes.xml @@ -1,6 +1,6 @@ -