From 69bcfd1f44ebc3f9f4b1bcb5b48a796bf954eafb Mon Sep 17 00:00:00 2001 From: NSabirov Date: Wed, 19 Apr 2023 00:43:23 +0300 Subject: [PATCH 1/2] =?UTF-8?q?=D0=97=D0=B0=D0=B4=D0=B0=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?=201.=20PieChart?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle | 40 +-- app/src/main/AndroidManifest.xml | 3 +- .../homework/customview/ColorGenerator.kt | 52 ++++ .../main/java/otus/homework/customview/Ext.kt | 7 + .../otus/homework/customview/MainActivity.kt | 38 ++- .../otus/homework/customview/PieChartView.kt | 231 ++++++++++++++++++ .../homework/customview/models/PieSlice.kt | 17 ++ .../otus/homework/customview/models/Spend.kt | 19 ++ app/src/main/res/layout/activity_main.xml | 4 +- build.gradle | 6 +- gradle/wrapper/gradle-wrapper.properties | 6 +- 11 files changed, 400 insertions(+), 23 deletions(-) create mode 100644 app/src/main/java/otus/homework/customview/ColorGenerator.kt create mode 100644 app/src/main/java/otus/homework/customview/Ext.kt create mode 100644 app/src/main/java/otus/homework/customview/PieChartView.kt create mode 100644 app/src/main/java/otus/homework/customview/models/PieSlice.kt create mode 100644 app/src/main/java/otus/homework/customview/models/Spend.kt diff --git a/app/build.gradle b/app/build.gradle index b4711913..f76aa116 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,20 +1,20 @@ plugins { id 'com.android.application' id 'kotlin-android' + id 'kotlin-parcelize' + id 'kotlin-kapt' } android { - compileSdkVersion 30 + compileSdkVersion 33 buildToolsVersion "30.0.3" defaultConfig { - applicationId "otus.homework.customview" - minSdkVersion 23 - targetSdkVersion 30 + applicationId "com.otus.securehomework" + minSdkVersion 21 + targetSdkVersion 33 versionCode 1 versionName "1.0" - - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildTypes { @@ -22,7 +22,15 @@ android { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } + debug { + debuggable true + } } + + buildFeatures { + viewBinding true + } + compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 @@ -34,12 +42,16 @@ android { 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' - testImplementation 'junit:junit:4.+' - androidTestImplementation 'androidx.test.ext:junit:1.1.2' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' + implementation 'androidx.core:core-ktx:1.10.0' + implementation 'androidx.appcompat:appcompat:1.6.1' + implementation 'androidx.annotation:annotation:1.6.0' + implementation 'com.google.android.material:material:1.8.0' + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation "androidx.fragment:fragment-ktx:1.5.6" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4" + testImplementation 'junit:junit:4.13.2' + androidTestImplementation 'androidx.test.ext:junit:1.1.5' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + implementation 'com.google.code.gson:gson:2.10.1' } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index efd1e519..0af130d3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -10,7 +10,8 @@ android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.CustomView"> - + diff --git a/app/src/main/java/otus/homework/customview/ColorGenerator.kt b/app/src/main/java/otus/homework/customview/ColorGenerator.kt new file mode 100644 index 00000000..fc33d12f --- /dev/null +++ b/app/src/main/java/otus/homework/customview/ColorGenerator.kt @@ -0,0 +1,52 @@ +package otus.homework.customview + + +import android.graphics.Color +import androidx.annotation.ColorInt +import java.util.* + +object ColorGenerator { + + @ColorInt + fun generateColor(): Int { + val rnd = Random() + return Color.argb(255, rnd.nextInt(256), rnd.nextInt(256), rnd.nextInt(256)) + } + + fun generatePalette( + size: Int, + baseColor: Int, + adjacentColors: Boolean + ): IntArray { + val colors = IntArray(size) + colors[0] = baseColor + + val hsv = FloatArray(3) + Color.RGBToHSV( + Color.red(baseColor), Color.green(baseColor), Color.blue(baseColor), + hsv + ) + + val step = 240.0 / size + val baseHue = hsv[0] + + for (i in 1 until size) { + val nextColorHue = (baseHue + step * i) % 240.0 + colors[i] = Color.HSVToColor(floatArrayOf(nextColorHue.toFloat(), hsv[1], hsv[2])) + } + + if (!adjacentColors && size > 2) { + var i = 0 + var j = size / 2 + + while (j < size) { + colors[i] = colors[j].also { colors[j] = colors[i] } + + i += 2 + j += 2 + } + } + + return colors + } +} \ No newline at end of file diff --git a/app/src/main/java/otus/homework/customview/Ext.kt b/app/src/main/java/otus/homework/customview/Ext.kt new file mode 100644 index 00000000..e627db34 --- /dev/null +++ b/app/src/main/java/otus/homework/customview/Ext.kt @@ -0,0 +1,7 @@ +package otus.homework.customview + +import android.content.res.Resources +import android.util.DisplayMetrics + +fun Int.dpToPx(res: Resources): Int = + (this.toFloat() * (res.displayMetrics.densityDpi.toFloat() / DisplayMetrics.DENSITY_DEFAULT)).toInt() diff --git a/app/src/main/java/otus/homework/customview/MainActivity.kt b/app/src/main/java/otus/homework/customview/MainActivity.kt index 78cb9448..e0e83014 100644 --- a/app/src/main/java/otus/homework/customview/MainActivity.kt +++ b/app/src/main/java/otus/homework/customview/MainActivity.kt @@ -1,11 +1,47 @@ package otus.homework.customview -import androidx.appcompat.app.AppCompatActivity +import android.graphics.Color +import android.os.Build import android.os.Bundle +import androidx.annotation.RequiresApi +import androidx.appcompat.app.AppCompatActivity +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.google.gson.reflect.TypeToken +import otus.homework.customview.models.PieSlice +import otus.homework.customview.models.Spend +import java.io.IOException +import java.io.InputStream +import java.io.Reader +import java.nio.file.Files +import java.nio.file.Paths class MainActivity : AppCompatActivity() { + @RequiresApi(Build.VERSION_CODES.O) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) + val list = readJson() + + val chart = findViewById(R.id.chart) + chart.setItems(list) + } + + private fun readJson(): List { + return try { + var json : String? = null + val inputStream: InputStream = resources.openRawResource( + resources.getIdentifier("payload", + "raw", packageName + )) + + json = inputStream.bufferedReader().use { it.readText() } + val itemType = object : TypeToken>() {}.type + val gson = GsonBuilder().create() + gson.fromJson>(json, itemType) + + } catch (e: IOException) { + emptyList() + } } } \ 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..f50d3a37 --- /dev/null +++ b/app/src/main/java/otus/homework/customview/PieChartView.kt @@ -0,0 +1,231 @@ +package otus.homework.customview + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.RectF +import android.os.Parcel +import android.os.Parcelable +import android.util.AttributeSet +import android.view.GestureDetector +import android.view.MotionEvent +import android.view.View +import otus.homework.customview.models.PieSlice +import otus.homework.customview.models.Spend +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.atan2 +import kotlin.math.hypot + +class PieChartView(context: Context, attrs: AttributeSet?) : View(context, attrs) { + private var minPieChartSize = 200.dpToPx(context.resources) + + private var rectF: RectF = RectF() + + private var strokeSize = 16.dpToPx(context.resources).toFloat() + private var clickedIndex = -1 + + private val slicePaint: Paint = Paint().apply { + isAntiAlias = true + isDither = true + style = Paint.Style.STROKE + strokeWidth = strokeSize + } + + private val textPaint: Paint = Paint().apply { + isAntiAlias = true + textSize = strokeSize + color = Color.BLACK + textAlign = Paint.Align.CENTER + } + + private var weightsAndColors: ArrayList = arrayListOf() + private var items = emptyList() + + private val outerRect = RectF() + private val innerRect = RectF() + + fun setItems(list: List) { + items = list + initWeightsAndColors() + } + + private fun initWeightsAndColors() { + clearChart() + val groupedList = items.groupBy { it.category } + val total = items.fold(0) { acc, item -> acc.plus(item.amount) } + val colors = ColorGenerator.generatePalette(items.size, Color.BLUE, false) + val weights = groupedList.map { setItem -> + setItem.value.fold(0) { acc, item -> acc.plus(item.amount) }.times(100f).div(total) + } + val totalAmounts = groupedList.map { setItem -> + setItem.value.fold(0) { acc, item -> acc.plus(item.amount) } + } + weights.forEachIndexed { i, item -> + weightsAndColors.add(PieSlice(item, colors[i], totalAmount = totalAmounts[i])) + } + } + + private fun clearChart() { + weightsAndColors.clear() + invalidate() + } + + private val generalGestureDetector = + GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() { + override fun onDown(e: MotionEvent): Boolean { + return true + } + + override fun onSingleTapConfirmed(event: MotionEvent): Boolean { + return handleViewClick(event) + } + }) + + override fun onTouchEvent(event: MotionEvent): Boolean { + return generalGestureDetector.onTouchEvent(event) + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val chartWidth = + Integer.max(minPieChartSize, suggestedMinimumWidth + paddingLeft + paddingRight) + val chartHeight = + Integer.max(minPieChartSize, suggestedMinimumHeight + paddingTop + paddingBottom) + + val size = Integer.min( + resolveSize(chartWidth, widthMeasureSpec), + resolveSize(chartHeight, heightMeasureSpec) + ) + + setMeasuredDimension(size, size) + } + + override fun onDraw(canvas: Canvas) { + val startTop = 0f + val startLeft = 0f + val endBottom = height.toFloat() + val rightBottom = height.toFloat() + rectF.set( + startLeft+strokeSize, startTop+strokeSize, endBottom-strokeSize, + rightBottom-strokeSize + ) + outerRect.set( + startLeft, + startTop, + endBottom, + rightBottom + ) + innerRect.set(rectF) + innerRect.inset(width *0.1f, height *0.1f) + + if (weightsAndColors.size > 0) { + val scaledValues = scale() + var sliceStartPoint = 0f//(width / 2).toFloat() + for (i in 0 until weightsAndColors.size) { + weightsAndColors[i].fromAngle = sliceStartPoint + weightsAndColors[i].toAngle = scaledValues[i] + slicePaint.color = + weightsAndColors[i].color + canvas.drawArc( + rectF, + sliceStartPoint, + scaledValues[i], + false, + slicePaint.apply { + strokeWidth = if(clickedIndex == i){ + strokeSize+(strokeSize/2) + }else{ + strokeSize + } + } + ) + if (clickedIndex == i){ + canvas.drawText(weightsAndColors[i].totalAmount.toString(), width/2f, width/2f, textPaint) + } + sliceStartPoint += scaledValues[i] + } + + } + } + + override fun onSaveInstanceState(): Parcelable { + val superState = super.onSaveInstanceState() + + val savedState = SavedState(superState) + + savedState.selectedIndex = clickedIndex + savedState.itemsList = items + + return savedState + } + + override fun onRestoreInstanceState(state: Parcelable?) { + if (state !is SavedState) { + super.onRestoreInstanceState(state) + return + } + + super.onRestoreInstanceState(state.superState) + + items = state.itemsList + clickedIndex = state.selectedIndex + initWeightsAndColors() + } + + private fun scale(): FloatArray { + val scaledValues = FloatArray(weightsAndColors.size) + for (i in weightsAndColors.indices) { + scaledValues[i] = + (weightsAndColors[i].weight * 360f / 100f) + } + return scaledValues + } + + private fun handleViewClick(event: MotionEvent): Boolean { + val innerRadius = innerRect.width() / 2f + val outerRadius = outerRect.width() / 2f + + val zeroX = event.x - innerRect.centerX() + val zeroY = -(event.y - innerRect.centerY()) + + val eventRadius = hypot(zeroX, zeroY) + + return if (eventRadius in innerRadius..outerRadius) { + val eventAngleRad = atan2(zeroY, zeroX).let { + if (it < 0) abs(it) else 2 * PI - it + }.toDouble() + + val eventAngleDegrees = Math.toDegrees(eventAngleRad) + weightsAndColors.forEachIndexed{index, item -> + if (item.isIn(eventAngleDegrees)){ + clickedIndex = index + invalidate() + } + } + true + } else { + false + } + } + + internal class SavedState : BaseSavedState { + var itemsList: List = arrayListOf() + var selectedIndex: Int = -1 + + constructor(superState: Parcelable?) : super(superState) + + private constructor(parcel: Parcel) : super(parcel) { + itemsList = listOf() + parcel.readList(itemsList, List::class.java.classLoader) + selectedIndex = parcel.readInt() + + } + + override fun writeToParcel(out: Parcel, flags: Int) { + super.writeToParcel(out, flags) + out.writeList(itemsList) + out.writeInt(selectedIndex) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/otus/homework/customview/models/PieSlice.kt b/app/src/main/java/otus/homework/customview/models/PieSlice.kt new file mode 100644 index 00000000..fd4320d5 --- /dev/null +++ b/app/src/main/java/otus/homework/customview/models/PieSlice.kt @@ -0,0 +1,17 @@ +package otus.homework.customview.models + +data class PieSlice( + var weight: Float, + val color: Int, + var fromAngle: Float? = null, + var toAngle: Float? = null, + val totalAmount: Int +) { + fun isIn(angle: Double): Boolean { + return if (fromAngle != null && toAngle != null) { + angle in fromAngle!!..(fromAngle!! + toAngle!!) + } else { + false + } + } +} \ No newline at end of file diff --git a/app/src/main/java/otus/homework/customview/models/Spend.kt b/app/src/main/java/otus/homework/customview/models/Spend.kt new file mode 100644 index 00000000..4f4922cb --- /dev/null +++ b/app/src/main/java/otus/homework/customview/models/Spend.kt @@ -0,0 +1,19 @@ +package otus.homework.customview.models + +import android.os.Parcelable +import com.google.gson.annotations.SerializedName +import kotlinx.parcelize.Parcelize + +@Parcelize +data class Spend( + @SerializedName("id") + val id: Int, + @SerializedName("name") + val name: String, + @SerializedName("amount") + val amount: Int, + @SerializedName("category") + val category: String, + @SerializedName("time") + val time: Int +): Parcelable \ 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..9c585aa0 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -7,10 +7,10 @@ android:layout_height="match_parent" tools:context=".MainActivity"> - Date: Wed, 19 Apr 2023 03:23:51 +0300 Subject: [PATCH 2/2] =?UTF-8?q?=D0=97=D0=B0=D0=B4=D0=B0=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?=202.=20LineChart?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle | 1 + .../otus/homework/customview/LineChartView.kt | 143 ++++++++++++++++++ .../otus/homework/customview/MainActivity.kt | 64 ++++++-- app/src/main/res/layout/activity_main.xml | 32 +++- 4 files changed, 228 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/otus/homework/customview/LineChartView.kt diff --git a/app/build.gradle b/app/build.gradle index f76aa116..bfdfd4e3 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -54,4 +54,5 @@ dependencies { androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' implementation 'com.google.code.gson:gson:2.10.1' + implementation 'joda-time:joda-time:2.12.5' } \ No newline at end of file diff --git a/app/src/main/java/otus/homework/customview/LineChartView.kt b/app/src/main/java/otus/homework/customview/LineChartView.kt new file mode 100644 index 00000000..2492fb6b --- /dev/null +++ b/app/src/main/java/otus/homework/customview/LineChartView.kt @@ -0,0 +1,143 @@ +package otus.homework.customview + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.RectF +import android.os.Parcel +import android.os.Parcelable +import android.util.AttributeSet +import android.view.View +import otus.homework.customview.models.Spend + +class LineChartView(context: Context, attrs: AttributeSet?) : View(context, attrs) { + private var minChartSizeX = 400.dpToPx(context.resources) + private var minChartSizeY = 400.dpToPx(context.resources) + + private var groupedItems: Map> = emptyMap() + + private var rectF: RectF = RectF() + private val strokeSize = 16.dpToPx(context.resources).toFloat() + + private val axisPaint = Paint().apply { + isAntiAlias = true + isDither = true + style = Paint.Style.STROKE + color = Color.BLACK + strokeWidth = strokeSize + } + private val linePaint = Paint().apply { + isAntiAlias = true + isDither = true + style = Paint.Style.STROKE + color = Color.BLUE + strokeWidth = strokeSize / 4 + } + private val dotPaint = Paint().apply { + isAntiAlias = true + isDither = true + style = Paint.Style.FILL_AND_STROKE + color = Color.BLUE + strokeWidth = strokeSize / 2 + } + + private var selectedIndex = -1 + + fun setItems(list: Map>) { + groupedItems = list + } + + fun setSelectedIndex(i: Int){ + selectedIndex = i + invalidate() + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val chartWidth = + Integer.max(minChartSizeX, suggestedMinimumWidth + paddingLeft + paddingRight) + val chartHeight = + Integer.max(minChartSizeY, suggestedMinimumHeight + paddingTop + paddingBottom) + + val sizeX = resolveSize(chartWidth, widthMeasureSpec) + val sizeY = resolveSize(chartHeight, heightMeasureSpec) + + setMeasuredDimension(sizeX, sizeY) + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + val startTop = 0f + val startLeft = 0f + val endBottom = height.toFloat() + val rightBottom = height.toFloat() + rectF.set( + startLeft + strokeSize, startTop, endBottom, + rightBottom - strokeSize + ) + canvas.drawLine(0f, 0f, 0f, height.toFloat(), axisPaint) + canvas.drawLine(0f, height.toFloat(), width.toFloat(), height.toFloat(), axisPaint) + if (selectedIndex != -1 && groupedItems.isNotEmpty()) { + val key = groupedItems.keys.toList()[selectedIndex] + val item = groupedItems[key] + if (item?.isNotEmpty() == true) { + val maxTime = item.maxBy{ it.time } + val maxAmount = item.maxBy { it.amount } + var startX = 0f + var startY = height.toFloat() + item.forEach { + val pxX = width.toFloat()*it.time/maxTime.time + val pxY = height.toFloat()*it.amount/maxAmount.amount + canvas.drawLine(startX, startY, pxX, pxY, linePaint) + canvas.drawPoint(pxX, pxY, dotPaint) + startX = pxX + startY = pxY + } + } + } + } + + override fun onSaveInstanceState(): Parcelable { + val superState = super.onSaveInstanceState() + + val savedState = SavedState(superState) + + savedState.selectedIndex = selectedIndex + savedState.groupedItems = groupedItems + + return savedState + } + + override fun onRestoreInstanceState(state: Parcelable?) { + if (state !is SavedState) { + super.onRestoreInstanceState(state) + return + } + + super.onRestoreInstanceState(state.superState) + + groupedItems = state.groupedItems + selectedIndex = state.selectedIndex + invalidate() + } + + internal class SavedState : BaseSavedState { + var groupedItems: Map> = emptyMap() + var selectedIndex: Int = -1 + + constructor(superState: Parcelable?) : super(superState) + + private constructor(parcel: Parcel) : super(parcel) { + groupedItems = emptyMap() + parcel.readMap(groupedItems, Map::class.java.classLoader) + selectedIndex = parcel.readInt() + + } + + override fun writeToParcel(out: Parcel, flags: Int) { + super.writeToParcel(out, flags) + out.writeMap(groupedItems) + out.writeInt(selectedIndex) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/otus/homework/customview/MainActivity.kt b/app/src/main/java/otus/homework/customview/MainActivity.kt index e0e83014..76f728ce 100644 --- a/app/src/main/java/otus/homework/customview/MainActivity.kt +++ b/app/src/main/java/otus/homework/customview/MainActivity.kt @@ -1,39 +1,81 @@ package otus.homework.customview -import android.graphics.Color import android.os.Build import android.os.Bundle +import android.view.View +import android.widget.Button +import android.widget.TextView import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatActivity -import com.google.gson.Gson import com.google.gson.GsonBuilder import com.google.gson.reflect.TypeToken -import otus.homework.customview.models.PieSlice import otus.homework.customview.models.Spend import java.io.IOException import java.io.InputStream -import java.io.Reader -import java.nio.file.Files -import java.nio.file.Paths class MainActivity : AppCompatActivity() { + private var index = 0 + @RequiresApi(Build.VERSION_CODES.O) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) + val textView = findViewById(R.id.tvCategory) + val btnPrev = findViewById