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
19 changes: 11 additions & 8 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
buildToolsVersion "34.0.0"

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

Expand All @@ -30,15 +30,18 @@ android {
kotlinOptions {
jvmTarget = '1.8'
}
namespace 'otus.homework.customview'
}

dependencies {

implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation 'androidx.core:core-ktx:1.3.2'
implementation 'androidx.appcompat:appcompat:1.2.0'
implementation 'com.google.android.material:material:1.3.0'
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
implementation 'androidx.core:core-ktx:1.13.1'
implementation 'androidx.appcompat:appcompat:1.7.0'
implementation 'com.google.android.material:material:1.12.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'com.google.code.gson:gson:2.11.0'
implementation 'androidx.activity:activity:1.9.2'
testImplementation 'junit:junit:4.+'
androidTestImplementation 'androidx.test.ext:junit:1.1.2'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
Expand Down
8 changes: 4 additions & 4 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
package="otus.homework.customview">
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<application
android:allowBackup="true"
Expand All @@ -10,7 +8,9 @@
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
144 changes: 144 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,144 @@
package otus.homework.customview

import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Path
import android.graphics.Rect
import android.util.AttributeSet
import android.view.View
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import kotlin.math.min

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

private val data: MutableMap<String, MutableMap<Long, Float>> = mutableMapOf()
private val secsInDay: Long = 60 * 60 * 24
private var minDay: Long = 0
private var maxDay: Long = 0
private var maxValue = 0f
private var catCount = 0
private var colWidth = 0 // in pixels
private var dayWidth = 0 // in pixels
private var winWidth = 1000 // in pixels
private var winHeight = 1000 // in pixels
private var baseLine = 950 // in pixels
private var chartWidth = 800 // in pixels
private var chartHeight = 900 // in pixels
private val vertMargin = 50 // in pixels
private val leftMargin = 100 // in pixels
private lateinit var palette : Palette

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

when (wMode) {
MeasureSpec.EXACTLY -> winWidth = wSize
MeasureSpec.AT_MOST -> winWidth = min(winWidth, wSize)
MeasureSpec.UNSPECIFIED -> winWidth = wSize
}
when (hMode) {
MeasureSpec.EXACTLY -> winHeight = hSize
MeasureSpec.AT_MOST -> winHeight = min(winHeight, hSize)
MeasureSpec.UNSPECIFIED -> winHeight = hSize
}

setMeasuredDimension(winWidth, winHeight)
}

override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)

if (data.isEmpty()) return

baseLine = winHeight - vertMargin
chartWidth = winWidth - leftMargin * 2
chartHeight = baseLine - vertMargin * 2
dayWidth = chartWidth / (maxDay - minDay + 1).toInt()
colWidth = dayWidth / (catCount + 1)
// TODO: check colWidth > 0

drawAxis(canvas)
drawData(canvas)
}

private fun drawAxis(canvas: Canvas) {
val linePaint = Paint().apply { color = Color.BLACK }
val textPaint = Paint().apply { textSize = 32f }
var markY = 1f
while (markY*10f <= maxValue) markY *= 10f
var y = (baseLine - vertMargin).toFloat() * markY / maxValue
// draw Y line at x=leftMargin with top vertical arrow
canvas.drawLine(leftMargin.toFloat(), (vertMargin-20).toFloat(), leftMargin.toFloat(), (baseLine+22).toFloat(), linePaint)
canvas.drawLine(leftMargin.toFloat(), (vertMargin-20).toFloat(), (leftMargin-6).toFloat(), vertMargin.toFloat(), linePaint)
canvas.drawLine(leftMargin.toFloat(), (vertMargin-20).toFloat(), (leftMargin+6).toFloat(), vertMargin.toFloat(), linePaint)
// draw origin and scale marks
canvas.drawLine((leftMargin-20).toFloat(), y, leftMargin.toFloat(), y, linePaint)
canvas.drawText("0", (leftMargin-32).toFloat(), (baseLine-8).toFloat(), textPaint)
canvas.drawText(String.format("%4.0f", markY),0f,y-8, textPaint)
// draw X line at y=baseLine with right horizontal arrow
val endX = (chartWidth+leftMargin+24).toFloat()
canvas.drawLine((leftMargin-20).toFloat(), baseLine.toFloat(), endX, baseLine.toFloat(), linePaint)
canvas.drawLine((chartWidth+leftMargin+4).toFloat(), (baseLine-6).toFloat(), endX, baseLine.toFloat(), linePaint)
canvas.drawLine((chartWidth+leftMargin+4).toFloat(), (baseLine+6).toFloat(), endX, baseLine.toFloat(), linePaint)
// draw day marks
textPaint.apply { textSize = 40f }
y = (winHeight-4).toFloat()
for (day in minDay .. maxDay) {
val date = LocalDateTime.ofEpochSecond(day * secsInDay, 0, ZoneOffset.UTC).toLocalDate()
val text = String.format("%02d.%02d.%04d", date.dayOfMonth, date.month.value, date.year)
val x = ((day - minDay) * dayWidth + leftMargin).toFloat()
canvas.drawLine(x + dayWidth, baseLine.toFloat(), x + dayWidth, (baseLine+22).toFloat(), linePaint)
canvas.drawText(text, x + 8, y, textPaint)
}
}

private fun drawData(canvas: Canvas) {
var index = 0
val paint = Paint()
for (cat in data) {
paint.apply {
color = palette.getColor(cat.key)
}
for (day in cat.value) {
val x = ((day.key - minDay) * dayWidth + index * colWidth).toInt() + leftMargin
val y = ((baseLine - vertMargin).toFloat() * day.value / maxValue).toInt()
val r = Rect(x + 1,baseLine - y,x + colWidth, baseLine - 1)
canvas.drawRect(r, paint)
}
++index
}
}

fun setContent(products: List<ProductData>, palette : Palette) {
this.palette = palette
data.clear()
maxValue = 0f
val sorted = products.sortedBy { it.time }
minDay = sorted.first().time / secsInDay
maxDay = sorted.last().time / secsInDay
val catMap = sorted.groupBy { it.category }
for (cat in catMap) {
data.put(cat.key, mutableMapOf())
val dayMap = cat.value.groupBy { it.time / secsInDay }
for (day in dayMap) {
data[cat.key]?.put(day.key, day.value.sumOf { it.amount }.toFloat())
}
var amount = data[cat.key]?.values?.max()
if (amount != null) {
if (amount > maxValue) maxValue = amount
}
}
catCount = data.keys.size
}
}
21 changes: 21 additions & 0 deletions app/src/main/java/otus/homework/customview/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,31 @@ package otus.homework.customview

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken

class MainActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

val products = loadData()
val palette = Palette()
palette.mapColors(products.map { it.category }.distinct())

val pieChartView = findViewById<PieChartView>(R.id.pieChartView)
pieChartView.setContent(products, palette)

val lineChartView = findViewById<LineChartView>(R.id.lineChartView)
lineChartView.setContent(products, palette)
}

private fun loadData() : List<ProductData> {
val file = resources.openRawResource(R.raw.payload)
val content = file.readBytes().decodeToString()
file.close()
val type = object : TypeToken<List<ProductData>>() {}.type
return Gson().fromJson(content, type)
}
}
26 changes: 26 additions & 0 deletions app/src/main/java/otus/homework/customview/Palette.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package otus.homework.customview

import android.graphics.Color

class Palette {
private val colors = arrayOf(
"#BBAACC", "#CCBBAA", "#AACCBB", "#AABBCC", "#BBCCAA", "#CCAABB", "#90B0D0", "#B090D0", "#90D0B0", "#D090B0"
)
private val catColor : MutableMap<String, Int> = mutableMapOf()

fun mapColors(cats : List<String>) {
var index = 0
for (cat in cats) {
catColor[cat] = Color.parseColor(colors[index++ % colors.size])
}
}

fun getColor(cat : String) : Int {
var color : Int? = catColor[cat]
if (color == null) {
color = Color.parseColor(colors[catColor.size % colors.size])
catColor[cat] = color
}
return color
}
}
127 changes: 127 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,127 @@
package otus.homework.customview

import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.RectF
import android.util.AttributeSet
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.View
import android.widget.Toast
import kotlin.math.atan2

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

private var selected : String = ""
private val data : MutableMap<String, Float> = mutableMapOf()
private var total = 0f
private var centerX = 540
private var centerY = 550
private var radius = 400
private val margin = 10
private lateinit var palette : Palette

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

when (wMode) {
MeasureSpec.AT_MOST -> wSize = Integer.min(radius * 2, wSize)
MeasureSpec.UNSPECIFIED -> wSize = radius * 2
}
when (hMode) {
MeasureSpec.AT_MOST -> hSize = Integer.min(radius * 2, hSize)
MeasureSpec.UNSPECIFIED -> hSize = radius * 2
}
setMeasuredDimension(wSize, hSize)
radius = Integer.min(wSize, hSize) / 2
}

override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)

if (data.isEmpty()) return

var angle = 0f
val paint = Paint()
val r = (radius - margin).toFloat()
centerX = canvas.width / 2
centerY = canvas.height / 2
val arcRect = RectF(centerX - r, centerY - r, centerX + r, centerY + r)

for (cat in data) {
val arc = 360f * cat.value / total
paint.apply {
color = palette.getColor(cat.key)
}
canvas.drawArc(arcRect, angle, arc, true, paint)
angle += arc
}
}

fun setContent(products: List<ProductData>, palette : Palette) {
this.palette = palette
data.clear()
val cats = products.groupBy { it.category }
for (cat in cats) {
val sum = cat.value.sumOf { it.amount }.toFloat()
data.put(cat.key, sum)
}
total = data.values.sum()
requestLayout()
invalidate()
}

fun setCategory(s: String) {
if (s.isNotEmpty()) {
selected = s
Toast.makeText(context, s, Toast.LENGTH_SHORT).show()
invalidate()
}
}

fun pointCategory(x: Float, y: Float): String {
var category = ""
val r2 = (x - centerX) * (x - centerX) + (y - centerY) * (y - centerY)
if (r2 < radius * radius) {
var angle = 0f
// convert coordinates
var point = 180f * atan2(y - centerY,x - centerX) / Math.PI
if (y < centerY) point += 360f
// find category sector for point
for (cat in data) {
val arc = 360f * cat.value / total
if (point in angle .. angle + arc) {
category = cat.key
break
}
angle += arc
}
}
return category
}

override fun onTouchEvent(event: MotionEvent): Boolean {
if (event.action == MotionEvent.ACTION_UP) {
super.performClick()
}
return clickDetector.onTouchEvent(event)
}

private val clickDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
override fun onDown(e: MotionEvent): Boolean {
return true
}

override fun onSingleTapUp(e: MotionEvent): Boolean {
setCategory(pointCategory(e.x, e.y))
return true
}
})
}
Loading