From c00f86da4da30f2a9cfdfabc1882e8da54352e90 Mon Sep 17 00:00:00 2001 From: niku Date: Tue, 22 Aug 2023 01:51:17 +0300 Subject: [PATCH 1/2] Task 1 --- app/build.gradle | 5 +- app/src/main/AndroidManifest.xml | 3 +- app/src/main/assets/payload.json | 86 ++++ .../homework/customview/BaseChartState.kt | 8 + .../homework/customview/BaseValueModel.kt | 3 + .../otus/homework/customview/Extensions.kt | 20 + .../otus/homework/customview/FileUtils.kt | 24 ++ .../otus/homework/customview/MainActivity.kt | 28 +- .../otus/homework/customview/PayLoadModel.kt | 9 + .../otus/homework/customview/PieChartModel.kt | 48 +++ .../otus/homework/customview/PieChartView.kt | 390 ++++++++++++++++++ app/src/main/res/layout/activity_main.xml | 13 +- app/src/main/res/values/attr.xml | 7 + app/src/main/res/values/strings.xml | 12 + 14 files changed, 645 insertions(+), 11 deletions(-) create mode 100644 app/src/main/assets/payload.json create mode 100644 app/src/main/java/otus/homework/customview/BaseChartState.kt create mode 100644 app/src/main/java/otus/homework/customview/BaseValueModel.kt create mode 100644 app/src/main/java/otus/homework/customview/Extensions.kt create mode 100644 app/src/main/java/otus/homework/customview/FileUtils.kt create mode 100644 app/src/main/java/otus/homework/customview/PayLoadModel.kt create mode 100644 app/src/main/java/otus/homework/customview/PieChartModel.kt create mode 100644 app/src/main/java/otus/homework/customview/PieChartView.kt create mode 100644 app/src/main/res/values/attr.xml diff --git a/app/build.gradle b/app/build.gradle index b4711913..4cbcd771 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -4,13 +4,13 @@ plugins { } android { - compileSdkVersion 30 + compileSdkVersion 33 buildToolsVersion "30.0.3" defaultConfig { applicationId "otus.homework.customview" minSdkVersion 23 - targetSdkVersion 30 + targetSdkVersion 33 versionCode 1 versionName "1.0" @@ -42,4 +42,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.8.9' } \ 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/assets/payload.json b/app/src/main/assets/payload.json new file mode 100644 index 00000000..5492267a --- /dev/null +++ b/app/src/main/assets/payload.json @@ -0,0 +1,86 @@ +[ + { + "id": 1, + "name": "Азбука Вкуса", + "amount": 1580, + "category": "Продукты", + "time": 1623318531 + }, + { + "id": 2, + "name": "Ригла", + "amount": 499, + "category": "Здоровье", + "time": 1623322251 + }, + { + "id": 3, + "name": "Пятерочка", + "amount": 129, + "category": "Продукты", + "time": 1623322371 + }, + { + "id": 4, + "name": "Truffo", + "amount": 4541, + "category": "Кафе и рестораны", + "time": 1623326031 + }, + { + "id": 5, + "name": "Simple Wine", + "amount": 1600, + "category": "Алкоголь", + "time": 1623329631 + }, + { + "id": 6, + "name": "Азбука Вкуса Экспресс", + "amount": 1841, + "category": "Доставка еды", + "time": 1623322371 + }, + { + "id": 7, + "name": "Uber", + "amount": 369, + "category": "Транспорт", + "time": 1623416031 + }, + { + "id": 8, + "name": "Метро", + "amount": 100, + "category": "Транспорт", + "time": 1623416211 + }, + { + "id": 9, + "name": "Стоматология", + "amount": 8000, + "category": "Здоровье", + "time": 1623419811 + }, + { + "id": 10, + "name": "Пятерочка", + "amount": 809, + "category": "Продукты", + "time": 1623419934 + }, + { + "id": 11, + "name": "Бассейн", + "amount": 1000, + "category": "Спорт", + "time": 1623419934 + }, + { + "id": 12, + "name": "Uber", + "amount": 389, + "category": "Транспорт", + "time": 1623419934 + } +] \ No newline at end of file diff --git a/app/src/main/java/otus/homework/customview/BaseChartState.kt b/app/src/main/java/otus/homework/customview/BaseChartState.kt new file mode 100644 index 00000000..5a532b13 --- /dev/null +++ b/app/src/main/java/otus/homework/customview/BaseChartState.kt @@ -0,0 +1,8 @@ +package otus.homework.customview + +import android.os.Parcelable +import android.view.View + +class BaseChartState( + private val superSavedState: Parcelable?, + val dataList: List): View.BaseSavedState(superSavedState), Parcelable \ No newline at end of file diff --git a/app/src/main/java/otus/homework/customview/BaseValueModel.kt b/app/src/main/java/otus/homework/customview/BaseValueModel.kt new file mode 100644 index 00000000..2fba93e2 --- /dev/null +++ b/app/src/main/java/otus/homework/customview/BaseValueModel.kt @@ -0,0 +1,3 @@ +package otus.homework.customview + +abstract class BaseValueModel \ No newline at end of file diff --git a/app/src/main/java/otus/homework/customview/Extensions.kt b/app/src/main/java/otus/homework/customview/Extensions.kt new file mode 100644 index 00000000..cb00ec42 --- /dev/null +++ b/app/src/main/java/otus/homework/customview/Extensions.kt @@ -0,0 +1,20 @@ +package otus.homework.customview + +import android.content.Context +import android.graphics.Canvas +import android.text.StaticLayout +import android.util.TypedValue +import androidx.core.graphics.withTranslation + + +fun Context.dpToPixels(dp: Int) = dp * this.resources.displayMetrics.density + +fun Context.spToPixels(sp: Int) = + TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_SP, sp.toFloat(), this.resources.displayMetrics) + +fun StaticLayout.draw(canvas: Canvas, x: Float, y: Float) { + canvas.withTranslation(x, y) { + draw(this) + } +} \ No newline at end of file diff --git a/app/src/main/java/otus/homework/customview/FileUtils.kt b/app/src/main/java/otus/homework/customview/FileUtils.kt new file mode 100644 index 00000000..35dd5417 --- /dev/null +++ b/app/src/main/java/otus/homework/customview/FileUtils.kt @@ -0,0 +1,24 @@ +package otus.homework.customview + +import android.content.Context +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken + +class FileUtils { + + object AssetsLoader { + + fun loadTextFromAsset(context: Context, file: String): String { + return context.assets.open(file).bufferedReader().use { reader -> + reader.readText() + } + } + + fun getDataFromText(text: String): List { + val listPayLoadModelType = object : TypeToken>() {}.type + val payloads: List = Gson().fromJson(text, listPayLoadModelType) + return payloads + } + + } +} \ 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 78cb9448..8ed52bc1 100644 --- a/app/src/main/java/otus/homework/customview/MainActivity.kt +++ b/app/src/main/java/otus/homework/customview/MainActivity.kt @@ -1,11 +1,35 @@ package otus.homework.customview -import androidx.appcompat.app.AppCompatActivity import android.os.Bundle +import android.util.Log +import androidx.appcompat.app.AppCompatActivity + +class MainActivity : AppCompatActivity(), PieChartView.Callbacks { + + private lateinit var pieChart: PieChartView -class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) + pieChart = findViewById(R.id.PieChartView) + //DataRepository.initialize() + val filename = "payload.json" + val jsonText = FileUtils.AssetsLoader.loadTextFromAsset(applicationContext, filename) + val dataList = FileUtils.AssetsLoader.getDataFromText(jsonText) + val categoryList = dataList.groupingBy { it.category } + .reduce { _, acc, element -> + PayLoadModel(0, "", acc.amount + element.amount, acc.category) + } + .values.toList() + updateUi(dataList = categoryList) + } + + private fun updateUi(dataList: List) { + pieChart.setDataChart(dataList) + pieChart.startAnimation() + } + + override fun onSectorSelected(valueModel: BaseValueModel) { + Log.d("MA", "sector ${(valueModel as PayLoadModel).category} clicked") } } \ No newline at end of file diff --git a/app/src/main/java/otus/homework/customview/PayLoadModel.kt b/app/src/main/java/otus/homework/customview/PayLoadModel.kt new file mode 100644 index 00000000..2b6b112b --- /dev/null +++ b/app/src/main/java/otus/homework/customview/PayLoadModel.kt @@ -0,0 +1,9 @@ +package otus.homework.customview + +class PayLoadModel ( + var id: Int = 0, + var name: String = "", + var amount: Int = 0, + var category: String = "", + //var time: Timestamp +): BaseValueModel() \ No newline at end of file diff --git a/app/src/main/java/otus/homework/customview/PieChartModel.kt b/app/src/main/java/otus/homework/customview/PieChartModel.kt new file mode 100644 index 00000000..7a0faf4c --- /dev/null +++ b/app/src/main/java/otus/homework/customview/PieChartModel.kt @@ -0,0 +1,48 @@ +package otus.homework.customview + +import android.graphics.Color +import android.graphics.Paint + +class PieChartModel( + var percentToStartAt: Float = 0F, + var percentOfCircle: Float = 0F, + var absPercentOfCircle: Float = 0F, + var colorOfLine: Int = 0, + var valueModel: BaseValueModel + ) { + + var paint: Paint + + init { + + if (percentOfCircle < 0 || percentOfCircle > 100) { + percentOfCircle = 100F + } + + percentOfCircle = 360 * percentOfCircle / 100 + + if (percentToStartAt < 0 || percentToStartAt > 100) { + percentToStartAt = 0F + } + + percentToStartAt = 360 * percentToStartAt / 100 + + if (absPercentOfCircle < 0 || absPercentOfCircle > 100) { + absPercentOfCircle = 100F + } + + absPercentOfCircle = 360 * absPercentOfCircle / 100 + + if (colorOfLine == 0) { + colorOfLine = Color.parseColor("#000000") + } + + paint = Paint() + paint.color = colorOfLine + paint.isAntiAlias = true + paint.style = Paint.Style.FILL_AND_STROKE + paint.isDither = true + + } + +} \ 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..c49bbe15 --- /dev/null +++ b/app/src/main/java/otus/homework/customview/PieChartView.kt @@ -0,0 +1,390 @@ +package otus.homework.customview + +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.* +import android.os.Parcelable +import android.text.* +import android.util.AttributeSet +import android.util.Log +import android.view.MotionEvent +import android.view.View +import androidx.interpolator.view.animation.FastOutSlowInInterpolator +import kotlin.math.atan2 +import kotlin.math.pow +import kotlin.math.sqrt + +const val TAG = "PieChartView" + +class PieChartView(context: Context, attributeSet: AttributeSet): + View(context, attributeSet) { + + companion object { + private const val DEFAULT_MARGIN_TEXT_1 = 2 + private const val DEFAULT_MARGIN_TEXT_2 = 10 + private const val DEFAULT_MARGIN_TEXT_3 = 2 + private const val DEFAULT_MARGIN_SMALL_CIRCLE = 12 + private const val TEXT_WIDTH_PERCENT = 0.40 + private const val CIRCLE_WIDTH_PERCENT = 0.50 + private const val DEFAULT_VIEW_SIZE_HEIGHT = 150 + private const val DEFAULT_VIEW_SIZE_WIDTH = 250 + } + + private var marginTextFirst: Float = context.dpToPixels(DEFAULT_MARGIN_TEXT_1) + private var marginTextSecond: Float = context.dpToPixels(DEFAULT_MARGIN_TEXT_2) + private var marginTextThird: Float = context.dpToPixels(DEFAULT_MARGIN_TEXT_3) + private var marginSmallCircle: Float = context.dpToPixels(DEFAULT_MARGIN_SMALL_CIRCLE) + private val marginText: Float = marginTextFirst + marginTextSecond + private val circleRect = RectF() + private var circleStrokeWidth: Float = context.dpToPixels(5) + private var circleRadius: Float = 0F + private var circlePadding: Float = context.dpToPixels(5) + private var circleSectionSpace: Float = 0F + private var circleCenterX: Float = 0F + private var circleCenterY: Float = 0F + private var numberTextPaint: TextPaint = TextPaint() + private var descriptionTextPain: TextPaint = TextPaint() + private var amountTextPaint: TextPaint = TextPaint() + private var textStartX: Float = 0F + private var textStartY: Float = 0F + private var textHeight: Int = 0 + private var textCircleRadius: Float = context.dpToPixels(4) + private var textAmountStr: String = "" + private var textAmountY: Float = 0F + private var textAmountXNumber: Float = 0F + private var textAmountXDescription: Float = 0F + private var textAmountYDescription: Float = 0F + private var totalAmount: Int = 0 + private var pieChartColors: List = listOf() + private var percentageCircleList: List = listOf() + private var textRowList: MutableList = mutableListOf() + private var dataList: List = listOf() + private var animationSweepAngle: Int = 0 + private val touchMargin: Float = 10F // отступ от окружности при касании, + // на которой всё еще будет срабатывать тачивент + + interface Callbacks { + fun onSectorSelected(valueModel: BaseValueModel) + } + + private var callbacks: Callbacks? = null + + init { + // Задаем базовые значения и конвертируем в px + val textAmountSize: Float = context.spToPixels(10) + val textNumberSize: Float = context.spToPixels(5) + val textDescriptionSize: Float = context.spToPixels(14) + val textAmountColor: Int = Color.WHITE + val textNumberColor: Int = Color.WHITE + val textDescriptionColor: Int = Color.GRAY + + val typeArray = context.obtainStyledAttributes(attributeSet, R.styleable.PieChartView) + + // Секция списка цветов + val colorResId = + typeArray.getResourceId(R.styleable.PieChartView_pieChartColors, 0) + pieChartColors = typeArray.resources.getStringArray(colorResId).toList() + + circlePadding += circleStrokeWidth + + initPains(amountTextPaint, textAmountSize, textAmountColor) + initPains(numberTextPaint, textNumberSize, textNumberColor) + initPains(descriptionTextPain, textDescriptionSize, textDescriptionColor, true) + + typeArray.recycle() + + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + callbacks = context as Callbacks? + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + callbacks = null + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + + textRowList.clear() + + val initSizeWidth = resolveDefaultSize(widthMeasureSpec, DEFAULT_VIEW_SIZE_WIDTH) + + val textTextWidth = (initSizeWidth * TEXT_WIDTH_PERCENT) + val initSizeHeight = calculateViewHeight(heightMeasureSpec, textTextWidth.toInt()) + + textStartX = initSizeWidth - textTextWidth.toFloat() + textStartY = initSizeHeight.toFloat() / 2 - textHeight / 2 + + calculateCircleRadius(initSizeWidth, initSizeHeight) + + setMeasuredDimension(initSizeWidth, initSizeHeight) + } + + override fun onRestoreInstanceState(state: Parcelable?) { + val baseChartState = state as? BaseChartState + super.onRestoreInstanceState(baseChartState?.superState ?: state) + + dataList = baseChartState?.dataList ?: listOf() + } + + override fun onSaveInstanceState(): Parcelable { + val superState = super.onSaveInstanceState() + return BaseChartState(superState, dataList) + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + when (event.action) { + MotionEvent.ACTION_DOWN -> { + // обработка нажатия пальца на экран + val segmentLength = sqrt( + (event.x - this.circleCenterX).toDouble().pow(2.0) + + (event.y - this.circleCenterY).toDouble().pow(2.0) + ) + if (circleRect.contains(event.x, event.y) + && segmentLength <= this.circleRadius + touchMargin) { + + // прямоугольник (квадрат), в который вписана окружность, содержит координаты клика + // и длина отрезка от центра окружности до точки клика меньше или равна + // радиусу окружности + разрешенный промах touchMargin + + //Log.d(TAG, "${this.circleRect.contains(event.x, event.y)}") + //Log.d(TAG, "r=$r, circleRadius = ${this.circleRadius}, r + angle in a.percentToStartAt..a.absPercentOfCircle } + Log.d(TAG, + "${touchedSector?.percentToStartAt} to ${touchedSector?.absPercentOfCircle}") + if (touchedSector != null) { + callbacks?.onSectorSelected(touchedSector.valueModel) + } + } + return true + } + MotionEvent.ACTION_MOVE -> { + // обработка перемещения пальца по экрану + return true + } + MotionEvent.ACTION_UP -> { + // обработка отпускания пальца от экрана + return true + } + } + return super.onTouchEvent(event) + } + + override fun onDraw(canvas: Canvas) { + + super.onDraw(canvas) + + drawCircle(canvas) + drawText(canvas) + + } + + private fun initPains(textPaint: TextPaint, textSize: Float, textColor: Int, isDescription: Boolean = false) { + textPaint.color = textColor + textPaint.textSize = textSize + textPaint.isAntiAlias = true + + if (!isDescription) textPaint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + } + + fun setDataChart(list: List) { + dataList = list + calculatePercentageOfData() + } + + private fun calculatePercentageOfData() { + + totalAmount = dataList.sumOf { payLoadItem -> payLoadItem.amount } + + var startAt = circleSectionSpace + percentageCircleList = dataList.mapIndexed { index, payLoad -> + var percent = payLoad.amount * 100 / totalAmount.toFloat() + percent = if (percent < 0F) 0F else percent + + val resultModel = PieChartModel( + percentToStartAt = startAt, + percentOfCircle = percent, + absPercentOfCircle = startAt + percent, + colorOfLine = Color.parseColor(pieChartColors[index % pieChartColors.size]), + valueModel = payLoad + ) + if (percent != 0F) startAt += percent + circleSectionSpace + resultModel + } + } + + private fun getMultilineText( + text: CharSequence, + textPaint: TextPaint, + width: Int, + start: Int = 0, + end: Int = text.length, + alignment: Layout.Alignment = Layout.Alignment.ALIGN_NORMAL, + textDir: TextDirectionHeuristic = TextDirectionHeuristics.LTR, + spacingMult: Float = 1f, + spacingAdd: Float = 0f) : StaticLayout { + + return StaticLayout.Builder + .obtain(text, start, end, textPaint, width) + .setAlignment(alignment) + .setTextDirection(textDir) + .setLineSpacing(spacingAdd, spacingMult) + .build() + } + + private fun drawCircle(canvas: Canvas) { + + for(percent in percentageCircleList) { + + if (animationSweepAngle > percent.percentToStartAt + percent.percentOfCircle) { + canvas.drawArc( + circleRect, + percent.percentToStartAt, + percent.percentOfCircle, + true, + percent.paint) + } else if (animationSweepAngle > percent.percentToStartAt) { + canvas.drawArc( + circleRect, + percent.percentToStartAt, + animationSweepAngle - percent.percentToStartAt, + true, + percent.paint) + } + + } + } + + private fun drawText(canvas: Canvas) { + + var textBuffY = textStartY + + textRowList.forEachIndexed { index, staticLayout -> + if (index % 2 == 0) { + staticLayout.draw(canvas, textStartX + marginSmallCircle + textCircleRadius, textBuffY) + canvas.drawCircle( + textStartX + marginSmallCircle / 2, + textBuffY + staticLayout.height / 2 + textCircleRadius / 2, + textCircleRadius, + Paint().apply { + color = Color.parseColor(pieChartColors[(index / 2) % pieChartColors.size]) + } + ) + // Прибавим высоту и отступ к Y + textBuffY += staticLayout.height + marginTextFirst + } else { + // Описание значения + staticLayout.draw(canvas, textStartX, textBuffY) + textBuffY += staticLayout.height + marginTextSecond + } + } + + } + + fun startAnimation() { + val animator = ValueAnimator.ofInt(0, 360).apply { + duration = 1000 // длительность 1.0 секунда + interpolator = FastOutSlowInInterpolator() + addUpdateListener { valueAnimator -> + animationSweepAngle = valueAnimator.animatedValue as Int + invalidate() + } + } + animator.start() + } + + private fun calculateCircleRadius(width: Int, height: Int) { + + val circleViewWidth = (width * CIRCLE_WIDTH_PERCENT) + circleRadius = if (circleViewWidth > height) { + (height.toFloat() - circlePadding) / 2 + } else { + circleViewWidth.toFloat() / 2 + } + + with(circleRect) { + left = circlePadding + top = height / 2 - circleRadius + right = circleRadius * 2 + circlePadding + bottom = height / 2 + circleRadius + } + + circleCenterX = (circleRadius * 2 + circlePadding + circlePadding) / 2 + circleCenterY = (height / 2 + circleRadius + (height / 2 - circleRadius)) / 2 + + textAmountY = circleCenterY + + val sizeTextAmountNumber = getWidthOfAmountText( + totalAmount.toString(), + amountTextPaint + ) + + textAmountXNumber = circleCenterX - sizeTextAmountNumber.width() / 2 + textAmountXDescription = circleCenterX - getWidthOfAmountText(textAmountStr, descriptionTextPain).width() / 2 + textAmountYDescription = circleCenterY + sizeTextAmountNumber.height() + marginTextThird + } + + private fun getWidthOfAmountText(text: String, textPaint: TextPaint): Rect { + val bounds = Rect() + textPaint.getTextBounds(text, 0, text.length, bounds) + return bounds + } + + private fun calculateViewHeight(heightMeasureSpec: Int, textWidth: Int): Int { + val initSizeHeight = resolveDefaultSize(heightMeasureSpec, DEFAULT_VIEW_SIZE_HEIGHT) + textHeight = (dataList.size * marginText + getTextViewHeight(textWidth)).toInt() + + val textHeightWithPadding = textHeight + paddingTop + paddingBottom + return if (textHeightWithPadding > initSizeHeight) textHeightWithPadding else initSizeHeight + } + + private fun resolveDefaultSize(spec: Int, defValue: Int): Int { + return when(MeasureSpec.getMode(spec)) { + MeasureSpec.UNSPECIFIED -> context.dpToPixels(defValue).toInt() + else -> MeasureSpec.getSize(spec) + } + } + + private fun getTextViewHeight(maxWidth: Int): Int { + var legendHeight = 0 + dataList.forEach { + val textLayoutNumber = getMultilineText( + text = it.amount.toString(), + textPaint = numberTextPaint, + width = maxWidth + ) + val textLayoutDescription = getMultilineText( + text = it.category, + textPaint = descriptionTextPain, + width = maxWidth + ) + textRowList.apply { + add(textLayoutNumber) + add(textLayoutDescription) + } + legendHeight += textLayoutNumber.height + textLayoutDescription.height + } + + return legendHeight + } + + private fun angleBetween2Lines(A1: PointF, A2: PointF, B1: PointF, B2: PointF): Float { + val angle1 = atan2((A2.y - A1.y).toDouble(), (A1.x - A2.x).toDouble()).toFloat() + val angle2 = atan2((B2.y - B1.y).toDouble(), (B1.x - B2.x).toDouble()).toFloat() + var calculatedAngle = Math.toDegrees((angle1 - angle2).toDouble()).toFloat() + if (calculatedAngle < 0) calculatedAngle += 360f + return calculatedAngle + } + +} \ 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..3fdb6a11 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -7,13 +7,14 @@ android:layout_height="match_parent" tools:context=".MainActivity"> - + android:id="@+id/PieChartView" + app:pieChartColors="@array/pieChartColorArray" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent"> + + \ No newline at end of file diff --git a/app/src/main/res/values/attr.xml b/app/src/main/res/values/attr.xml new file mode 100644 index 00000000..d036d669 --- /dev/null +++ b/app/src/main/res/values/attr.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9213c339..92647715 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,15 @@ Custom View + + #E480F4 + #6CC3F3 + #7167ED + #D9455F + #6054EA + #1A237E + #5E35B1 + #0D47A1 + #263238 + #01579B + \ No newline at end of file From 15463cfcd09679abc5a71bd8d9846c267a055920 Mon Sep 17 00:00:00 2001 From: niku Date: Thu, 24 Aug 2023 01:02:29 +0300 Subject: [PATCH 2/2] Task 1+2 --- app/src/main/assets/payload.json | 30 ++++- .../otus/homework/customview/BarChartView.kt | 126 ++++++++++++++++++ .../otus/homework/customview/MainActivity.kt | 41 +++++- .../otus/homework/customview/PieChartView.kt | 4 +- app/src/main/res/layout/activity_main.xml | 26 +++- app/src/main/res/values/attr.xml | 8 ++ app/src/main/res/values/strings.xml | 1 + 7 files changed, 226 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/otus/homework/customview/BarChartView.kt diff --git a/app/src/main/assets/payload.json b/app/src/main/assets/payload.json index 5492267a..9408684d 100644 --- a/app/src/main/assets/payload.json +++ b/app/src/main/assets/payload.json @@ -9,7 +9,7 @@ { "id": 2, "name": "Ригла", - "amount": 499, + "amount": 4990, "category": "Здоровье", "time": 1623322251 }, @@ -82,5 +82,33 @@ "amount": 389, "category": "Транспорт", "time": 1623419934 + }, + { + "id": 13, + "name": "Стоматология", + "amount": 3000, + "category": "Здоровье", + "time": 1623419811 + }, + { + "id": 14, + "name": "Стоматология", + "amount": 1250, + "category": "Здоровье", + "time": 1623419811 + }, + { + "id": 15, + "name": "Стоматология", + "amount": 1500, + "category": "Здоровье", + "time": 1623419811 + }, + { + "id": 15, + "name": "Стоматология", + "amount": 1500, + "category": "Здоровье", + "time": 1623419811 } ] \ No newline at end of file diff --git a/app/src/main/java/otus/homework/customview/BarChartView.kt b/app/src/main/java/otus/homework/customview/BarChartView.kt new file mode 100644 index 00000000..0e1765c9 --- /dev/null +++ b/app/src/main/java/otus/homework/customview/BarChartView.kt @@ -0,0 +1,126 @@ +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.util.AttributeSet +import android.view.View +import kotlin.math.min + +class BarChartView @JvmOverloads constructor ( + context: Context, + attrs: AttributeSet? = null, +) : View(context, attrs) { + + private val list = ArrayList() + private var maxValue = 0 + private var barWidth = context.dpToPixels(50) + private var paintBaseFill : Paint = Paint() + private var paintText : Paint = Paint() + private var threshold: Int = Int.MAX_VALUE + private var paintStroke : Paint = Paint() + private val rect = RectF() + private val textAmountSize: Float = context.spToPixels(12) + private var wSize: Int = 0 + + companion object { + private const val DEFAULT_MARGIN_BAR_X = 30 + private const val DEFAULT_MARGIN_TEXT_BOTTOM_Y = 30 + private const val DEFAULT_MARGIN_TEXT_TOP_Y = 60 + } + + init { + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.BarChartView) + val baseColor: Int = + typedArray.getColor( + R.styleable.BarChartView_baseColor, Color.parseColor("#1A237E")) + val threshold = typedArray.getInt(R.styleable.BarChartView_threshold, Int.MAX_VALUE) + val barWidth = + typedArray.getDimension( + R.styleable.BarChartView_barWidth, barWidth) + typedArray.recycle() + setup(baseColor, threshold, barWidth) + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val wMode = MeasureSpec.getMode(widthMeasureSpec) + wSize = MeasureSpec.getSize(widthMeasureSpec) + val hSize = MeasureSpec.getSize(heightMeasureSpec) + + when (wMode) { + MeasureSpec.EXACTLY -> setMeasuredDimension(wSize, hSize) + MeasureSpec.AT_MOST -> { + val newW = Integer.min((list.size * barWidth).toInt(), wSize) + setMeasuredDimension(newW, hSize) + } + MeasureSpec.UNSPECIFIED -> setMeasuredDimension((list.size * barWidth).toInt(), hSize) + } + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + + if (list.size == 0) return + + val widthPerView = width.toFloat() / list.size + var currentX = 0f + val heightWithMargin = (height - 200) + val heightPerValue = heightWithMargin.toFloat() / maxValue + + for (item in list) { + val barX = currentX + DEFAULT_MARGIN_BAR_X + val barY = + min((heightWithMargin - heightPerValue * item.amount) + DEFAULT_MARGIN_TEXT_TOP_Y, + heightWithMargin.toFloat()) + rect.set( + barX, + barY, + (currentX + widthPerView), + heightWithMargin.toFloat(), + ) + canvas.drawRect(rect, paintBaseFill) + canvas.drawRect(rect, paintStroke) + canvas.drawText(item.amount.toString(), barX, barY - DEFAULT_MARGIN_TEXT_BOTTOM_Y, paintText) + currentX += widthPerView + } + + canvas.drawLine( + DEFAULT_MARGIN_BAR_X.toFloat(), + heightWithMargin.toFloat(), + width.toFloat() + 100, + heightWithMargin.toFloat() , paintStroke) + } + + fun setValues(values : List) { + list.clear() + list.addAll(values) + maxValue = list.maxOf { it.amount } + + requestLayout() + invalidate() + } + + private fun setup(baseColor: Int, threshold : Int, barWidth : Float) { + + paintBaseFill = Paint().apply { + color = baseColor + style = Paint.Style.FILL + } + + paintText = Paint().apply { + color = baseColor + style = Paint.Style.FILL + textSize = textAmountSize + } + + paintStroke = Paint().apply { + color = Color.BLACK + style = Paint.Style.STROKE + strokeWidth = 2.0f + } + this.threshold = threshold + this.barWidth = barWidth + } +} \ 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 8ed52bc1..fc20f082 100644 --- a/app/src/main/java/otus/homework/customview/MainActivity.kt +++ b/app/src/main/java/otus/homework/customview/MainActivity.kt @@ -2,34 +2,65 @@ package otus.homework.customview import android.os.Bundle import android.util.Log +import android.view.View import androidx.appcompat.app.AppCompatActivity +import com.google.android.material.button.MaterialButton class MainActivity : AppCompatActivity(), PieChartView.Callbacks { private lateinit var pieChart: PieChartView + private lateinit var lineChart: BarChartView + private lateinit var buttonBack: MaterialButton + private var dataList: List = listOf() override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) - pieChart = findViewById(R.id.PieChartView) - //DataRepository.initialize() + pieChart = findViewById(R.id.pieChartView) + lineChart = findViewById(R.id.barChartView) + lineChart.visibility = View.GONE + buttonBack = findViewById(R.id.buttonBack) + + buttonBack.apply { + setOnClickListener { + goBack() + } + } + val filename = "payload.json" val jsonText = FileUtils.AssetsLoader.loadTextFromAsset(applicationContext, filename) - val dataList = FileUtils.AssetsLoader.getDataFromText(jsonText) + dataList = FileUtils.AssetsLoader.getDataFromText(jsonText) val categoryList = dataList.groupingBy { it.category } .reduce { _, acc, element -> PayLoadModel(0, "", acc.amount + element.amount, acc.category) } .values.toList() + updateUi(dataList = categoryList) + } private fun updateUi(dataList: List) { - pieChart.setDataChart(dataList) + pieChart.setValues(dataList) pieChart.startAnimation() } override fun onSectorSelected(valueModel: BaseValueModel) { - Log.d("MA", "sector ${(valueModel as PayLoadModel).category} clicked") + pieChart.visibility = View.GONE + val category = (valueModel as PayLoadModel).category + Log.d("MA", "sector $category clicked") + val purchasesListByCategory = + dataList.filter { it.category == category } + lineChart.setValues(purchasesListByCategory) + lineChart.visibility = View.VISIBLE + buttonBack.visibility = View.VISIBLE + } + + private fun goBack() { + pieChart.visibility = View.VISIBLE + lineChart.visibility = View.GONE + buttonBack.visibility = View.GONE } + } \ 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 index c49bbe15..3ac6d030 100644 --- a/app/src/main/java/otus/homework/customview/PieChartView.kt +++ b/app/src/main/java/otus/homework/customview/PieChartView.kt @@ -14,7 +14,7 @@ import kotlin.math.atan2 import kotlin.math.pow import kotlin.math.sqrt -const val TAG = "PieChartView" +private const val TAG = "PieChartView" class PieChartView(context: Context, attributeSet: AttributeSet): View(context, attributeSet) { @@ -198,7 +198,7 @@ class PieChartView(context: Context, attributeSet: AttributeSet): if (!isDescription) textPaint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) } - fun setDataChart(list: List) { + fun setValues(list: List) { dataList = list calculatePercentageOfData() } diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 3fdb6a11..aac79f04 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -7,14 +7,36 @@ android:layout_height="match_parent" tools:context=".MainActivity"> + + + + + app:layout_constraintTop_toBottomOf="@+id/buttonBack" + app:layout_constraintBottom_toTopOf="@+id/barChartView"> + + + + \ No newline at end of file diff --git a/app/src/main/res/values/attr.xml b/app/src/main/res/values/attr.xml index d036d669..ef0f3b4f 100644 --- a/app/src/main/res/values/attr.xml +++ b/app/src/main/res/values/attr.xml @@ -1,7 +1,15 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 92647715..c6d30fe2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,5 +1,6 @@ Custom View + Back #E480F4 #6CC3F3