Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions app/build.gradle
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
plugins {
id 'com.android.application'
id 'kotlin-android'
id 'kotlin-parcelize'
id 'kotlinx-serialization'
}

android {
compileSdkVersion 30
buildToolsVersion "30.0.3"

defaultConfig {
namespace = "otus.homework.customview"
applicationId "otus.homework.customview"
minSdkVersion 23
targetSdkVersion 30
targetSdkVersion 34
versionCode 1
versionName "1.0"

Expand All @@ -30,10 +33,15 @@ android {
kotlinOptions {
jvmTarget = '1.8'
}
buildFeatures {
viewBinding = true
}
}

dependencies {

implementation 'com.google.code.gson:gson:2.8.8'
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.0"
implementation "org.jetbrains.kotlin:kotlin-serialization:1.9.24"
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation 'androidx.core:core-ktx:1.3.2'
implementation 'androidx.appcompat:appcompat:1.2.0'
Expand Down
3 changes: 2 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.CustomView">
<activity android:name=".MainActivity">
<activity android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

Expand Down
112 changes: 112 additions & 0 deletions app/src/main/java/otus/homework/customview/CategoryView.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package otus.homework.customview

import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.RectF
import android.os.Bundle
import android.os.Parcelable
import android.util.AttributeSet
import android.view.View
import androidx.core.content.withStyledAttributes
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json

class CategoryView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {

private val rectangle by lazy { RectF() }
private val paint by lazy { Paint() }

private var labelText: String? = null
private var overHead: Float? = null
private var maxValue: Double = 0.0

private var categoryWidth: Int = 0
private var categoryHeight: Int = 0

private var defaultBlockWidth = 30.px.toFloat()

private var categories: List<Category> = emptyList()

init {
initAttributes(attrs)
paint.apply {
style = Paint.Style.FILL
}
}

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val wMode = MeasureSpec.getMode(widthMeasureSpec)
val hMode = MeasureSpec.getMode(heightMeasureSpec)

when (wMode) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

можно упросить через resolveSize(desiredWidth, widthMeasureSpec), он под капотом делает аналогичную обработку, тогда и wMode ну нужен будет и код станет сильно короче, аналогично с высотой

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

как пример для ширины:

val paddingWidth = paddingStart + paddingEnd
val minWidth = suggestedMinimumWidth + paddingWidth
val desiredBlocksWidth = (defaultBlockWidth * categories.size).toInt() + paddingWidth
val desiredWidth = max(minWidth, desiredBlocksWidth)

далее аналогично считаем для высоты и:

setMeasuredDimension(
            resolveSize(desiredWidth, widthMeasureSpec),
            resolveSize(desiredHeight, heightMeasureSpec)
        )

defaultBlockWidth нужно задавать изначально в dp и потом пересчитывать в пиксели, сейчас он 30 пикселей, и будет по разному смотреться на разных экранах

MeasureSpec.AT_MOST, MeasureSpec.UNSPECIFIED -> {
categoryWidth = (defaultBlockWidth * categories.size).toInt()
categoryHeight = MeasureSpec.getSize(heightMeasureSpec)
}

MeasureSpec.EXACTLY -> {
categoryWidth = MeasureSpec.getSize(widthMeasureSpec)
categoryHeight = MeasureSpec.getSize(heightMeasureSpec)
}
}
setMeasuredDimension(categoryWidth, categoryHeight)
}

override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
val blockWidth = categoryWidth / categories.size
val blockHeight = categoryHeight / maxValue

var currentValueX = 0.0
categories.forEach { category ->
paint.apply { color = category.color }
rectangle.set(
currentValueX.toFloat(),
(categoryHeight - blockHeight * category.value).toFloat(),
(currentValueX + blockWidth).toFloat(),
categoryHeight.toFloat()
)
canvas?.drawRect(rectangle, paint)
currentValueX += blockWidth
}
}

override fun onRestoreInstanceState(state: Parcelable?) {
val bundle = state as Bundle
val savedState = bundle.getString(SAVED_STATE_VIEW_KEY)
savedState?.let {
val viewState = Json.decodeFromString<SavedCategoryState>(it)
this@CategoryView.categories = viewState.charts
}
super.onRestoreInstanceState(bundle.getParcelable(INSTANCE_CATEGORY_KEY))
}

override fun onSaveInstanceState(): Parcelable {
val bundle = Bundle()
bundle.putString(SAVED_STATE_VIEW_KEY, Json.encodeToString(SavedCategoryState(categories)))
bundle.putParcelable(INSTANCE_CATEGORY_KEY, super.onSaveInstanceState())
return bundle
}

private fun initAttributes(attrs: AttributeSet?) {
context.withStyledAttributes(attrs, R.styleable.CategoryView, 0, 0) {
labelText = getString(R.styleable.CategoryView_chart_label)
overHead = getFloat(R.styleable.CategoryView_chart_sum_overhead, 0F)
}
}

fun setCharts(charts: List<Category>) {
this@CategoryView.categories = charts
maxValue = charts.maxOf { it.value }
requestLayout()
}

private companion object {
private const val SAVED_STATE_VIEW_KEY = "saved_state_view_key"
private const val INSTANCE_CATEGORY_KEY = "instance_category_key"
}
}
21 changes: 20 additions & 1 deletion app/src/main/java/otus/homework/customview/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,29 @@ package otus.homework.customview

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import otus.homework.customview.databinding.ActivityMainBinding

class MainActivity : AppCompatActivity() {

private val payloads by lazy { this.readData() }

private lateinit var binding: ActivityMainBinding

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
binding = ActivityMainBinding.inflate(layoutInflater)
val view = binding.root
setContentView(view)
initCategory()
}

private fun initPieChart() {
val pieces = payloads.mapPieces()
/* binding.pieChart.setPieces(pieces) */
}

private fun initCategory() {
val charts = payloads.mapCategories()
binding.view.setCharts(charts)
}
}
90 changes: 90 additions & 0 deletions app/src/main/java/otus/homework/customview/PayloadData.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package otus.homework.customview

import android.content.Context
import android.content.res.Resources
import android.graphics.Color
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.serialization.Serializable
import java.util.Random

const val DEFAULT_ANGLE = 0.0
const val MAX_SWEEP_ANGLE = 360

data class PayloadData(
val id: Int,
val time: Long,
val name: String,
val amount: Double,
val category: String,
)

@Serializable
data class PieData(
val color: Int,
val name: String,
val startAngle: Double,
val sweepAngle: Double
)

@Serializable
data class Category(
val color: Int,
val value: Double,
val name: String
)

@Serializable
data class SavedPiecesState(
val pieces: List<PieData>
)

@Serializable
data class SavedCategoryState(
val charts: List<Category>
)

fun List<PayloadData>.mapPieces(): List<PieData> {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

не происходит агрегация по категориям, каждая запись это свой сектор Pie Chart, в этом чарте сектор должен соответствовать одной категории, в которую включены все записи этой категории

val totalSum = this.sumOf { it.amount }
var startAngle = DEFAULT_ANGLE

return this.map { payload ->
val sweepAngle = payload.amount.calculateSweepAngle(totalSum)
val data = PieData(
name = payload.name,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

имя сектора это должно быть название категории, а не расхода

color = generateColor(),
startAngle = startAngle,
sweepAngle = sweepAngle
)
startAngle += sweepAngle
data
}
}

fun List<PayloadData>.mapCategories(): List<Category> = this.map {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

как и в mapPieces отсутствует группировка по дням, просто идут записи, в одной категории может быть несколько трат за день

Category(
name = it.name,
value = it.amount,
color = generateColor()
)
}

private fun generateColor(): Int {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

полный рандом возможно не лучшее решение, разные категории могут получить схожие цвета, возможно было бы лучше скорректировать генерацию под какие то условия (например, взять число категорий и равномерно разбить эти цвета на спектре), тогда бы и сохранять их не пришлось в стейте

val rnd = Random()
return Color.argb(255, rnd.nextInt(256), rnd.nextInt(256), rnd.nextInt(256))
}

private fun Double.calculateSweepAngle(total: Double): Double = this / total * MAX_SWEEP_ANGLE

fun Context.readData(): List<PayloadData> {
val data = this.resources.openRawResource(R.raw.payload)
val str = data.bufferedReader().use { it.readText() }
val gson = Gson()

val listType = object : TypeToken<List<PayloadData>>() {}.type
val itemList: List<PayloadData> = gson.fromJson(str, listType)
return itemList
}

val Int.px: Int
get() = (this * Resources.getSystem().displayMetrics.density).toInt()
Loading