diff --git a/app/build.gradle b/app/build.gradle index f22e7497..c7b19cd0 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.androidApplication) alias(libs.plugins.kotlinAndroid) + id 'kotlin-parcelize' } android { diff --git a/app/src/main/java/otus/homework/customview/MainActivity.kt b/app/src/main/java/otus/homework/customview/MainActivity.kt index 78cb9448..0aba3073 100644 --- a/app/src/main/java/otus/homework/customview/MainActivity.kt +++ b/app/src/main/java/otus/homework/customview/MainActivity.kt @@ -1,11 +1,44 @@ package otus.homework.customview -import androidx.appcompat.app.AppCompatActivity import android.os.Bundle +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import org.json.JSONArray class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) + + val pieChart = findViewById(R.id.pieChart) + + pieChart.setOnSectorClickListener { category -> + Toast.makeText(this, category, Toast.LENGTH_SHORT).show() + } + + val entries = loadEntriesFromRaw(R.raw.payload_7) + pieChart.setData(entries) + } + + private fun loadEntriesFromRaw(rawResId: Int): List { + val json = resources.openRawResource(rawResId).bufferedReader().use { it.readText() } + val array = JSONArray(json) + val result = ArrayList(array.length()) + + for (i in 0 until array.length()) { + val obj = array.getJSONObject(i) + val amount = obj.optLong("amount", 0L) + if (amount <= 0L) continue + + result.add( + ViraPieChartView.Entry( + name = obj.optString("name", ""), + category = obj.optString("category", ""), + time = obj.optLong("time", 0L), + amount = amount, + ) + ) + } + return result } } \ No newline at end of file diff --git a/app/src/main/java/otus/homework/customview/ViewUtils.kt b/app/src/main/java/otus/homework/customview/ViewUtils.kt new file mode 100644 index 00000000..8d99d9b6 --- /dev/null +++ b/app/src/main/java/otus/homework/customview/ViewUtils.kt @@ -0,0 +1,22 @@ +package otus.homework.customview + +import android.content.Context +import android.util.TypedValue + +object ViewUtils { + fun dp(context: Context, value: Float): Int { + return TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + value, + context.resources.displayMetrics, + ).toInt() + } + + fun sp(context: Context, value: Float): Float { + return TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_SP, + value, + context.resources.displayMetrics, + ) + } +} diff --git a/app/src/main/java/otus/homework/customview/ViraPieChartView.kt b/app/src/main/java/otus/homework/customview/ViraPieChartView.kt new file mode 100644 index 00000000..314c2caf --- /dev/null +++ b/app/src/main/java/otus/homework/customview/ViraPieChartView.kt @@ -0,0 +1,479 @@ +package otus.homework.customview + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.RectF +import android.os.Parcelable +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View +import kotlinx.parcelize.Parcelize +import java.text.NumberFormat +import kotlin.math.* + +class ViraPieChartView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : View(context, attrs, defStyleAttr) { + + private companion object { + private const val ERROR_DATA_NOT_SET = + "ViraPieChartView: data is not set. Provide data via setData(entries)." + } + + enum class ColorMode { MONO, COLOR } + enum class TextMode { NONE, INNER, OUTER } + enum class WidthMode { CONST, VAR } + + data class Entry( + val name: String, + val category: String, + val time: Long, + val amount: Long, + ) + + fun interface OnSectorClickListener { + fun onSectorClick(category: String) + } + + private data class Slice( + val name: String, + val category: String, + val time: Long, + val amount: Long, + val percent: Float, + // Геометрическая информация для кликов (заполняется в onDraw) + var startAngle: Float = 0f, + var sweepAngle: Float = 0f, + var innerRadius: Float = 0f, + var outerRadius: Float = 0f, + var centerX: Float = 0f, + var centerY: Float = 0f, + ) + + private val arcPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeCap = Paint.Cap.BUTT + } + private val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.BLACK + textAlign = Paint.Align.CENTER + textSize = ViewUtils.sp(context, 12f) + } + private val centerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.BLACK + textAlign = Paint.Align.CENTER + textSize = ViewUtils.sp(context, 18f) + isFakeBoldText = true + } + private val centerTitlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.BLACK + textAlign = Paint.Align.CENTER + textSize = ViewUtils.sp(context, 14f) + } + + private val arcBounds = RectF() + + private var colorMode: ColorMode = ColorMode.COLOR + private var textMode: TextMode = TextMode.OUTER + private var widthMode: WidthMode = WidthMode.CONST + + private var titleText: String = "Title" + + private var slices: List = emptyList() + private var totalAmount: Long = 0L + + private var onSectorClickListener: OnSectorClickListener? = null + private var pressedSlice: Slice? = null + + // Кешируемые измерения для производительности + private var cachedOuterLabelReserve: Float = 0f + + // Кешируемые font metrics для отрисовки текста + private var labelFontHeight: Float = 0f + private var centerTitleFontHeight: Float = 0f + private var centerFontHeight: Float = 0f + + private val palette = intArrayOf( + 0xFF8DD3C7.toInt(), 0xFFFFFFB3.toInt(), 0xFFBEBADA.toInt(), 0xFFFB8072.toInt(), + 0xFF80B1D3.toInt(), 0xFFFDB462.toInt(), 0xFFB3DE69.toInt(), 0xFFFCCDE5.toInt(), + 0xFFD9D9D9.toInt(), 0xFFBC80BD.toInt(), 0xFFCCEBC5.toInt(), 0xFFFFED6F.toInt() + ) + + private fun updateCacheValues() { + cachedOuterLabelReserve = if (textMode == TextMode.OUTER) { + ViewUtils.dp(context, 16f).toFloat() + labelPaint.textSize + } else { + 0f + } + + labelFontHeight = labelPaint.fontMetrics.run { descent - ascent } + centerTitleFontHeight = centerTitlePaint.fontMetrics.run { descent - ascent } + centerFontHeight = centerPaint.fontMetrics.run { descent - ascent } + } + + init { + isSaveEnabled = true + if (attrs != null) { + val a = context.obtainStyledAttributes(attrs, R.styleable.ViraPieChartView, defStyleAttr, 0) + try { + colorMode = when (a.getInt(R.styleable.ViraPieChartView_colorMode, 1)) { + 0 -> ColorMode.MONO + else -> ColorMode.COLOR + } + textMode = when (a.getInt(R.styleable.ViraPieChartView_textMode, 2)) { + 0 -> TextMode.NONE + 1 -> TextMode.INNER + else -> TextMode.OUTER + } + widthMode = when (a.getInt(R.styleable.ViraPieChartView_widthMode, 0)) { + 1 -> WidthMode.VAR + else -> WidthMode.CONST + } + + titleText = a.getString(R.styleable.ViraPieChartView_titleText) ?: titleText + } finally { + a.recycle() + } + } + + if (isInEditMode) { + val (previewSlices, previewTotal) = buildPreviewData() + setSlices(previewSlices, previewTotal) + } + + updateCacheValues() + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val wMode = MeasureSpec.getMode(widthMeasureSpec) + val wSize = MeasureSpec.getSize(widthMeasureSpec) + val hMode = MeasureSpec.getMode(heightMeasureSpec) + val hSize = MeasureSpec.getSize(heightMeasureSpec) + + val width = when (wMode) { + MeasureSpec.EXACTLY -> wSize + MeasureSpec.AT_MOST -> wSize + else -> wSize // UNSPECIFIED - используем размер окна + } + + val height = when (hMode) { + MeasureSpec.EXACTLY -> hSize + MeasureSpec.AT_MOST -> { + val squareHeight = width - paddingLeft - paddingRight + min(squareHeight.coerceAtLeast(0), hSize) + } + + else -> width - paddingLeft - paddingRight + } + + setMeasuredDimension(width, height) + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + if (!isInEditMode && slices.isEmpty()) { + // Данные должны прийти извне через setData(...) + throw IllegalStateException(ERROR_DATA_NOT_SET) + } + if (slices.isEmpty() || totalAmount <= 0) { + return + } + + val contentLeft = paddingLeft.toFloat() + val contentTop = paddingTop.toFloat() + val contentRight = (width - paddingRight).toFloat() + val contentBottom = (height - paddingBottom).toFloat() + val contentW = (contentRight - contentLeft).coerceAtLeast(0f) + val contentH = (contentBottom - contentTop).coerceAtLeast(0f) + val diameter = min(contentW, contentH) + + val cx = contentLeft + contentW / 2f + val cy = contentTop + contentH / 2f + val outerLabelReserve = cachedOuterLabelReserve + + val availableOuterEdgeRadius = (diameter / 2f - outerLabelReserve).coerceAtLeast(0f) + val baseThickness = availableOuterEdgeRadius / 3f + if (baseThickness <= 0f) return + + val ringCenterRadius = (availableOuterEdgeRadius - baseThickness / 2f).coerceAtLeast(0f) + arcBounds.set( + cx - ringCenterRadius, + cy - ringCenterRadius, + cx + ringCenterRadius, + cy + ringCenterRadius, + ) + + slices.forEach { + it.apply { + startAngle = 0f + sweepAngle = 0f + innerRadius = 0f + outerRadius = 0f + centerX = 0f + centerY = 0f + } + } + + var startAngle = -90f + slices = slices.mapIndexed { index, slice -> + val thicknessMultiplier = if (widthMode == WidthMode.VAR && index % 2 == 1) 0.75f else 1f + val thickness = baseThickness * thicknessMultiplier + val halfStroke = thickness / 2f + + arcPaint.strokeWidth = thickness + arcPaint.color = sliceColor(index, slices.size) + val sweep = 360f * (slice.amount.toFloat() / totalAmount.toFloat()) + canvas.drawArc(arcBounds, startAngle, sweep, false, arcPaint) + + slice.copy( + startAngle = startAngle, + sweepAngle = sweep, + innerRadius = ringCenterRadius - halfStroke, + outerRadius = ringCenterRadius + halfStroke, + centerX = cx, + centerY = cy + ).also { updatedSlice -> + if (textMode != TextMode.NONE) { + val outerEdgeRadius = ringCenterRadius + halfStroke + drawLabel(canvas, cx, cy, ringCenterRadius, outerEdgeRadius, startAngle, sweep, updatedSlice) + } + startAngle += sweep + } + } + + drawCenterTotal(canvas, cx, cy) + } + + private fun drawCenterTotal(canvas: Canvas, cx: Float, cy: Float) { + val title = titleText + val totalText = NumberFormat.getIntegerInstance().format(totalAmount) + + val spacing = ViewUtils.dp(context, 4f) + val blockHeight = centerTitleFontHeight + spacing + centerFontHeight + val blockTop = cy - blockHeight / 2f + + val titleBaseline = blockTop - centerTitlePaint.fontMetrics.ascent + val totalBaseline = titleBaseline + centerTitleFontHeight + spacing - centerPaint.fontMetrics.ascent + + canvas.drawText(title, cx, titleBaseline, centerTitlePaint) + canvas.drawText(totalText, cx, totalBaseline, centerPaint) + } + + private fun drawLabel( + canvas: Canvas, + cx: Float, + cy: Float, + ringCenterRadius: Float, + outerEdgeRadius: Float, + startAngle: Float, + sweepAngle: Float, + slice: Slice, + ) { + val percentText = "%.1f%%".format(slice.percent) + + val midAngleRad = Math.toRadians((startAngle + sweepAngle / 2f).toDouble()) + + val labelRadius = when (textMode) { + TextMode.INNER -> ringCenterRadius + TextMode.OUTER -> outerEdgeRadius + ViewUtils.dp(context, 16f).toFloat() + TextMode.NONE -> return + } + + val x = (cx + cos(midAngleRad) * labelRadius).toFloat() + val y = (cy + sin(midAngleRad) * labelRadius).toFloat() + + canvas.drawText(percentText, x, y - (labelPaint.fontMetrics.ascent + labelPaint.fontMetrics.descent) / 2f, labelPaint) + } + + private fun sliceColor(index: Int, sliceCount: Int): Int { + return when (colorMode) { + ColorMode.MONO -> monoColor(index, sliceCount) + ColorMode.COLOR -> { + val n = sliceCount.coerceAtLeast(1) + val idx = floor((index + 0.5) * palette.size.toDouble() / n.toDouble()).toInt() + palette[idx.coerceIn(0, palette.lastIndex)] + } + } + } + + private fun monoColor(index: Int, sliceCount: Int): Int { + val n = sliceCount.coerceAtLeast(1) + val evenCount = (n + 1) / 2 + val pos = if (index % 2 == 0) index / 2 else evenCount + (index - 1) / 2 + + val start = 0.95 + val end = 0.05 + val t = if (n == 1) 0.0 else pos.toDouble() / (n - 1).toDouble() + val level = (start + (end - start) * t).coerceIn(end, start) + val gray = (255.0 * level).roundToInt().coerceIn(0, 255) + return Color.rgb(gray, gray, gray) + } + + private fun buildPreviewData(): Pair, Long> { + val items = listOf( + Slice(name = "A", category = "C1", time = 0L, amount = 274, percent = 0f), + Slice(name = "B", category = "C2", time = 0L, amount = 189, percent = 0f), + Slice(name = "C", category = "C3", time = 0L, amount = 356, percent = 0f), + Slice(name = "D", category = "C4", time = 0L, amount = 181, percent = 0f), + ) + val total = items.sumOf { it.amount } + return items.withPercent(total) to total + } + + + private fun List.withPercent(total: Long): List { + if (total <= 0L) return this + return map { slice -> + val percent = slice.amount.toDouble() * 100.0 / total.toDouble() + slice.copy(percent = percent.toFloat()) + } + } + + private fun setSlices(slices: List, total: Long) { + this.slices = slices + this.totalAmount = total + invalidate() + } + + fun setData(entries: List) { + setDataInternal(entries) + } + + private fun setDataInternal(entries: List) { + val filtered = entries.filter { it.amount > 0L } + val total = filtered.sumOf { it.amount } + if (total <= 0L) { + setSlices(emptyList(), 0L) + return + } + + val sliceList = filtered + .sortedByDescending { it.amount } + .map { + Slice( + name = it.name, + category = it.category, + time = it.time, + amount = it.amount, + percent = 0f, + ) + } + .withPercent(total) + + setSlices(sliceList, total) + } + + fun setOnSectorClickListener(listener: OnSectorClickListener?) { + onSectorClickListener = listener + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + when (event.action) { + MotionEvent.ACTION_DOWN -> { + val x = event.x + val y = event.y + + for (slice in slices) { + if (isPointInSector(x, y, slice)) { + pressedSlice = slice + return true + } + } + pressedSlice = null + return false + } + + MotionEvent.ACTION_UP -> { + val slice = pressedSlice + pressedSlice = null + + if (slice != null && isPointInSector(event.x, event.y, slice)) { + performClick() + onSectorClickListener?.onSectorClick(slice.category) + return true + } + return false + } + + MotionEvent.ACTION_CANCEL -> { + pressedSlice = null + return false + } + } + return super.onTouchEvent(event) + } + + override fun performClick(): Boolean { + super.performClick() + return true + } + + private fun isPointInSector(x: Float, y: Float, slice: Slice): Boolean { + val dx = x - slice.centerX + val dy = y - slice.centerY + val distance = sqrt(dx.pow(2) + dy.pow(2)) + + if (distance < slice.innerRadius || distance > slice.outerRadius) { + return false + } + + val angleRad = atan2(dy, dx) + var angleDeg = Math.toDegrees(angleRad.toDouble()).toFloat() + if (angleDeg < 0) angleDeg += 360f + + var adjustedAngle = angleDeg + 90f + if (adjustedAngle >= 360f) adjustedAngle -= 360f + + val startAngle = slice.startAngle + 90f + val endAngle = startAngle + slice.sweepAngle + + return if (startAngle <= endAngle) { + adjustedAngle in startAngle..endAngle + } else { + adjustedAngle >= startAngle || adjustedAngle <= endAngle + } + } + + override fun onSaveInstanceState(): Parcelable { + return PieSavedState( + superState = super.onSaveInstanceState(), + colorModeName = colorMode.name, + textModeName = textMode.name, + widthModeName = widthMode.name, + titleText = titleText, + ) + } + + override fun onRestoreInstanceState(state: Parcelable?) { + if (state !is PieSavedState) { + super.onRestoreInstanceState(state) + return + } + + super.onRestoreInstanceState(state.superState) + + colorMode = state.colorModeName?.let { runCatching { ColorMode.valueOf(it) }.getOrNull() } ?: colorMode + textMode = state.textModeName?.let { runCatching { TextMode.valueOf(it) }.getOrNull() } ?: textMode + widthMode = state.widthModeName?.let { runCatching { WidthMode.valueOf(it) }.getOrNull() } ?: widthMode + titleText = state.titleText ?: titleText + + updateCacheValues() + + // Данные восстанавливаются снаружи (Activity/VM) через setData(...) + invalidate() + } + + @Parcelize + internal data class PieSavedState( + val superState: Parcelable?, + val colorModeName: String?, + val textModeName: String?, + val widthModeName: String?, + val titleText: String?, + ) : Parcelable + +} diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 79ae6993..f7ea0053 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,19 +1,23 @@ - - + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + app:textMode="inner" + app:widthMode="var_mode" /> \ No newline at end of file diff --git a/app/src/main/res/raw/payload_1.json b/app/src/main/res/raw/payload_1.json new file mode 100644 index 00000000..d65e7bfb --- /dev/null +++ b/app/src/main/res/raw/payload_1.json @@ -0,0 +1,9 @@ +[ + { + "id": 1, + "name": "Truffo", + "amount": 4541, + "category": "Кафе и рестораны", + "time": 1623326031 + } +] \ No newline at end of file diff --git a/app/src/main/res/raw/payload_2.json b/app/src/main/res/raw/payload_2.json new file mode 100644 index 00000000..412ea998 --- /dev/null +++ b/app/src/main/res/raw/payload_2.json @@ -0,0 +1,16 @@ +[ + { + "id": 1, + "name": "Truffo", + "amount": 4541, + "category": "Кафе и рестораны", + "time": 1623326031 + }, + { + "id": 2, + "name": "Стоматология", + "amount": 8000, + "category": "Здоровье", + "time": 1623419811 + } +] \ No newline at end of file diff --git a/app/src/main/res/raw/payload_4.json b/app/src/main/res/raw/payload_4.json new file mode 100644 index 00000000..03fbd3af --- /dev/null +++ b/app/src/main/res/raw/payload_4.json @@ -0,0 +1,30 @@ +[ + { + "id": 1, + "name": "Truffo", + "amount": 4541, + "category": "Кафе и рестораны", + "time": 1623326031 + }, + { + "id": 2, + "name": "Simple Wine", + "amount": 1600, + "category": "Алкоголь", + "time": 1623329631 + }, + { + "id": 3, + "name": "Азбука Вкуса Экспресс", + "amount": 1841, + "category": "Доставка еды", + "time": 1623322371 + }, + { + "id": 4, + "name": "Стоматология", + "amount": 8000, + "category": "Здоровье", + "time": 1623419811 + } +] \ No newline at end of file diff --git a/app/src/main/res/raw/payload_7.json b/app/src/main/res/raw/payload_7.json new file mode 100644 index 00000000..a255b2b0 --- /dev/null +++ b/app/src/main/res/raw/payload_7.json @@ -0,0 +1,51 @@ +[ + { + "id": 1, + "name": "Азбука Вкуса", + "amount": 1580, + "category": "Продукты", + "time": 1623318531 + }, + { + "id": 2, + "name": "Truffo", + "amount": 4541, + "category": "Кафе и рестораны", + "time": 1623326031 + }, + { + "id": 3, + "name": "Simple Wine", + "amount": 1600, + "category": "Алкоголь", + "time": 1623329631 + }, + { + "id": 4, + "name": "Азбука Вкуса Экспресс", + "amount": 1841, + "category": "Доставка еды", + "time": 1623322371 + }, + { + "id": 5, + "name": "Стоматология", + "amount": 8000, + "category": "Здоровье", + "time": 1623419811 + }, + { + "id": 6, + "name": "Пятерочка", + "amount": 809, + "category": "Продукты", + "time": 1623419934 + }, + { + "id": 7, + "name": "Бассейн", + "amount": 1000, + "category": "Спорт", + "time": 1623419934 + } +] \ No newline at end of file diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml new file mode 100644 index 00000000..c8d3b903 --- /dev/null +++ b/app/src/main/res/values/attrs.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9213c339..af1c3efc 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 diff --git a/gradle.properties b/gradle.properties index 98bed167..38960496 100644 --- a/gradle.properties +++ b/gradle.properties @@ -18,4 +18,7 @@ android.useAndroidX=true # Automatically convert third-party libraries to use AndroidX android.enableJetifier=true # Kotlin code style for this project: "official" or "obsolete": -kotlin.code.style=official \ No newline at end of file +kotlin.code.style=official + +# Gradle 8.x/Groovy can break on very new JDK classfile versions; pin Gradle JVM to a supported JDK. +org.gradle.java.home=/Library/Java/JavaVirtualMachines/zulu-21.jdk/Contents/Home \ No newline at end of file