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
16 changes: 10 additions & 6 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ plugins {
}

android {
compileSdkVersion 30
buildToolsVersion "30.0.3"
compileSdkVersion 34
namespace = "otus.homework.customview"

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

Expand All @@ -24,11 +24,14 @@ android {
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '1.8'
jvmTarget = '17'
}
buildFeatures {
viewBinding = true
}
}

Expand All @@ -42,4 +45,5 @@ dependencies {
testImplementation 'junit:junit:4.+'
androidTestImplementation 'androidx.test.ext:junit:1.1.2'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
implementation 'com.google.code.gson:gson:2.10.1'
}
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
9 changes: 9 additions & 0 deletions app/src/main/java/otus/homework/customview/CostDto.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package otus.homework.customview

data class CostDto(
val id: Long,
val name: String,
val amount: Long,
val category: String,
val time: Long,
)
116 changes: 116 additions & 0 deletions app/src/main/java/otus/homework/customview/LineChartView.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package otus.homework.customview

import android.content.Context
import android.graphics.*
import android.os.Build
import android.util.AttributeSet
import android.view.View
import androidx.annotation.RequiresApi
import java.time.*
import java.time.format.DateTimeFormatter
import kotlin.math.max

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

private var data: List<CostDto> = listOf()
private var colors: List<Pair<Int, Int>> = listOf()

fun setData(values: List<CostDto>, colors: List<Pair<Int, Int>>) {
this.data = values
this.colors = colors
invalidate()
}

@RequiresApi(Build.VERSION_CODES.O)
private val dateFormatter = DateTimeFormatter.ofPattern("dd.MM")

private val axisPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.BLACK
strokeWidth = 5f
}

private val gridPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.LTGRAY
strokeWidth = 2f
}

private val linePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.BLUE
strokeWidth = 5f
style = Paint.Style.STROKE
}

private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.BLACK
textSize = 30f
textAlign = Paint.Align.CENTER
}

@RequiresApi(Build.VERSION_CODES.O)
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)

val padding = 100f
val width = width.toFloat()
val height = height.toFloat()

val numGridLines = 5
for (i in 0..numGridLines) {
val y = height - padding - (i * (height - 2 * padding) / numGridLines)
canvas.drawLine(padding, y, width - padding, y, gridPaint)
}

val localDates = data.associate {
it to LocalDateTime.ofInstant(
Instant.ofEpochSecond(it.time),
ZoneId.systemDefault()
)
}

val groupedByCategories = data.groupBy { it.category }

for ((_, categoryData) in groupedByCategories) {
val categoryColor = colors.random().first
linePaint.color = categoryColor

val maxY = categoryData.maxOf { it.amount } * 1.2f

val stepX = (width - 2 * padding) / max(1, categoryData.size - 1)
val stepY = (height - 2 * padding) / maxY

canvas.drawLine(
padding,
height - padding,
width - padding,
height - padding,
axisPaint
)
canvas.drawLine(padding, padding, padding, height - padding, axisPaint)

var prevX: Float? = null
var prevY: Float? = null

for ((index, entry) in categoryData.withIndex()) {
val x = padding + index * stepX
val y = height - padding - (entry.amount * stepY)

if (prevX != null && prevY != null) {
canvas.drawLine(prevX, prevY, x, y, linePaint)
}

canvas.drawCircle(x, y, 10f, linePaint)

val dateText = localDates[entry]?.format(dateFormatter) ?: ""
canvas.drawText(dateText, x, height - padding + 40f, textPaint)

prevX = x
prevY = y
}
}
}

}
56 changes: 54 additions & 2 deletions app/src/main/java/otus/homework/customview/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -1,11 +1,63 @@
package otus.homework.customview

import androidx.appcompat.app.AppCompatActivity
import android.graphics.Color
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.databinding.ActivityMainBinding
import java.io.InputStreamReader

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)

val inputStream = resources.openRawResource(R.raw.payload)
val payload = InputStreamReader(inputStream)
val values = readCosts(payload)

initPieChartView(values)
initLineChartView(values)
}

private fun initLineChartView(data: List<CostDto>) {
binding.lineChart.setData(data, getColorsForGradient())
}

private fun initPieChartView(data: List<CostDto>) {
binding.pieChart.setData(data, getColorsForGradient())
binding.pieChart.applyCallback(object : PieChartView.Callback {
override fun onSectorClick(costDto: CostDto) {
Toast.makeText(
this@MainActivity,
"${costDto.name} - ${costDto.amount} USD",
Toast.LENGTH_SHORT
).show()
}
})
}

private fun readCosts(payload: InputStreamReader): List<CostDto> {
val costListType = object : TypeToken<List<CostDto>>() {}.type
return Gson().fromJson(payload, costListType)
}

private fun getColorsForGradient() = listOf(
Color.RED to Color.rgb(255, 165, 0),
Color.BLUE to Color.CYAN,
Color.rgb(138, 43, 226) to Color.rgb(255, 105, 180),
Color.GREEN to Color.rgb(50, 205, 50),
Color.YELLOW to Color.rgb(255, 215, 0),
Color.rgb(255, 0, 0) to Color.rgb(255, 182, 193),
Color.rgb(64, 224, 208) to Color.rgb(0, 191, 255),
Color.BLACK to Color.rgb(169, 169, 169),
Color.rgb(0, 0, 139) to Color.rgb(135, 206, 250),
Color.rgb(128, 0, 128) to Color.rgb(230, 230, 250)
).shuffled()
}
148 changes: 148 additions & 0 deletions app/src/main/java/otus/homework/customview/PieChartView.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package otus.homework.customview

import android.R.attr.radius
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import kotlin.math.*

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

private val sectionPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.FILL
}

private val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = 5F
strokeCap = Paint.Cap.ROUND
color = Color.WHITE
}

private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.BLACK
textSize = 50f
textAlign = Paint.Align.CENTER
}

private val holePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.WHITE }

private val rectF = RectF()
private val textBounds = Rect()
private var data: List<CostDto> = listOf()
private var colors: List<Pair<Int, Int>> = listOf()
private var gradientColors: List<SweepGradient> = listOf()

private var callback: Callback? = null

fun setData(values: List<CostDto>, colors: List<Pair<Int, Int>>) {
this.data = values
this.colors = colors
invalidate() // Перерисовка View
}

@SuppressLint("DrawAllocation")
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)

val width = width.toFloat()
val height = height.toFloat()

if (gradientColors.isEmpty() and colors.isNotEmpty()) {
colors.forEach {
gradientColors += SweepGradient(width, height, it.first, it.second)
}
}

val radius = min(width, height) / 2 - 20f
val holeRadius = radius * 0.5f // Радиус "дырки"

rectF.set(
width / 2 - radius, height / 2 - radius,
width / 2 + radius, height / 2 + radius
)

var startAngle = 0f // Начальный угол секторов
val dataSum = data.sumOf { it.amount }

for (i in data.indices) {
val dataPercent = 100 * data[i].amount.toFloat() / dataSum
val sweepAngle = (dataPercent / 100) * 360f

sectionPaint.shader = gradientColors[i % gradientColors.size]

canvas.drawArc(rectF, startAngle, sweepAngle, true, sectionPaint)
canvas.drawArc(rectF, startAngle, sweepAngle, true, strokePaint)

startAngle += sweepAngle
}

// Рисуем круг внутри (создавая отверстие)
canvas.drawCircle(width / 2, height / 2, holeRadius, holePaint)

// Рисуем суммы в центре
val centerX = width / 2f
val centerY = height / 2f
textPaint.getTextBounds(dataSum.toString(), 0, dataSum.toString().length, textBounds)
val textHeight = textBounds.height()
val textY = centerY + textHeight / 2f
canvas.drawText(dataSum.toString(), centerX, textY, textPaint)
}

@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent?): Boolean {
when (event?.action) {
MotionEvent.ACTION_DOWN -> {
val touchX = event.x
val touchY = event.y

val centerX = width / 2f
val centerY = height / 2f

val distance =
sqrt((touchX - centerX) * (touchX - centerX) + (touchY - centerY) * (touchY - centerY))
if (distance <= radius) {
// Если касание внутри круга, вычисляем угол
val angle = Math.toDegrees(
atan2(
(touchY - centerY).toDouble(),
(touchX - centerX).toDouble()
)
).toFloat()

// Переход в положительный диапазон углов
val adjustedAngle = if (angle < 0) angle + 360 else angle

// Проверяем, в какой сектор попало касание
var startAngle = 0f
val dataSum = data.sumOf { it.amount }

for (i in data.indices) {
val sweepAngle = (100 * data[i].amount.toFloat() / dataSum) * 360f / 100

if (adjustedAngle >= startAngle && adjustedAngle <= startAngle + sweepAngle) {
callback?.onSectorClick(data[i])
break
}

startAngle += sweepAngle
}
}
}
}
return true
}

fun applyCallback(callback: Callback) = apply { this.callback = callback }

interface Callback {
fun onSectorClick(costDto: CostDto)
}
}
Loading