-
Notifications
You must be signed in to change notification settings - Fork 168
feat: homework04 #128
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
feat: homework04 #128
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) | ||
| } | ||
| } | ||
| 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) | ||
| } |
| 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 |
| 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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Здесь сохраненный startAngle |
||
| sweepAngle = sweepAngle, | ||
| ) | ||
| }.toMutableList() | ||
| } | ||
|
|
||
| override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
| } | ||
| } | ||
| } | ||
| 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 | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Создается новый экземпляр Gson при каждом вызове. Gson потокобезопасен и может быть переиспользован. Если вынести в companion object, будет красиво