diff --git a/app/src/main/java/otus/homework/customview/CategoryData.kt b/app/src/main/java/otus/homework/customview/CategoryData.kt new file mode 100644 index 00000000..c2ef1d8e --- /dev/null +++ b/app/src/main/java/otus/homework/customview/CategoryData.kt @@ -0,0 +1,8 @@ +package otus.homework.customview + +data class CategoryData( + val category: String, + val amount: Double, + val color: Int, + val transactions: List = emptyList() +) diff --git a/app/src/main/java/otus/homework/customview/MainActivity.kt b/app/src/main/java/otus/homework/customview/MainActivity.kt index 78cb9448..e859568b 100644 --- a/app/src/main/java/otus/homework/customview/MainActivity.kt +++ b/app/src/main/java/otus/homework/customview/MainActivity.kt @@ -1,11 +1,121 @@ package otus.homework.customview -import androidx.appcompat.app.AppCompatActivity import android.os.Bundle +import android.text.format.DateFormat +import android.widget.TextView +import androidx.activity.enableEdgeToEdge +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import com.google.gson.Gson +import java.util.Date class MainActivity : AppCompatActivity() { + + private lateinit var detailsTextView: TextView + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) + ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) + insets + } + + detailsTextView = findViewById(R.id.detailsTextView) + + val jsonString = loadJsonFromRaw() + val transactions = parseTransactions(jsonString) + val categoriesData = groupTransactionsByCategory(transactions) + + val pieChartView = findViewById(R.id.pieChartView) + pieChartView.setData(categoriesData) + pieChartView.setOnSectorClickListener(object : PieChartView.OnSectorClickListener { + override fun onSectorClick(categoryData: CategoryData) { + showCategoryDetails(categoryData) + } + }) + } + + private fun showCategoryDetails(categoryData: CategoryData) { + val stringBuilder = StringBuilder() + + stringBuilder.append("Категория: ${categoryData.category}\n") + stringBuilder.append("Общая сумма: ${String.format("%.2f", categoryData.amount)} руб.\n") + stringBuilder.append("Количество транзакций: ${categoryData.transactions.size}\n\n") + + stringBuilder.append("Детали транзакций:\n") + stringBuilder.append("-------------------\n") + + categoryData.transactions.forEach { transaction -> + stringBuilder.append("Название: ${transaction.name}\n") + stringBuilder.append("Сумма: ${String.format("%.2f", transaction.amount)} руб.\n") + stringBuilder.append("Время: ${formatTime(transaction.time)}\n") + stringBuilder.append("---\n") + } + + detailsTextView.text = stringBuilder.toString() + } + + private fun formatTime(timestamp: Long): String { + val date = Date(timestamp * 1000L) + return DateFormat.format("dd.MM.yyyy HH:mm", date).toString() + } + + private fun loadJsonFromRaw(): String { + return try { + resources.openRawResource(R.raw.payload).bufferedReader().use { it.readText() } + } catch (e: Exception) { + e.printStackTrace() + "[]" + } + } + + private fun parseTransactions(jsonString: String): List { + return try { + Gson().fromJson(jsonString, Array::class.java).toList() + } catch (e: Exception) { + e.printStackTrace() + emptyList() + } + } + + private fun groupTransactionsByCategory(transactions: List): List { + val categoriesMap = mutableMapOf>() + + transactions.forEach { transaction -> + if (!categoriesMap.containsKey(transaction.category)) { + categoriesMap[transaction.category] = mutableListOf() + } + categoriesMap[transaction.category]?.add(transaction) + } + + val colors = arrayOf( + ContextCompat.getColor(this, android.R.color.holo_red_dark), + ContextCompat.getColor(this, android.R.color.holo_blue_dark), + ContextCompat.getColor(this, android.R.color.holo_green_dark), + ContextCompat.getColor(this, android.R.color.holo_orange_dark), + ContextCompat.getColor(this, android.R.color.holo_purple), + ContextCompat.getColor(this, android.R.color.darker_gray), + ContextCompat.getColor(this, android.R.color.holo_blue_light), + ContextCompat.getColor(this, android.R.color.holo_green_light), + ContextCompat.getColor(this, android.R.color.holo_orange_light), + ContextCompat.getColor(this, android.R.color.holo_red_light), + ContextCompat.getColor(this, R.color.colorPrimary), + ContextCompat.getColor(this, R.color.colorAccent) + ) + + return categoriesMap.entries.mapIndexed { index, (category, transList) -> + val totalAmount = transList.sumOf { it.amount } + CategoryData( + category = category, + amount = totalAmount, + color = colors[index % colors.size], + transactions = transList + ) + }.sortedByDescending { it.amount } } } \ 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 new file mode 100644 index 00000000..3399a69c --- /dev/null +++ b/app/src/main/java/otus/homework/customview/PieChartView.kt @@ -0,0 +1,299 @@ +package otus.homework.customview + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Rect +import android.graphics.Typeface +import android.os.Parcel +import android.os.Parcelable +import android.text.TextPaint +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.pow +import kotlin.math.sin +import kotlin.math.sqrt +import androidx.core.graphics.withSave + +class PieChartView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : View(context, attrs, defStyleAttr) { + + interface OnSectorClickListener { + fun onSectorClick(categoryData: CategoryData) + } + + private var onSectorClickListener: OnSectorClickListener? = null + + fun setOnSectorClickListener(listener: OnSectorClickListener) { + this.onSectorClickListener = listener + } + + private var categoriesData = mutableListOf() + private var totalAmount = 0.0 + + private val paint = Paint(Paint.ANTI_ALIAS_FLAG) + private val textPaint = TextPaint(Paint.ANTI_ALIAS_FLAG) + private val selectedPaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val centerPaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val legendPaint = TextPaint(Paint.ANTI_ALIAS_FLAG) + + private var centerX = 0f + private var centerY = 0f + private var radius = 0f + private var selectedSector = -1 + private var textSize = 0f + private var legendTextSize: Int = 0 + + init { + setupPaints() + } + + private fun setupPaints() { + paint.style = Paint.Style.FILL + paint.strokeWidth = 2f + + textPaint.color = Color.BLACK + textPaint.textAlign = Paint.Align.CENTER + textPaint.typeface = Typeface.DEFAULT_BOLD + + legendPaint.color = Color.DKGRAY + legendPaint.textAlign = Paint.Align.LEFT + legendPaint.textSize = 36f + + selectedPaint.style = Paint.Style.STROKE + selectedPaint.color = Color.BLACK + selectedPaint.strokeWidth = 4f + + centerPaint.color = Color.WHITE + centerPaint.style = Paint.Style.FILL + } + + fun setData(categories: List) { + categoriesData.clear() + categoriesData.addAll(categories) + totalAmount = categoriesData.sumOf { it.amount } + invalidate() + requestLayout() + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val minSize = dpToPx(400) + + val widthMode = MeasureSpec.getMode(widthMeasureSpec) + val widthSize = MeasureSpec.getSize(widthMeasureSpec) + + val heightMode = MeasureSpec.getMode(heightMeasureSpec) + val heightSize = MeasureSpec.getSize(heightMeasureSpec) + + val width: Int = when (widthMode) { + MeasureSpec.EXACTLY -> widthSize + MeasureSpec.AT_MOST -> minOf(widthSize, minSize) + else -> minSize + } + + val height: Int = when (heightMode) { + MeasureSpec.EXACTLY -> heightSize + MeasureSpec.AT_MOST -> minOf(heightSize, minSize) + else -> minSize + } + + val size = minOf(width, height) + setMeasuredDimension(size, size) + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + + centerX = w / 2f + centerY = h / 2f + radius = minOf(w, h) * 0.3f + + textSize = radius * 0.1f + legendTextSize = dpToPx(12) + + textPaint.textSize = textSize + legendPaint.textSize = legendTextSize.toFloat() + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + + if (categoriesData.isEmpty() || totalAmount == 0.0) { + drawEmptyState(canvas) + return + } + + var startAngle = -90f + + for ((index, category) in categoriesData.withIndex()) { + val sweepAngle = (category.amount / totalAmount * 360).toFloat() + + paint.color = category.color + + canvas.drawArc( + centerX - radius, + centerY - radius, + centerX + radius, + centerY + radius, + startAngle, + sweepAngle, + true, + paint + ) + + if (index == selectedSector) { + canvas.drawArc( + centerX - radius, + centerY - radius, + centerX + radius, + centerY + radius, + startAngle, + sweepAngle, + true, + selectedPaint + ) + } + + if (sweepAngle > 10) { + val angle = startAngle + sweepAngle / 2 + val textRadius = radius * 0.6f + val x = centerX + cos(Math.toRadians(angle.toDouble())).toFloat() * textRadius + val y = centerY + sin(Math.toRadians(angle.toDouble())).toFloat() * textRadius + + val displayText = if (category.category.length > 10) { + "${category.category.substring(0, 8)}..." + } else { + category.category + } + + canvas.withSave { + if (angle > 90 && angle < 270) { + rotate(angle + 180, x, y) + drawText(displayText, x, y, textPaint) + } else { + rotate(angle, x, y) + drawText(displayText, x, y, textPaint) + } + } + } + + startAngle += sweepAngle + } + + canvas.drawCircle(centerX, centerY, radius * 0.2f, centerPaint) + + val totalText = "Всего:\n${String.format("%.0f", totalAmount)} руб." + val totalBounds = Rect() + textPaint.getTextBounds(totalText, 0, totalText.length, totalBounds) + + canvas.drawText( + totalText, + centerX, + centerY - textPaint.descent(), + textPaint.apply { color = Color.BLACK } + ) + } + + private fun drawEmptyState(canvas: Canvas) { + paint.color = Color.LTGRAY + canvas.drawCircle(centerX, centerY, radius, paint) + + textPaint.color = Color.DKGRAY + canvas.drawText( + "Нет данных", + centerX, + centerY, + textPaint + ) + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + when (event.action) { + MotionEvent.ACTION_DOWN -> { + val x = event.x + val y = event.y + + val distance = sqrt((x - centerX).pow(2) + (y - centerY).pow(2)) + + if (distance <= radius) { + var angle = + Math.toDegrees(atan2((y - centerY).toDouble(), (x - centerX).toDouble())) + angle = (angle + 360) % 360 + angle = (angle + 90) % 360 + + var currentAngle = 0f + for ((index, category) in categoriesData.withIndex()) { + val sweepAngle = (category.amount / totalAmount * 360).toFloat() + + if (angle >= currentAngle && angle < currentAngle + sweepAngle) { + selectedSector = index + onSectorClickListener?.onSectorClick(category) + invalidate() + return true + } + currentAngle += sweepAngle + } + } + selectedSector = -1 + invalidate() + } + } + return super.onTouchEvent(event) + } + + private fun dpToPx(dp: Int): Int { + return (dp * resources.displayMetrics.density).toInt() + } + + override fun onSaveInstanceState(): Parcelable { + val superState = super.onSaveInstanceState() + return SavedState(superState).apply { + this.selectedSector = this@PieChartView.selectedSector + } + } + + override fun onRestoreInstanceState(state: Parcelable?) { + val savedState = state as? SavedState + if (savedState != null) { + super.onRestoreInstanceState(savedState.superState) + selectedSector = savedState.selectedSector + } else { + super.onRestoreInstanceState(state) + } + } + + private class SavedState : BaseSavedState { + var selectedSector: Int = -1 + + constructor(superState: Parcelable?) : super(superState) + + constructor(parcel: Parcel) : super(parcel) { + selectedSector = parcel.readInt() + } + + override fun writeToParcel(out: Parcel, flags: Int) { + super.writeToParcel(out, flags) + out.writeInt(selectedSector) + } + + companion object { + @JvmField + val CREATOR = object : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): SavedState { + return SavedState(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/otus/homework/customview/Transaction.kt b/app/src/main/java/otus/homework/customview/Transaction.kt new file mode 100644 index 00000000..080a8ca7 --- /dev/null +++ b/app/src/main/java/otus/homework/customview/Transaction.kt @@ -0,0 +1,9 @@ +package otus.homework.customview + +data class Transaction( + val id: Int, + val name: String, + val amount: Double, + val category: String, + val time: Long +) diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 79ae6993..d2348709 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,19 +1,43 @@ - + android:layout_marginBottom="24dp" + android:text="@string/spending_chart_by_category" + android:textSize="20sp" + android:textStyle="bold" /> - \ No newline at end of file + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index f8c6127d..335c1b73 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -7,4 +7,7 @@ #FF018786 #FF000000 #FFFFFFFF + #3F51B5 + #303F9F + #FF4081 \ 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..220aa627 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,5 @@ Custom View + Диаграмма расходов по категориям + Нажмите на сектор диаграммы для просмотра деталей… \ No newline at end of file