From 0e383e7699c03426e7cf4c7affc8a13f7d848948 Mon Sep 17 00:00:00 2001 From: m_kolobanova Date: Sun, 19 Oct 2025 17:54:28 +0300 Subject: [PATCH 1/2] Add custom View PieChartView.kt. It show a cost by Category --- .../java/otus/homework/customview/ColorSet.kt | 15 ++ .../otus/homework/customview/MainActivity.kt | 41 +++- .../otus/homework/customview/PieChartData.kt | 3 + .../otus/homework/customview/PieChartView.kt | 182 ++++++++++++++++++ app/src/main/res/layout/activity_main.xml | 15 +- app/src/main/res/values/strings.xml | 1 + 6 files changed, 254 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/otus/homework/customview/ColorSet.kt create mode 100644 app/src/main/java/otus/homework/customview/PieChartData.kt create mode 100644 app/src/main/java/otus/homework/customview/PieChartView.kt diff --git a/app/src/main/java/otus/homework/customview/ColorSet.kt b/app/src/main/java/otus/homework/customview/ColorSet.kt new file mode 100644 index 00000000..887cdb5e --- /dev/null +++ b/app/src/main/java/otus/homework/customview/ColorSet.kt @@ -0,0 +1,15 @@ +package otus.homework.customview + +enum class ColorSet (val hexCode: String){ + PRIMARY("#123456"), + RED("#FFFF0000"), + GREEN("#00FF00"), + BLUE("#0000FF"), + YELLOW("#FFFF00"), + ORANGE("#FFA500"), + PURPLE("#800080"), + PINK("#FFC0CB"), + CYAN("#00FFFF"), + + +} \ No newline at end of file diff --git a/app/src/main/java/otus/homework/customview/MainActivity.kt b/app/src/main/java/otus/homework/customview/MainActivity.kt index 78cb9448..2cc6ba1c 100644 --- a/app/src/main/java/otus/homework/customview/MainActivity.kt +++ b/app/src/main/java/otus/homework/customview/MainActivity.kt @@ -1,11 +1,50 @@ package otus.homework.customview -import androidx.appcompat.app.AppCompatActivity +import android.content.Context import android.os.Bundle +import android.util.Log +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +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) + + val testPayData = parseJson(this, R.raw.payload) + for (item in testPayData) { + Log.d("MainActivity", item.toString()) + } + val categories = testPayData.groupBy { it.category } + Log.d("MainActivity", "countCategories: $categories") + val totalAmount = testPayData.sumOf { it.amount } + for (it in categories) { + val amount = it.value.sumOf { it.amount } + Log.d("MainActivity", "category: ${it.key}" + " amount: $amount") + } + + binding.pieChartView.setData(testPayData) + binding.pieChartView.setOnCategoryClickListener { category -> + { + Toast.makeText(this, category, Toast.LENGTH_LONG).show() + Log.d("MainActivity", "category1: $category") + } + } } + + private fun parseJson(context: Context, resId: Int): List { + context.resources.openRawResource(resId).use { inputStream -> + val json = inputStream.bufferedReader().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/PieChartData.kt b/app/src/main/java/otus/homework/customview/PieChartData.kt new file mode 100644 index 00000000..cd1e406c --- /dev/null +++ b/app/src/main/java/otus/homework/customview/PieChartData.kt @@ -0,0 +1,3 @@ +package otus.homework.customview + +data class PieChartData(val id: Int, val name: String, val amount: Int, val category: String, val time: Long) diff --git a/app/src/main/java/otus/homework/customview/PieChartView.kt b/app/src/main/java/otus/homework/customview/PieChartView.kt new file mode 100644 index 00000000..36a50d68 --- /dev/null +++ b/app/src/main/java/otus/homework/customview/PieChartView.kt @@ -0,0 +1,182 @@ +package otus.homework.customview + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Path +import android.graphics.RectF +import android.os.Bundle +import android.os.Parcelable +import android.util.AttributeSet +import android.util.Log +import android.view.MotionEvent +import android.view.View +import androidx.core.graphics.toColorInt +import kotlin.enums.EnumEntries +import kotlin.enums.enumEntries +import kotlin.math.atan2 +import kotlin.math.sqrt + +class PieChartView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : View(context, attrs) { + private var pieHeight: Int = 0 + private var pieWidth: Int = 0 + private var payChartData: List = emptyList() + private var totalAmount: Int = 0 + private var colorSet: EnumEntries = enumEntries() + private var pieElements: MutableList = mutableListOf() + + private var paint: Paint = Paint() + private val paintText: Paint = Paint() + private var rectf: RectF = RectF() + private var selectedCategory: String? = null + private var onCategoryClickListener: ((String) -> Unit)? = null + + + fun setOnCategoryClickListener(listener: (String) -> Unit) { + onCategoryClickListener = listener + } + + fun setData(data: List) { + payChartData = data + initPieElements() + } + + init { + paintText.color = Color.BLACK + paintText.textSize = 30f + paintText.textAlign = Paint.Align.CENTER + } + + private fun initPieElements() { + val categories = payChartData.groupBy { it.category } + totalAmount = payChartData.sumOf { it.amount } + var counter = 0; + var startAngle = 0f; + for (it in categories) { + val categoryAmount = it.value.sumOf { it.amount } + val partSize = categoryAmount / (totalAmount * 1f) + val angle = partSize * 360f; + pieElements.add(PieElement(colorSet.get(counter), startAngle, angle, it.key, categoryAmount)) + counter++; + startAngle = startAngle + angle; + } + } + + data class PieElement( + val color: ColorSet, + val startAngle: Float, + val angle: Float, + val category: String, + val amount: Int + ) + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + val midHeight = height / 2f + val midWidth = width / 2f + val radius = maxOf(midHeight, midWidth) / 2.0f + + rectf.set( + midWidth - radius, + midHeight - radius, + midWidth + radius, + midHeight + radius + ) + for (item in pieElements) { + drowPieElement(canvas, getPiePaint(paint, item), rectf, item) + if(item.category == selectedCategory){ + drowPieElement(canvas, getSelectPiePaint(), rectf, item) + canvas.drawText( "${item.category}: ${item.amount} руб", midWidth, midHeight - 1.7f*radius, paintText) + } + } + } + + fun getPiePaint(paint: Paint, pieElement: PieElement): Paint { + paint.reset() + + paint.color = pieElement.color.hexCode.toColorInt() + if(pieElement.category == selectedCategory){ + paint.alpha = 255 + } else { + paint.alpha = 127 + } + paint.style = Paint.Style.FILL_AND_STROKE + return paint + } + + fun getSelectPiePaint(): Paint { + paint.reset() + paint.style = Paint.Style.STROKE + paint.strokeWidth = 2f + paint.color = Color.BLACK + paint.strokeJoin = Paint.Join.BEVEL + return paint + } + + fun drowPieElement( + canvas: Canvas, + paint: Paint, + rect: RectF, + pieElement: PieElement + ) { + val path: Path = Path() + path.addArc(rect, pieElement.startAngle, pieElement.angle) + canvas.drawArc(rect, pieElement.startAngle, pieElement.angle, true, paint) + canvas.drawTextOnPath(pieElement.angle.toUInt().toString() + "%", path, 0f,0f, paintText) + invalidate() + + } + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean { + if (event.action == MotionEvent.ACTION_DOWN) { + val midHeight = height / 2f + val midWidth = width / 2f + val dx = event.x - midWidth + val dy = event.y - midHeight + val dist = sqrt(dx * dx + dy * dy) + + + val radius = maxOf(midHeight, midWidth) / 2.0f + val innerRadius = radius - radius / 2 + val outerRadius = radius + radius / 2 + + if (dist in innerRadius..outerRadius) { + val touchAngle = (Math.toDegrees(atan2(dy.toDouble(), dx.toDouble())) + 360) % 360 + val slice = pieElements.firstOrNull { + touchAngle in it.startAngle..(it.startAngle + it.angle) + } + slice?.let { + selectedCategory = it.category + onCategoryClickListener?.invoke(it.category) + Log.d("PieChartView", "category: $selectedCategory") + //Toast.makeText(context, it.category, Toast.LENGTH_SHORT).show() + invalidate() + } + } + } + return super.onTouchEvent(event) + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + } + + override fun onSaveInstanceState(): Parcelable { + return Bundle().apply { + putParcelable("super", super.onSaveInstanceState()) + putString("selectedCategory", selectedCategory) + } + } + + override fun onRestoreInstanceState(state: Parcelable?) { + (state as? Bundle)?.let { + selectedCategory = it.getString("selectedCategory", null) + super.onRestoreInstanceState(it.getParcelable("super")) + } + } +} diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 79ae6993..fcd82d4e 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -10,10 +10,21 @@ + + + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toEndOf="parent"/> \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9213c339..fd611009 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,4 @@ Custom View + Траты по категориям \ No newline at end of file From 0ea579c5198ec9d61468807b29294d3141e59809 Mon Sep 17 00:00:00 2001 From: m_kolobanova Date: Thu, 6 Nov 2025 13:22:23 +0300 Subject: [PATCH 2/2] Fix code review issues. Add onMeasure --- .../java/otus/homework/customview/ColorSet.kt | 5 +- .../otus/homework/customview/MainActivity.kt | 9 +- .../otus/homework/customview/PieChartView.kt | 97 +++++++++++++------ 3 files changed, 69 insertions(+), 42 deletions(-) diff --git a/app/src/main/java/otus/homework/customview/ColorSet.kt b/app/src/main/java/otus/homework/customview/ColorSet.kt index 887cdb5e..adc05678 100644 --- a/app/src/main/java/otus/homework/customview/ColorSet.kt +++ b/app/src/main/java/otus/homework/customview/ColorSet.kt @@ -1,6 +1,6 @@ package otus.homework.customview -enum class ColorSet (val hexCode: String){ +enum class ColorSet(val hexCode: String) { PRIMARY("#123456"), RED("#FFFF0000"), GREEN("#00FF00"), @@ -10,6 +10,5 @@ enum class ColorSet (val hexCode: String){ PURPLE("#800080"), PINK("#FFC0CB"), CYAN("#00FFFF"), - - + BROWN("#79553D") } \ No newline at end of file diff --git a/app/src/main/java/otus/homework/customview/MainActivity.kt b/app/src/main/java/otus/homework/customview/MainActivity.kt index 2cc6ba1c..68261c24 100644 --- a/app/src/main/java/otus/homework/customview/MainActivity.kt +++ b/app/src/main/java/otus/homework/customview/MainActivity.kt @@ -23,7 +23,6 @@ class MainActivity : AppCompatActivity() { } val categories = testPayData.groupBy { it.category } Log.d("MainActivity", "countCategories: $categories") - val totalAmount = testPayData.sumOf { it.amount } for (it in categories) { val amount = it.value.sumOf { it.amount } Log.d("MainActivity", "category: ${it.key}" + " amount: $amount") @@ -31,10 +30,8 @@ class MainActivity : AppCompatActivity() { binding.pieChartView.setData(testPayData) binding.pieChartView.setOnCategoryClickListener { category -> - { - Toast.makeText(this, category, Toast.LENGTH_LONG).show() - Log.d("MainActivity", "category1: $category") - } + Toast.makeText(this, category, Toast.LENGTH_SHORT).show() + Log.d("MainActivity", "category1: $category") } } @@ -45,6 +42,4 @@ class MainActivity : AppCompatActivity() { return Gson().fromJson(json, type) } } - - } \ No newline at end of file diff --git a/app/src/main/java/otus/homework/customview/PieChartView.kt b/app/src/main/java/otus/homework/customview/PieChartView.kt index 36a50d68..2647e9ff 100644 --- a/app/src/main/java/otus/homework/customview/PieChartView.kt +++ b/app/src/main/java/otus/homework/customview/PieChartView.kt @@ -23,8 +23,8 @@ class PieChartView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : View(context, attrs) { - private var pieHeight: Int = 0 - private var pieWidth: Int = 0 + private var pieHeight: Float = height / 2f + private var pieWidth: Float = width / 2f private var payChartData: List = emptyList() private var totalAmount: Int = 0 private var colorSet: EnumEntries = enumEntries() @@ -32,10 +32,10 @@ class PieChartView @JvmOverloads constructor( private var paint: Paint = Paint() private val paintText: Paint = Paint() - private var rectf: RectF = RectF() + private var rect: RectF = RectF() private var selectedCategory: String? = null private var onCategoryClickListener: ((String) -> Unit)? = null - + private val path: Path = Path() fun setOnCategoryClickListener(listener: (String) -> Unit) { onCategoryClickListener = listener @@ -55,15 +55,24 @@ class PieChartView @JvmOverloads constructor( private fun initPieElements() { val categories = payChartData.groupBy { it.category } totalAmount = payChartData.sumOf { it.amount } - var counter = 0; - var startAngle = 0f; + var counter = 0 + var startAngle = 0f + pieElements.clear() for (it in categories) { val categoryAmount = it.value.sumOf { it.amount } val partSize = categoryAmount / (totalAmount * 1f) - val angle = partSize * 360f; - pieElements.add(PieElement(colorSet.get(counter), startAngle, angle, it.key, categoryAmount)) - counter++; - startAngle = startAngle + angle; + val angle = partSize * 360f + pieElements.add( + PieElement( + colorSet[counter], + startAngle, + angle, + it.key, + categoryAmount + ) + ) + counter++ + startAngle += angle } } @@ -77,21 +86,24 @@ class PieChartView @JvmOverloads constructor( override fun onDraw(canvas: Canvas) { super.onDraw(canvas) - val midHeight = height / 2f - val midWidth = width / 2f - val radius = maxOf(midHeight, midWidth) / 2.0f - - rectf.set( - midWidth - radius, - midHeight - radius, - midWidth + radius, - midHeight + radius + val radius = maxOf(pieHeight, pieWidth) / 2.0f + + rect.set( + pieWidth - radius, + pieHeight - radius, + pieWidth + radius, + pieHeight + radius ) for (item in pieElements) { - drowPieElement(canvas, getPiePaint(paint, item), rectf, item) - if(item.category == selectedCategory){ - drowPieElement(canvas, getSelectPiePaint(), rectf, item) - canvas.drawText( "${item.category}: ${item.amount} руб", midWidth, midHeight - 1.7f*radius, paintText) + drawPieElement(canvas, getPiePaint(paint, item), rect, item) + if (item.category == selectedCategory) { + drawPieElement(canvas, getSelectPiePaint(), rect, item) + canvas.drawText( + "${item.category}: ${item.amount} руб", + pieWidth, + pieHeight - 1.7f * radius, + paintText + ) } } } @@ -100,7 +112,7 @@ class PieChartView @JvmOverloads constructor( paint.reset() paint.color = pieElement.color.hexCode.toColorInt() - if(pieElement.category == selectedCategory){ + if (pieElement.category == selectedCategory) { paint.alpha = 255 } else { paint.alpha = 127 @@ -118,19 +130,18 @@ class PieChartView @JvmOverloads constructor( return paint } - fun drowPieElement( + fun drawPieElement( canvas: Canvas, paint: Paint, rect: RectF, pieElement: PieElement ) { - val path: Path = Path() + path.reset() path.addArc(rect, pieElement.startAngle, pieElement.angle) canvas.drawArc(rect, pieElement.startAngle, pieElement.angle, true, paint) - canvas.drawTextOnPath(pieElement.angle.toUInt().toString() + "%", path, 0f,0f, paintText) - invalidate() - + canvas.drawTextOnPath(pieElement.angle.toUInt().toString() + "%", path, 0f, 0f, paintText) } + @SuppressLint("ClickableViewAccessibility") override fun onTouchEvent(event: MotionEvent): Boolean { if (event.action == MotionEvent.ACTION_DOWN) { @@ -140,7 +151,6 @@ class PieChartView @JvmOverloads constructor( val dy = event.y - midHeight val dist = sqrt(dx * dx + dy * dy) - val radius = maxOf(midHeight, midWidth) / 2.0f val innerRadius = radius - radius / 2 val outerRadius = radius + radius / 2 @@ -153,8 +163,7 @@ class PieChartView @JvmOverloads constructor( slice?.let { selectedCategory = it.category onCategoryClickListener?.invoke(it.category) - Log.d("PieChartView", "category: $selectedCategory") - //Toast.makeText(context, it.category, Toast.LENGTH_SHORT).show() + Log.d("${javaClass.name}", "category: $selectedCategory") invalidate() } } @@ -162,8 +171,32 @@ class PieChartView @JvmOverloads constructor( return super.onTouchEvent(event) } + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + pieHeight = height / 2f + pieWidth = width / 2f + } + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) + // 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) + MeasureSpec.UNSPECIFIED -> desiredWidth + else -> Log.w("${javaClass.name}", "Unknown MeasureSpec") + } + + // Simplified height measurement example + val minHeight = suggestedMinimumHeight + paddingTop + paddingBottom + + // Combined result + setMeasuredDimension(width, resolveSize(minHeight, heightMeasureSpec)) } override fun onSaveInstanceState(): Parcelable {