Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions app/build.gradle
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.kotlinAndroid)
alias(libs.plugins.kotlinParcelize)
}

android {
Expand All @@ -27,8 +28,8 @@ android {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
kotlin {
jvmToolchain(17)
}
buildFeatures {
viewBinding true
Expand Down
26 changes: 24 additions & 2 deletions app/src/main/java/otus/homework/customview/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -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<Transaction> {
val json = resources.openRawResource(R.raw.payload)
.bufferedReader().use { it.readText() }
val type = object : TypeToken<List<Transaction>>() {}.type
return Gson().fromJson(json, type)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Создается новый экземпляр Gson при каждом вызове. Gson потокобезопасен и может быть переиспользован. Если вынести в companion object, будет красиво

}
}
18 changes: 18 additions & 0 deletions app/src/main/java/otus/homework/customview/data/CategoryColor.kt
Original file line number Diff line number Diff line change
@@ -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)
}
14 changes: 14 additions & 0 deletions app/src/main/java/otus/homework/customview/data/CategorySlice.kt
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions app/src/main/java/otus/homework/customview/data/Transaction.kt
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Размер текста жестко задан и не масштабируется с размером View. Рекомендую вычислять его в onSizeChanged в зависимости от радиуса, например: textPaint.textSize = radius * 0.1f

}

private var rectF = RectF()
private var centerX = 0f
private var centerY = 0f
private var radius = 0f

private var transactions = listOf<Transaction>()
private var slices = mutableListOf<CategorySlice>()
private var selectedSlice: CategorySlice? = null

private var onCategoryClickListener: ((CategorySlice) -> Unit)? = null

fun setOnCategoryClickListener(listener: (CategorySlice) -> Unit) {
onCategoryClickListener = listener
}

fun setTransactions(items: List<Transaction>) {
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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Можно еще добавить проверку на totalAmount <= 0f перед делением, чтобы избежать некорректных углов при пустых данных


// 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Привет! Вы увеличиваете currentAngle до того, как вычисляется startAngle, из-за этого секторы будут перекрываться или располагаться неправильно. Нужно сначала сохранить текущий угол в startAngle, а потом увеличивать currentAngle

CategorySlice(
name = entry.key,
amount = entry.value,
percentage = percentage * 100, // Human-readable
color = CategoryColor.entries[index % CategoryColor.entries.size].colorCode,
startAngle = currentAngle - sweepAngle,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Здесь сохраненный startAngle

sweepAngle = sweepAngle,
)
}.toMutableList()
}

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Обращаю внимание на ТЗ: "Обязательно реализуйте метод onMeasure и учтите все возможные MeasureSpecs". Сейчас высота обрабатывается через resolveSize, что не учитывает все режимы MeasureSpec. Для кругового графика лучше явно обработать все три режима (EXACTLY, AT_MOST, UNSPECIFIED) и выбрать минимальный размер для сохранения квадратной формы.


// 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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Используется java.lang.Math, хотя уже импортирован kotlin.math.atan2. Для консистентности лучше использовать kotlin.math.toDegrees

var angle = 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<Transaction>("transactions")
items?.let { setTransactions(it) }
super.onRestoreInstanceState(state.getParcelable("superState"))
} else {
super.onRestoreInstanceState(state)
}
}
}
25 changes: 21 additions & 4 deletions app/src/main/res/layout/activity_main.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,29 @@
tools:context=".MainActivity">

<TextView
android:id="@+id/titleTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!"
android:layout_marginBottom="24dp"
android:text="Spending by Category"
android:textSize="24sp"
android:textStyle="bold"
app:layout_constraintBottom_toTopOf="@id/pieChartView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />

<otus.homework.customview.presentation.PieChartView
android:id="@+id/pieChartView"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginHorizontal="24dp"
android:layout_marginTop="72dp"
android:layout_marginBottom="48dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
app:layout_constraintDimensionRatio="1:1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:background="@color/black" />

</androidx.constraintlayout.widget.ConstraintLayout>
2 changes: 1 addition & 1 deletion app/src/main/res/values-night/themes.xml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.CustomView" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<style name="Theme.CustomView" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_200</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/res/values/themes.xml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.CustomView" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<style name="Theme.CustomView" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_500</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
Expand Down
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
plugins {
alias(libs.plugins.androidApplication) apply false
alias(libs.plugins.kotlinAndroid) apply false
alias(libs.plugins.kotlinParcelize) apply false
alias(libs.plugins.androidLibrary) apply false
}
Loading