diff --git a/app/build.gradle b/app/build.gradle index b4711913..59013267 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,15 +1,17 @@ plugins { id 'com.android.application' id 'kotlin-android' + id 'org.jetbrains.kotlin.plugin.serialization' version '2.1.10' } android { compileSdkVersion 30 buildToolsVersion "30.0.3" + namespace "otus.homework.customview" defaultConfig { applicationId "otus.homework.customview" - minSdkVersion 23 + minSdkVersion 26 targetSdkVersion 30 versionCode 1 versionName "1.0" @@ -30,6 +32,9 @@ android { kotlinOptions { jvmTarget = '1.8' } + buildFeatures { + viewBinding = true + } } dependencies { @@ -39,6 +44,7 @@ dependencies { implementation 'androidx.appcompat:appcompat:1.2.0' implementation 'com.google.android.material:material:1.3.0' implementation 'androidx.constraintlayout:constraintlayout:2.0.4' + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0") testImplementation 'junit:junit:4.+' androidTestImplementation 'androidx.test.ext:junit:1.1.2' androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' diff --git a/app/src/main/java/otus/homework/customview/MainActivity.kt b/app/src/main/java/otus/homework/customview/MainActivity.kt index 78cb9448..d9dd3a22 100644 --- a/app/src/main/java/otus/homework/customview/MainActivity.kt +++ b/app/src/main/java/otus/homework/customview/MainActivity.kt @@ -1,11 +1,59 @@ package otus.homework.customview -import androidx.appcompat.app.AppCompatActivity +import android.graphics.Color import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import kotlinx.serialization.json.Json +import otus.homework.customview.PieChartView.DataItem +import otus.homework.customview.SimpleStockChartView.ChartItem +import otus.homework.customview.databinding.ActivityMainBinding +import java.time.Instant +import java.time.ZoneId +import kotlin.random.Random 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) + val view = binding.root + if (savedInstanceState == null) { + createChart() + } + binding.pieChartView.setOnSectorClickListener(object : PieChartView.OnSectorClickListener { + override fun onSectorClicked(sector: DataItem?) { + sector?.let { + val products = productList() + val item = products.find { it.id == sector.id } + val items = products + .filter { it.category==item?.category} + .map { + val instant = Instant.ofEpochSecond(it.time.toLong()) + ChartItem( + instant.atZone(ZoneId.systemDefault()).toLocalDate(), + it.amount.toDouble() + ) + } + binding.simpleStockChartView.setData(items) + } + } + }) + setContentView(view) + } + + private fun createChart() { + val items = productList().map { + val color = + Color.argb(255, Random.nextInt(256), Random.nextInt(256), Random.nextInt(256)) + DataItem(it.id, it.name, color, it.amount.toFloat()) + } + binding.pieChartView.setData(items) + } + + private fun productList(): List { + val dataStr = readRawFileAsString(this, R.raw.payload) + val data = Json.decodeFromString>(dataStr) + return data } } \ 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..756abfc4 --- /dev/null +++ b/app/src/main/java/otus/homework/customview/PieChartView.kt @@ -0,0 +1,251 @@ +package otus.homework.customview + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +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.PI +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.min +import kotlin.math.sin +import kotlin.math.sqrt + +class PieChartView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : View(context, attrs, defStyleAttr) { + + private var data: List = emptyList() + + private val paint = Paint().apply { + style = Paint.Style.FILL + isAntiAlias = true + } + + private val textPaint = TextPaint().apply { + color = Color.BLACK + textSize = 52f + typeface = Typeface.DEFAULT_BOLD + textAlign = Paint.Align.LEFT + } + + private var selectedSector: DataItem? = null + + interface OnSectorClickListener { + fun onSectorClicked(sector: DataItem?) + } + + private var sectorClickListener: OnSectorClickListener? = null + + fun setOnSectorClickListener(listener: OnSectorClickListener) { + sectorClickListener = listener + } + + private var startAngle = 0f + + fun setData(newData: List) { + this.data = newData + invalidate() + } + + override fun onDraw(canvas: Canvas?) { + super.onDraw(canvas) + + if (canvas == null || data.isEmpty()) return + val width = width.toFloat() + val height = height.toFloat() + + val centerX = width / 2 + val centerY = height / 2 + + val radius = min(width, height) / 2 * RADIUS_COEFFICIENT + + val totalValue = data.sumOf { it.value.toDouble() }.toFloat() + var currentAngle = startAngle + + val textItems = mutableListOf() + + for (item in data) { + paint.color = item.color + val normalizedAngle = (item.value / totalValue) * 360f + canvas.drawArc( + centerX - radius, + centerY - radius, + centerX + radius, + centerY + radius, + currentAngle, + normalizedAngle, + true, + paint + ) + + val middleAngle = currentAngle + normalizedAngle / 2 + val xPosition = + (centerX + radius * cos(Math.toRadians(middleAngle.toDouble()))).toFloat() + val yPosition = + (centerY + radius * sin(Math.toRadians(middleAngle.toDouble()))).toFloat() + + currentAngle += normalizedAngle + textItems.add(TextItem(item.label, xPosition, yPosition, textPaint)) + } + + for (item in textItems) { + with(item) { + if (x > width / 2) { + textPaint.textAlign = Paint.Align.RIGHT + } else { + textPaint.textAlign = Paint.Align.LEFT + } + canvas.drawText(text, x, y, paint) + } + } + } + + override fun onTouchEvent(event: MotionEvent?): Boolean { + event?.let { + when (it.actionMasked) { + MotionEvent.ACTION_DOWN -> { + handleTouch(it.x, it.y) + return true + } + + else -> return false + } + } + return super.onTouchEvent(event) + } + + private fun handleTouch(x: Float, y: Float) { + val width = width.toFloat() + val height = height.toFloat() + + val centerX = width / 2 + val centerY = height / 2 + val radius = min(width, height) / 2 * RADIUS_COEFFICIENT + + val distance = sqrt((x - centerX) * (x - centerX) + (y - centerY) * (y - centerY)) + if (distance > radius) { + clearSelectedSector() + return + } + + val totalValue = data.sumOf { it.value.toDouble() }.toFloat() + var currentAngle = startAngle + + for (item in data) { + val normalizedAngle = (item.value / totalValue) * 360f + if (currentAngle <= calculateAngle(x, y, centerX, centerY) + && calculateAngle(x, y, centerX, centerY) < currentAngle + normalizedAngle + ) { + selectSector(item) + break + } + currentAngle += normalizedAngle + } + } + + private fun calculateAngle(x: Float, y: Float, centerX: Float, centerY: Float): Float { + val angleInDegrees = atan2(y - centerY, x - centerX) * 180 / PI + return if (angleInDegrees >= 0) angleInDegrees.toFloat() else (angleInDegrees + 360).toFloat() + } + + private fun selectSector(sector: DataItem) { + selectedSector = sector + sectorClickListener?.onSectorClicked(selectedSector) + invalidate() + } + + private fun clearSelectedSector() { + selectedSector = null + sectorClickListener?.onSectorClicked(null) + invalidate() + } + + override fun onSaveInstanceState(): Parcelable { + val superState = super.onSaveInstanceState() + val savedState = SavedState(superState).apply { + dataArray = data.toTypedArray() + } + return savedState + } + + override fun onRestoreInstanceState(state: Parcelable?) { + if (state !is SavedState) { + super.onRestoreInstanceState(state) + return + } + super.onRestoreInstanceState(state.superState) + data = state.dataArray.toList() + requestLayout() + } + + internal class SavedState : BaseSavedState { + lateinit var dataArray: Array + + constructor(superState: Parcelable?) : super(superState) + + constructor(source: Parcel) : super(source) { + dataArray = source.createTypedArray(DataItem.CREATOR) ?: emptyArray() + } + + override fun writeToParcel(out: Parcel, flags: Int) { + super.writeToParcel(out, flags) + out.writeTypedArray(dataArray, flags) + } + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): SavedState { + return SavedState(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } + } + + private class TextItem(val text: String, val x: Float, val y: Float, val paint: TextPaint) + + + data class DataItem(val id: Int, val label: String, val color: Int, val value: Float) : + Parcelable { + constructor(parcel: Parcel) : this( + parcel.readInt(), + parcel.readString().orEmpty(), + parcel.readInt(), + parcel.readFloat() + ) + + override fun writeToParcel(dest: Parcel?, flags: Int) { + dest?.writeInt(id) + dest?.writeString(label) + dest?.writeInt(color) + dest?.writeFloat(value) + } + + override fun describeContents(): Int = 0 + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): DataItem { + return DataItem(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } + } + + companion object { + private const val RADIUS_COEFFICIENT = 0.9f + } +} \ No newline at end of file diff --git a/app/src/main/java/otus/homework/customview/Product.kt b/app/src/main/java/otus/homework/customview/Product.kt new file mode 100644 index 00000000..7da9504f --- /dev/null +++ b/app/src/main/java/otus/homework/customview/Product.kt @@ -0,0 +1,18 @@ +package otus.homework.customview + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class Product( + @SerialName("amount") + val amount: Int, + @SerialName("category") + val category: String, + @SerialName("id") + val id: Int, + @SerialName("name") + val name: String, + @SerialName("time") + val time: Int +) \ No newline at end of file diff --git a/app/src/main/java/otus/homework/customview/SimpleStockChartView.kt b/app/src/main/java/otus/homework/customview/SimpleStockChartView.kt new file mode 100644 index 00000000..a87e4f41 --- /dev/null +++ b/app/src/main/java/otus/homework/customview/SimpleStockChartView.kt @@ -0,0 +1,226 @@ +package otus.homework.customview + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.CornerPathEffect +import android.graphics.Paint +import android.graphics.Path +import android.os.Parcel +import android.os.Parcelable +import android.util.AttributeSet +import android.view.View +import java.time.LocalDate +import java.time.format.DateTimeFormatter + + +class SimpleStockChartView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : View(context, attrs, defStyleAttr) { + + private val items = ArrayList() + + private val path: Path = Path() + + private val axisPaint = Paint().apply { + color = Color.BLACK + style = Paint.Style.STROKE + strokeWidth = 2f + } + private val linePaint = Paint().apply { + color = Color.RED + style = Paint.Style.STROKE + strokeWidth = 4f + pathEffect = CornerPathEffect(30f) + } + private val textPaint = Paint().apply { + color = Color.BLACK + textSize = 32f + textAlign = Paint.Align.CENTER + } + + private val formatterD = DateTimeFormatter.ofPattern("dd") + private val formatterM = DateTimeFormatter.ofPattern("MMM") + + fun setData(data: List) { + items.clear() + if (data.isEmpty()) return + items.addAll(data) + invalidate() + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + //??? + val wMode = MeasureSpec.getMode(widthMeasureSpec) + val wSize = MeasureSpec.getSize(widthMeasureSpec) + val hSize = MeasureSpec.getSize(heightMeasureSpec) + + when (wMode) { + MeasureSpec.EXACTLY, + MeasureSpec.AT_MOST, + MeasureSpec.UNSPECIFIED -> setMeasuredDimension(wSize, hSize) + } + } + + override fun onDraw(canvas: Canvas?) { + super.onDraw(canvas) + if (items.isEmpty()) return + canvas?.let { c -> + drawAxes(c) + drawLineGraph(c) + } + } + + private fun drawAxes(canvas: Canvas) { + canvas.drawLine( + paddingLeft.toFloat(), + height - paddingBottom.toFloat(), + width - paddingRight.toFloat(), + height - paddingBottom.toFloat(), + axisPaint + ) + canvas.drawLine( + paddingLeft.toFloat(), + height - paddingBottom.toFloat(), + paddingLeft.toFloat(), + paddingTop.toFloat(), + axisPaint + ) + + val stepX = stepX() + for (i in items.indices) { + val x = paddingLeft + i * stepX + canvas.drawText( + items[i].time.format(formatterD), + x, + height - paddingBottom + textPaint.textSize, + textPaint + ) + canvas.drawText( + items[i].time.format(formatterM), + x, + height - paddingBottom + 2 * textPaint.textSize, + textPaint + ) + } + + val maxY = items.maxOf { it.amount } + var minY = items.minOf { it.amount } + if (maxY == minY) minY = 0.0 + val rangeY = maxY - minY + val stepY = (height - paddingTop - paddingBottom).toFloat() / rangeY + for (yValue in minY.toInt()..maxY.toInt() step (rangeY / 5).toInt()) { + val y = height - paddingBottom - (yValue - minY) * stepY + canvas.drawText( + yValue.toString(), + paddingLeft - textPaint.measureText(yValue.toString()), + y.toFloat(), + textPaint + ) + } + } + + private fun drawLineGraph(canvas: Canvas) { + path.reset() + val stepX = stepX() + + val maxY = items.maxOf { it.amount } + var minY = items.minOf { it.amount } + if (maxY == minY) minY = 0.0 + val rangeY = maxY - minY + val stepY = (height - paddingTop - paddingBottom).toFloat() / rangeY + + var currentX = paddingLeft.toFloat() + for ((index, value) in items.withIndex()) { + val y = height - paddingBottom - (value.amount - minY) * stepY + if (index == 0) { + path.moveTo(currentX, y.toFloat()) + if (items.size == 1) path.lineTo(currentX, (height - paddingBottom).toFloat()) + } else { + path.lineTo(currentX, y.toFloat()) + } + currentX += stepX + } + + canvas.drawPath(path, linePaint) + } + + private fun stepX(): Float { + val step = if (items.size == 1) 1 else items.size - 1 + val stepX = (width - paddingLeft - paddingRight).toFloat() / step + return stepX + } + + override fun onSaveInstanceState(): Parcelable { + val superState = super.onSaveInstanceState() + val savedState = SavedState(superState).apply { + dataArray = items.toTypedArray() + } + return savedState + } + + override fun onRestoreInstanceState(state: Parcelable?) { + if (state !is SavedState) { + super.onRestoreInstanceState(state) + return + } + super.onRestoreInstanceState(state.superState) + items.addAll(state.dataArray) + requestLayout() + } + + data class ChartItem( + val time: LocalDate, + val amount: Double + ) : + Parcelable { + constructor(parcel: Parcel) : this( + parcel.readSerializable() as LocalDate, + parcel.readDouble() + ) + + override fun writeToParcel(dest: Parcel?, flags: Int) { + dest?.writeSerializable(time) + dest?.writeDouble(amount) + } + + override fun describeContents(): Int = 0 + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): ChartItem { + return ChartItem(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } + } + + internal class SavedState : BaseSavedState { + lateinit var dataArray: Array + + constructor(superState: Parcelable?) : super(superState) + + constructor(source: Parcel) : super(source) { + dataArray = source.createTypedArray(ChartItem.CREATOR) ?: emptyArray() + } + + override fun writeToParcel(out: Parcel, flags: Int) { + super.writeToParcel(out, flags) + out.writeTypedArray(dataArray, flags) + } + + companion object CREATOR : 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/Utils.kt b/app/src/main/java/otus/homework/customview/Utils.kt new file mode 100644 index 00000000..0bcac6bc --- /dev/null +++ b/app/src/main/java/otus/homework/customview/Utils.kt @@ -0,0 +1,11 @@ +package otus.homework.customview + +import android.content.Context +import java.io.BufferedReader +import java.io.InputStreamReader + +fun readRawFileAsString(context: Context, resourceId: Int): String { + val inputStream = context.resources.openRawResource(resourceId) + val reader = BufferedReader(InputStreamReader(inputStream)) + return reader.readText() +} \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 79ae6993..8da97ab1 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,19 +1,28 @@ - - + + \ No newline at end of file diff --git a/build.gradle b/build.gradle index e47bb55b..2e646ccf 100644 --- a/build.gradle +++ b/build.gradle @@ -1,12 +1,12 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { - ext.kotlin_version = "1.4.32" + ext.kotlin_version = "2.0.21" repositories { google() - jcenter() + mavenCentral() } dependencies { - classpath "com.android.tools.build:gradle:4.1.2" + classpath ("com.android.tools.build:gradle:8.4.2") classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" // NOTE: Do not place your application dependencies here; they belong diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 8734448b..92383cd5 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-all.zip