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..adc05678 --- /dev/null +++ b/app/src/main/java/otus/homework/customview/ColorSet.kt @@ -0,0 +1,14 @@ +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"), + 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 78cb9448..68261c24 100644 --- a/app/src/main/java/otus/homework/customview/MainActivity.kt +++ b/app/src/main/java/otus/homework/customview/MainActivity.kt @@ -1,11 +1,45 @@ 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") + 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_SHORT).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..2647e9ff --- /dev/null +++ b/app/src/main/java/otus/homework/customview/PieChartView.kt @@ -0,0 +1,215 @@ +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: Float = height / 2f + private var pieWidth: Float = width / 2f + 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 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 + } + + 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 + 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[counter], + startAngle, + angle, + it.key, + categoryAmount + ) + ) + counter++ + 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 radius = maxOf(pieHeight, pieWidth) / 2.0f + + rect.set( + pieWidth - radius, + pieHeight - radius, + pieWidth + radius, + pieHeight + radius + ) + for (item in pieElements) { + 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 + ) + } + } + } + + 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 drawPieElement( + canvas: Canvas, + paint: Paint, + rect: RectF, + pieElement: PieElement + ) { + 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) + } + + @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("${javaClass.name}", "category: $selectedCategory") + invalidate() + } + } + } + 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 { + 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