diff --git a/app/build.gradle b/app/build.gradle index b4711913..43d3c09a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,6 +1,7 @@ plugins { id 'com.android.application' id 'kotlin-android' + id 'kotlin-parcelize' } android { @@ -42,4 +43,7 @@ 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.5' + implementation 'com.jakewharton.threetenabp:threetenabp:1.3.0' } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index efd1e519..a1e2ee6a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -10,7 +10,7 @@ android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.CustomView"> - + diff --git a/app/src/main/java/otus/homework/customview/MainActivity.kt b/app/src/main/java/otus/homework/customview/MainActivity.kt deleted file mode 100644 index 78cb9448..00000000 --- a/app/src/main/java/otus/homework/customview/MainActivity.kt +++ /dev/null @@ -1,11 +0,0 @@ -package otus.homework.customview - -import androidx.appcompat.app.AppCompatActivity -import android.os.Bundle - -class MainActivity : AppCompatActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_main) - } -} \ No newline at end of file diff --git a/app/src/main/java/otus/homework/customview/models/Models.kt b/app/src/main/java/otus/homework/customview/models/Models.kt new file mode 100644 index 00000000..5192bce6 --- /dev/null +++ b/app/src/main/java/otus/homework/customview/models/Models.kt @@ -0,0 +1,74 @@ +package otus.homework.customview.models + +import android.graphics.Color +import android.os.Parcelable +import androidx.annotation.ColorInt +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import otus.homework.customview.utils.ColorGenerator + +data class Expenditure( + val id: Int, + val name: String, + val amount: Float, + val category: String, + val time: Long +) + +@Parcelize +data class PieChartSegment( + val id: Int, + val name: String, + val amount: Float, + val category: String, + val startAngle: Float, + val endAngle: Float, + val percentageOfMaximum: Float, + @ColorInt val color: Int +): Parcelable { + + @IgnoredOnParcel + val segmentAngle = endAngle - startAngle +} + +@Parcelize +data class LinearChartPoint( + val id: Int, + val name: String, + val amount: Float, + val category: String, + val time: Long, + val dayInMonth: Int +) : Parcelable { + + fun mapExpenditureCategory() = when (category) { + "Продукты" -> ExpenditureCategory.PRODUCTS + "Здоровье" -> ExpenditureCategory.HEALTH + "Кафе и рестораны" -> ExpenditureCategory.EATING_OUT + "Алкоголь" -> ExpenditureCategory.ALCOHOL + "Доставка еды" -> ExpenditureCategory.DELIVERY + "Транспорт" -> ExpenditureCategory.TRANSPORT + "Спорт" -> ExpenditureCategory.SPORT + else -> throw IllegalArgumentException("Error while parsing unknown category: $category") + } +} + +@Parcelize +enum class ExpenditureCategory : Parcelable { + PRODUCTS, HEALTH, EATING_OUT, ALCOHOL, DELIVERY, TRANSPORT, SPORT; + + @ColorInt + fun getChartColor(): Int { + return when (this) { + PRODUCTS -> Color.rgb(38, 50, 124) + HEALTH -> Color.rgb(100, 188, 223) + EATING_OUT -> Color.rgb(230, 197, 87) + TRANSPORT -> Color.rgb(202, 58, 46) + else -> ColorGenerator.generateColor() + } + } +} + +enum class Chart { + PIE, LINEAR +} \ No newline at end of file diff --git a/app/src/main/java/otus/homework/customview/utils/ColorGenerator.kt b/app/src/main/java/otus/homework/customview/utils/ColorGenerator.kt new file mode 100644 index 00000000..4201a78b --- /dev/null +++ b/app/src/main/java/otus/homework/customview/utils/ColorGenerator.kt @@ -0,0 +1,14 @@ +package otus.homework.customview.utils + +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)) + } +} \ No newline at end of file diff --git a/app/src/main/java/otus/homework/customview/utils/Extention.kt b/app/src/main/java/otus/homework/customview/utils/Extention.kt new file mode 100644 index 00000000..ecb95902 --- /dev/null +++ b/app/src/main/java/otus/homework/customview/utils/Extention.kt @@ -0,0 +1,11 @@ +package otus.homework.customview.utils + +import android.content.res.Resources +import android.util.TypedValue + +val Number.toPx + get() = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + this.toFloat(), + Resources.getSystem().displayMetrics + ) \ No newline at end of file diff --git a/app/src/main/java/otus/homework/customview/views/ExpenditureLinearChart.kt b/app/src/main/java/otus/homework/customview/views/ExpenditureLinearChart.kt new file mode 100644 index 00000000..194585d9 --- /dev/null +++ b/app/src/main/java/otus/homework/customview/views/ExpenditureLinearChart.kt @@ -0,0 +1,296 @@ +package otus.homework.customview.views + +import android.content.Context +import android.graphics.* +import android.os.Parcel +import android.os.Parcelable +import android.util.AttributeSet +import android.view.View +import androidx.core.graphics.ColorUtils +import otus.homework.customview.models.ExpenditureCategory +import otus.homework.customview.models.LinearChartPoint +import otus.homework.customview.R +import otus.homework.customview.utils.toPx +import kotlin.math.max +import kotlin.properties.Delegates + + +class ExpenditureLinearChart(context: Context?, attrs: AttributeSet) : + View(context, attrs) { + + private val linearChartMinSize = resources.getDimension(R.dimen.linear_chart_min_size).toInt() + + private var columnsCount by Delegates.notNull() + private val rowCount = 4 + + private val globalRect = Rect() + private val chartRect = Rect() + private val bottomSignatureRect = Rect() + + private val chartPath = Path() + + private val gridPaint = Paint().apply { + color = ColorUtils.setAlphaComponent(Color.GRAY, 128) + style = Paint.Style.STROKE + pathEffect = DashPathEffect(floatArrayOf(2f, 4f), 50f) + strokeWidth = 2f + } + private val signaturePaint = Paint().apply { + color = ColorUtils.setAlphaComponent(Color.GRAY, 192) + textSize = 24f + } + private val mainPaint = Paint().apply { + pathEffect = CornerPathEffect(40f) + style = Paint.Style.STROKE + strokeWidth = 8f + } + + private lateinit var categories: HashMap> + private var maxValue by Delegates.notNull() + private var daysInMonth by Delegates.notNull() + + override fun onSaveInstanceState(): Parcelable { + val superState = super.onSaveInstanceState() + return SavedState(categories, maxValue, daysInMonth, superState) + } + + override fun onRestoreInstanceState(state: Parcelable?) { + if (state is SavedState) { + categories = state.categories + maxValue = state.maxValue + daysInMonth = state.daysInMonth + super.onRestoreInstanceState(state.superState) + } else { + super.onRestoreInstanceState(state) + } + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val viewWidth = measureViewWidth(widthMeasureSpec) + val viewHeight = measureViewWidth(heightMeasureSpec) + setMeasuredDimension(viewWidth, viewHeight) + } + + override fun onDraw(canvas: Canvas) { + calculateRect(canvas) + measureBackgroundGridColumnsCount() + drawBackgroundGrid(canvas) + drawSideSignature(canvas) + drawBottomSignature(canvas) + drawLinearChart(canvas) + } + + fun setupData( + linearChartPoints: HashMap>, + maxAmount: Int, + daysInMonth: Int = 29 + ) { + this.categories = linearChartPoints + this.maxValue = maxAmount + this.daysInMonth = daysInMonth + } + + private fun measureViewWidth(widthMeasureSpec: Int): Int { + var result = linearChartMinSize + val specMode = MeasureSpec.getMode(widthMeasureSpec) + val specSize = MeasureSpec.getSize(widthMeasureSpec) + when (specMode) { + MeasureSpec.UNSPECIFIED -> result = linearChartMinSize + MeasureSpec.AT_MOST, MeasureSpec.EXACTLY -> result = max(linearChartMinSize, specSize) + } + return result + } + + private fun calculateRect(canvas: Canvas) { + globalRect.set(canvas.clipBounds) + + val signatureHeight = 16f.toPx.toInt() + val chartHorizontalPadding = 12f.toPx.toInt() + chartRect.set( + globalRect.left + chartHorizontalPadding, + globalRect.top, + globalRect.right - chartHorizontalPadding, + globalRect.bottom - signatureHeight + ) + bottomSignatureRect.set( + globalRect.left, + globalRect.bottom - signatureHeight, + globalRect.right, + globalRect.bottom + ) + } + + private fun measureBackgroundGridColumnsCount() { + val width = chartRect.width() + columnsCount = when { + width in (240..359) -> 2 + width in (360..479) -> 3 + width in (480..599) -> 4 + width in (600..719) -> 5 + width in (720..839) -> 6 + width in (840..959) -> 7 + width in (960..1079) -> 8 + width >= 1080 -> 9 + else -> throw IllegalStateException("Width is too small") + } + } + + private fun drawBackgroundGrid(canvas: Canvas) { + canvas.drawRect(chartRect, gridPaint) + val gridWidth = chartRect.width() / columnsCount + val gridHeight = chartRect.height() / rowCount + canvas.save() + canvas.clipRect(chartRect) + for (index in 1 until rowCount) { + canvas.drawLine( + chartRect.left.toFloat(), + index * gridHeight.toFloat(), + chartRect.right.toFloat(), + index * gridHeight.toFloat(), + gridPaint + ) + } + for (index in 1 until columnsCount) { + canvas.drawLine( + chartRect.left + index * gridWidth.toFloat(), + chartRect.top.toFloat(), + chartRect.left + index * gridWidth.toFloat(), + chartRect.bottom.toFloat(), + gridPaint + ) + } + canvas.restore() + } + + private fun drawSideSignature(canvas: Canvas) { + val gridHeight = chartRect.height() / rowCount + for (index in (1..rowCount)) { + val textToDraw = "${maxValue * (rowCount - index + 1) / rowCount}$" + val textLength = signaturePaint.measureText(textToDraw) + val dx = chartRect.right - textLength - 2f.toPx + val dy = (index - 1) * gridHeight + 10f.toPx + canvas.drawText(textToDraw, dx, dy, signaturePaint) + } + } + + private fun drawBottomSignature(canvas: Canvas) { + val gridWidth = chartRect.width() / columnsCount + var dayOfMonth = 1 + val diff = daysInMonth / columnsCount + for (index in (0..columnsCount)) { + val textToDraw = dayOfMonth.toString() + val textLength = signaturePaint.measureText(textToDraw) + val dx = chartRect.left + index * gridWidth - textLength * .5f + val dy = bottomSignatureRect.top + 10f.toPx + canvas.drawText(textToDraw, dx, dy, signaturePaint) + + dayOfMonth += diff + } + } + + private fun drawLinearChart(canvas: Canvas) { + categories.forEach { entry -> + chartPath.reset() + entry.value.firstOrNull()?.let { + val dx = chartRect.left.toFloat() + val dy = chartRect.top + chartRect.height() + .toFloat() * (1 - it.amount / maxValue) + chartPath.moveTo(dx, dy) + } ?: chartPath.moveTo(chartRect.left.toFloat(), chartRect.left.toFloat()) + mainPaint.color = entry.key.getChartColor() + entry.value + .sortedWith { o1, o2 -> o1.dayInMonth - o2.dayInMonth } + .forEach { + chartPath.apply { + val dx = chartRect.left + chartRect.width() + .toFloat() * (it.dayInMonth - 1) / (daysInMonth - 1) + val dy = chartRect.top + chartRect.height() + .toFloat() * (1 - it.amount / maxValue) + lineTo(dx, dy) + } + } + canvas.drawPath(chartPath, mainPaint) + } + } + + internal class SavedState : BaseSavedState { + + val categories: HashMap> + val maxValue: Int + val daysInMonth: Int + + constructor( + categories: HashMap>, + maxAmount: Int, + daysInMonth: Int = 29, + superState: Parcelable? + ) : super(superState) { + this.categories = hashMapOf>().also { + it.clear() + it.putAll(categories) + } + this.maxValue = maxAmount + this.daysInMonth = daysInMonth + } + + private constructor(input: Parcel) : super(input) { + categories = readParcelableMap( + parcel = input, + kClass = ExpenditureCategory::class.java, + vClass = LinearChartPoint::class.java + ) as HashMap> + maxValue = input.readInt() + daysInMonth = input.readInt() + } + + override fun writeToParcel(out: Parcel, flags: Int) { + super.writeToParcel(out, flags) + writeParcelableMap(out, flags, categories) + out.writeInt(maxValue) + out.writeInt(daysInMonth) + } + + override fun describeContents(): Int { + return 0 + } + + private fun writeParcelableMap( + parcel: Parcel, + flags: Int, + map: Map> + ) { + parcel.writeInt(map.size) + for ((key, value) in map) { + parcel.writeParcelable(key, flags) + parcel.writeList(value) + } + } + + // For reading from a Parcel + private fun readParcelableMap( + parcel: Parcel, + kClass: Class, + vClass: Class + ): Map> { + val size = parcel.readInt() + val map: MutableMap> = HashMap(size) + for (i in 0 until size) { + val key = kClass.cast(parcel.readParcelable(kClass.classLoader)) as K + val value = arrayListOf() + parcel.readList(value, ArrayList::class.java.classLoader) + map[key] = value + } + return map + } + + 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/views/ExpenditurePieChart.kt b/app/src/main/java/otus/homework/customview/views/ExpenditurePieChart.kt new file mode 100644 index 00000000..52e8143d --- /dev/null +++ b/app/src/main/java/otus/homework/customview/views/ExpenditurePieChart.kt @@ -0,0 +1,442 @@ +package otus.homework.customview.views + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.AnimatorSet +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.* +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 android.view.animation.OvershootInterpolator +import androidx.core.graphics.ColorUtils +import otus.homework.customview.models.PieChartSegment +import otus.homework.customview.R +import kotlin.math.* + +class ExpenditurePieChart : View { + + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) + + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) + : super(context, attrs, defStyleAttr) + + private val minPieChartSize = resources.getDimension(R.dimen.pie_chart_min_size).toInt() + + private var rotationAngle = 0f + private var increasingDelta = 0f + private var decreasingAlpha = 0 + private var decreasingDelta = 0f + private var increasingAlpha = 0 + + private val globalRect = Rect() + private val outerRectF = RectF() + private val innerRectF = RectF() + private val animationDelta: Float by lazy { (outerRectF.width() - innerRectF.width()) / 2f / DEFAULT_COEFFICIENT } + private val percentageRectF = RectF() + + private val pieChartPath = Path() + private val nextStartPointPath = Path() + private val pathMeasure = PathMeasure() + private val startPoint = PointF() + private val nextStartPoint = PointF() + + private val mainPaint = Paint().apply { + style = Paint.Style.FILL + strokeWidth = 2f + } + private val whitePaint = Paint().apply { + color = Color.rgb(255, 255, 255) + style = Paint.Style.STROKE + strokeWidth = 6f + } + private val moneyPaint = Paint().apply { + color = Color.BLACK + textSize = 72f + textAlign = Paint.Align.CENTER + typeface = Typeface.DEFAULT_BOLD + } + private val signaturePaint = Paint().apply { + color = Color.GRAY + textSize = 32f + textAlign = Paint.Align.CENTER + typeface = Typeface.DEFAULT + } + private val cornerPaint = Paint().apply { + style = Paint.Style.STROKE + color = Color.GRAY + strokeWidth = 2f + alpha = 50 + } + + private lateinit var segments: List + private var increasingSegment: PieChartSegment? = null + private var decreasingSegment: PieChartSegment? = null + private var callback: ((String) -> Unit)? = null + + private val generalGestureDetector = + GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() { + override fun onDown(e: MotionEvent?): Boolean { + return true + } + + override fun onSingleTapUp(event: MotionEvent): Boolean { + return handleOnSingleTapUp(event) + } + }) + + + override fun onSaveInstanceState(): Parcelable { + val superState = super.onSaveInstanceState() + return SavedState(segments, superState) + } + + override fun onRestoreInstanceState(state: Parcelable?) { + if (state is SavedState) { + segments = state.segments + super.onRestoreInstanceState(state.superState) + } else { + super.onRestoreInstanceState(state) + } + } + + override fun onTouchEvent(event: MotionEvent?): Boolean { + return generalGestureDetector.onTouchEvent(event) + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val viewWidth = measureViewSize(widthMeasureSpec) + val viewHeight = measureViewSize(heightMeasureSpec) + min(viewWidth, viewHeight).also { viewSize -> + setMeasuredDimension(viewSize, viewSize) + } + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + calculateRect(canvas) + + + startPoint.set(innerRectF.right, innerRectF.centerY()) + + segments.forEach { segment -> + /* + * Adjustment of the segment width according to the ratio with the highest value of the diagram + * */ + val ratio = (1 - segment.percentageOfMaximum / 100f) + val realCoefficient = ratio / DEFAULT_COEFFICIENT + var dx = (outerRectF.width() - innerRectF.width()).div(2).times(realCoefficient) + var dy = (outerRectF.height() - innerRectF.height()).div(2).times(realCoefficient) + + when (segment.id) { + increasingSegment?.id -> { + dx += increasingDelta + dy += increasingDelta + } + decreasingSegment?.id -> { + dx += decreasingDelta + dy += decreasingDelta + } + } + outerRectF.inset(dx, dy) + + calculatePieChartPath(segment) + calculateNextStartPoint(segment) + + mainPaint.apply { + color = segment.color + /* + * TODO(Don't know how to change the coordinates of a LinearGradient without recreating its entity in onDraw) + * */ + shader = LinearGradient( + startPoint.x, + startPoint.y, + nextStartPoint.x, + nextStartPoint.y, + segment.color, + ColorUtils.setAlphaComponent(segment.color, ALPHA_CHANNEL_FOR_THE_GRADIENT), + Shader.TileMode.MIRROR + ) + } + + drawPercentageItem(canvas, segment) + drawSegmentItem(canvas) + + outerRectF.inset(dx.unaryMinus(), dy.unaryMinus()) + + pieChartPath.reset() + startPoint.set(nextStartPoint) + } + + drawCentralSignature(canvas) + } + + fun setupData(segments: List, callback: (String) -> Unit) { + this.segments = segments + this.callback = callback + } + + fun startAnimation() { + ValueAnimator.ofFloat(0f, 145f).apply { + setDuration(1500) + setInterpolator(OvershootInterpolator()) + addUpdateListener { + rotationAngle = animatedValue as Float + invalidate() + } + start() + } + } + + private fun startSegmentIncreaseAnim(segment: PieChartSegment) { + increasingSegment = segment + val increaseAnimator = ValueAnimator.ofFloat(0f, animationDelta.unaryMinus()).apply { + setDuration(1000) + setInterpolator(OvershootInterpolator()) + addUpdateListener { + increasingDelta = animatedValue as Float + invalidate() + } + } + val alphaAnimator = ValueAnimator.ofInt(255, 0).apply { + setDuration(1000) + addUpdateListener { + decreasingAlpha = it.animatedValue as Int + } + } + AnimatorSet().apply { + playTogether(increaseAnimator, alphaAnimator) + start() + } + } + + private fun startSegmentDecreaseAnim(segment: PieChartSegment) { + decreasingSegment = segment + val decreaseAnimator = ValueAnimator.ofFloat(animationDelta.unaryMinus(), 0f).apply { + setDuration(1000) + setInterpolator(OvershootInterpolator()) + addUpdateListener { + decreasingDelta = animatedValue as Float + invalidate() + } + addListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator?) { + super.onAnimationEnd(animation) + decreasingSegment = null + } + }) + } + val alphaAnimator = ValueAnimator.ofInt(0, 255).apply { + setDuration(1000) + addUpdateListener { + increasingAlpha = it.animatedValue as Int + } + } + AnimatorSet().apply { + playTogether(decreaseAnimator, alphaAnimator) + start() + } + } + + private fun handleOnSingleTapUp(event: MotionEvent): Boolean { + val clickInsideOuterRect = outerRectF.contains(event.x, event.y) + val clickInsideInnerRect = innerRectF.contains(event.x, event.y) + + return when { + clickInsideOuterRect && !clickInsideInnerRect -> { + /* + * segment click handling + * */ + + val dx = event.x - innerRectF.centerX().toDouble() + val dy = -(event.y - innerRectF.centerY()).toDouble() + var inRad = atan2(dy, dx) + + // We need to map to coord system when 0 degree is at 3 O'clock, 270 at 12 O'clock + inRad = if (inRad < 0) abs(inRad) else 2 * Math.PI - inRad + + val inDegrees = Math.toDegrees(inRad) + val trueAngle = + if (inDegrees >= rotationAngle) inDegrees - rotationAngle else 360 + inDegrees - rotationAngle + segments.find { segment -> (segment.startAngle..segment.endAngle).contains(trueAngle) } + ?.let { + callback?.invoke(it.category) + if (it.id == increasingSegment?.id) { + increasingSegment = null + startSegmentDecreaseAnim(it) + } else { + increasingSegment + ?.let { startSegmentDecreaseAnim(it) } + .also { increasingSegment = null } + startSegmentIncreaseAnim(it) + } + true + } ?: false + } + else -> false + } + } + + private fun measureViewSize(measureSpec: Int): Int { + var result = minPieChartSize + val specMode = MeasureSpec.getMode(measureSpec) + val specSize = MeasureSpec.getSize(measureSpec) + when (specMode) { + MeasureSpec.UNSPECIFIED -> result = minPieChartSize + MeasureSpec.AT_MOST, MeasureSpec.EXACTLY -> result = max(minPieChartSize, specSize) + } + return result + } + + private fun calculateRect(canvas: Canvas) { + canvas.getClipBounds(globalRect) + + globalRect.also { rect -> + outerRectF.set(rect) + innerRectF.set(rect) + + val width = globalRect.width() + val height = globalRect.height() + + outerRectF.inset(width * 0.15f, height * 0.15f) + innerRectF.inset(width * 0.3f, height * 0.3f) + } + } + + private fun calculatePieChartPath(segments: PieChartSegment) { + with(segments) { + pieChartPath.apply { + moveTo(startPoint.x, startPoint.y) + arcTo(innerRectF, startAngle, segments.segmentAngle) + arcTo(outerRectF, endAngle, 0f) + arcTo(outerRectF, endAngle, segments.segmentAngle.unaryMinus()) + arcTo(innerRectF, startAngle, 0f) + } + } + } + + private fun calculateNextStartPoint(segment: PieChartSegment) { + nextStartPointPath.apply { + moveTo(startPoint.x, startPoint.y) + arcTo(innerRectF, segment.startAngle, segment.segmentAngle) + + pathMeasure.setPath(this, false) + val xyCoordinate = floatArrayOf(startPoint.x, startPoint.y) + pathMeasure.getPosTan(pathMeasure.length, xyCoordinate, null) + + nextStartPoint.set(xyCoordinate[0], xyCoordinate[1]) + reset() + } + } + + + /* + * Draw methods + * */ + + private fun drawSegmentItem(canvas: Canvas) { + canvas.save() + canvas.rotate(rotationAngle, innerRectF.centerX(), innerRectF.centerY()) + canvas.drawPath(pieChartPath, mainPaint) + canvas.drawPath(pieChartPath, whitePaint) + canvas.restore() + } + + private fun drawPercentageItem( + canvas: Canvas, + segment: PieChartSegment + ) { + val value = segment.segmentAngle * 100 / 360 + if (value < 3) return + val angleInRadian = + ((segment.endAngle + segment.startAngle) / 2 + rotationAngle) * PI / 180 + val distance = outerRectF.width() / 2 + 100 + val x = distance * cos(angleInRadian).toFloat() + outerRectF.centerX() + val y = distance * sin(angleInRadian).toFloat() + outerRectF.centerY() + percentageRectF.set( + x - PERCENTAGE_HALF_WIDTH, + y - PERCENTAGE_HALF_HEIGHT, + x + PERCENTAGE_HALF_WIDTH, + y + PERCENTAGE_HALF_HEIGHT + ) + val defaultCornerAlpha = cornerPaint.alpha + val defaultSignatureAlpha = signaturePaint.alpha + cornerPaint.alpha = when (segment.id) { + increasingSegment?.id -> decreasingAlpha / 5 + decreasingSegment?.id -> increasingAlpha / 5 + else -> defaultCornerAlpha + } + signaturePaint.alpha = when (segment.id) { + increasingSegment?.id -> decreasingAlpha + decreasingSegment?.id -> increasingAlpha + else -> defaultSignatureAlpha + } + + canvas.drawRect(percentageRectF, cornerPaint) + val stringValue = value.toString().substringBefore(".") + canvas.drawText("$stringValue%", x, y + 12, signaturePaint) + + cornerPaint.alpha = defaultCornerAlpha + signaturePaint.alpha = defaultSignatureAlpha + } + + private fun drawCentralSignature(canvas: Canvas) { + val totalAmount = segments.fold(0f) { acc, segment -> + acc.plus(segment.amount) + }.let { total -> return@let "\$$total" } + canvas.drawText(totalAmount, innerRectF.centerX(), innerRectF.centerY(), moneyPaint) + + val periodText = "in january" + canvas.drawText( + periodText, + innerRectF.centerX(), + innerRectF.centerY() + 56f, + signaturePaint + ) + } + + internal class SavedState : BaseSavedState { + + val segments: List + + constructor(segments: List, superState: Parcelable?) : super(superState) { + this.segments = segments + } + + private constructor(input: Parcel) : super(input) { + segments = listOf() + input.readList(segments, List::class.java.classLoader) + } + + override fun writeToParcel(out: Parcel, flags: Int) { + super.writeToParcel(out, flags) + out.writeList(segments) + } + + override fun describeContents(): Int { + return 0 + } + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): SavedState { + return SavedState(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } + } + + + companion object { + private const val DEFAULT_COEFFICIENT = 1.66f + private const val ALPHA_CHANNEL_FOR_THE_GRADIENT = 200 + private const val PERCENTAGE_HALF_WIDTH = 44 + private const val PERCENTAGE_HALF_HEIGHT = 32 + } +} \ No newline at end of file diff --git a/app/src/main/java/otus/homework/customview/views/MainActivity.kt b/app/src/main/java/otus/homework/customview/views/MainActivity.kt new file mode 100644 index 00000000..03701060 --- /dev/null +++ b/app/src/main/java/otus/homework/customview/views/MainActivity.kt @@ -0,0 +1,159 @@ +package otus.homework.customview.views + +import android.graphics.Color +import android.os.Bundle +import android.widget.Toast +import androidx.annotation.ColorInt +import androidx.appcompat.app.AppCompatActivity +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import org.threeten.bp.* +import org.threeten.bp.format.DateTimeFormatter +import org.threeten.bp.format.DateTimeFormatterBuilder +import org.threeten.bp.format.ResolverStyle +import org.threeten.bp.format.SignStyle +import org.threeten.bp.temporal.ChronoField +import otus.homework.customview.* +import otus.homework.customview.models.* +import otus.homework.customview.utils.ColorGenerator +import kotlin.math.roundToInt + +class MainActivity : AppCompatActivity() { + + private val LOCAL_DATE_PATTERN: DateTimeFormatter by lazy { + DateTimeFormatterBuilder() + .appendValue(ChronoField.DAY_OF_MONTH, 1, 2, SignStyle.NORMAL) + .toFormatter() + .withResolverStyle(ResolverStyle.STRICT) + + } + + private lateinit var pieChart: ExpenditurePieChart + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + + pieChart = findViewById(R.id.expenditure_pie_chart).apply { + val expenditures = readExpenditures(Chart.PIE) + val pieChartSegments = getPieChartSegments(expenditures) + setupData(pieChartSegments) { category: String -> + Toast.makeText(this@MainActivity, "category: $category", Toast.LENGTH_SHORT).show() + } + } + findViewById(R.id.expenditure_linear_chart).apply { + val expenditures = readExpenditures(Chart.LINEAR) + val categories = getCategoriesMap(expenditures) + setupData(categories, 20000) + } + } + + override fun onStart() { + super.onStart() + pieChart.startAnimation() + } + + private fun readExpenditures( + chartType: Chart + ): List { + val payloadResource = if (TESTING_MODE) when (chartType) { + Chart.PIE -> R.raw.test_pie_chart_payload + Chart.LINEAR -> R.raw.test_linear_chart_payload + } else R.raw.payload + val text = resources + .openRawResource(payloadResource) + .bufferedReader() + .use { it.readText() } + + val gson = Gson() + val type = object : TypeToken>() {}.type + return gson.fromJson(text, type) + } + + + /* + * Methods for linear chart points getting + * */ + + private fun getCategoriesMap(expenditures: List): HashMap> { + val resultMap = initDefaultMap() + expenditures.forEach { expenditure -> + val chartPoint = mapLinearChartPoint(expenditure) + chartPoint.mapExpenditureCategory().let { key -> + resultMap[key]?.add(chartPoint) + } + } + return resultMap + } + + private fun initDefaultMap(): HashMap> { + return hashMapOf>().apply { + put(ExpenditureCategory.PRODUCTS, arrayListOf()) + put(ExpenditureCategory.HEALTH, arrayListOf()) + put(ExpenditureCategory.EATING_OUT, arrayListOf()) + put(ExpenditureCategory.ALCOHOL, arrayListOf()) + put(ExpenditureCategory.DELIVERY, arrayListOf()) + put(ExpenditureCategory.TRANSPORT, arrayListOf()) + put(ExpenditureCategory.SPORT, arrayListOf()) + } + } + + private fun mapLinearChartPoint(expenditure: Expenditure) = + LinearChartPoint( + id = expenditure.id, + name = expenditure.name, + amount = expenditure.amount, + category = expenditure.category, + time = expenditure.time, + dayInMonth = mapExpenditureTime(expenditure.time) + ) + + private fun mapExpenditureTime(timestamp: Long): Int { + val instant = Instant.ofEpochSecond(timestamp) + val localDate = LocalDateTime.ofInstant(instant, ZoneOffset.UTC) + return LOCAL_DATE_PATTERN.format(localDate).toInt() + } + + + /* + * Methods for pie chart segments getting + * */ + + private fun getPieChartSegments(expenditures: List): List { + val totalAmount = expenditures.fold(0f) { acc, expenditure -> acc.plus(expenditure.amount) } + val maxAmount = expenditures.maxOf { it.amount } + var previousAngle = 0f + return expenditures.map { expenditure -> + val angleValue = (expenditure.amount * 360f).roundToInt() / totalAmount + val color = + if (TESTING_MODE) getTestColor(expenditure.id) else ColorGenerator.generateColor() + PieChartSegment( + id = expenditure.id, + name = expenditure.name, + amount = expenditure.amount, + category = expenditure.category, + startAngle = previousAngle, + endAngle = previousAngle + angleValue, + percentageOfMaximum = (expenditure.amount * 100f).roundToInt() / maxAmount, + color = color + ).also { previousAngle += angleValue } + } + } + + @ColorInt + private fun getTestColor(id: Int): Int { + return when (id) { + 1 -> Color.rgb(131, 88, 246) + 2 -> Color.rgb(240, 143, 103) + 3 -> Color.rgb(93, 111, 246) + 4 -> Color.rgb(112, 224, 184) + 5 -> Color.rgb(106, 181, 224) + else -> ColorGenerator.generateColor() + } + } + + + companion object { + private const val TESTING_MODE = true + } +} \ No newline at end of file diff --git a/app/src/main/res/layout-land/activity_main.xml b/app/src/main/res/layout-land/activity_main.xml new file mode 100644 index 00000000..4e42e1fe --- /dev/null +++ b/app/src/main/res/layout-land/activity_main.xml @@ -0,0 +1,29 @@ + + + + + + + + \ 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..a04a0325 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,19 +1,29 @@ - + tools:context=".views.MainActivity"> - + + \ No newline at end of file diff --git a/app/src/main/res/raw/test_linear_chart_payload.json b/app/src/main/res/raw/test_linear_chart_payload.json new file mode 100644 index 00000000..5c372b79 --- /dev/null +++ b/app/src/main/res/raw/test_linear_chart_payload.json @@ -0,0 +1,66 @@ +[ + {"id": 1, "name": "Азбука Вкуса", "amount": 0, "category": "Продукты", "time": 1659908034}, + {"id": 2, "name": "Азбука Вкуса", "amount": 1080, "category": "Продукты", "time": 1659994434}, + {"id": 3, "name": "Азбука Вкуса", "amount": 1280, "category": "Продукты", "time": 1660080834}, + {"id": 4, "name": "Азбука Вкуса", "amount": 1320, "category": "Продукты", "time": 1660167234}, + {"id": 5, "name": "Азбука Вкуса", "amount": 1320, "category": "Продукты", "time": 1660253634}, + {"id": 6, "name": "Азбука Вкуса", "amount": 2800, "category": "Продукты", "time": 1660340034}, + {"id": 7, "name": "Азбука Вкуса", "amount": 2800, "category": "Продукты", "time": 1660426434}, + {"id": 8, "name": "Азбука Вкуса", "amount": 7500, "category": "Продукты", "time": 1660512834}, + {"id": 9, "name": "Азбука Вкуса", "amount": 12500, "category": "Продукты", "time": 1660599234}, + {"id": 10, "name": "Азбука Вкуса", "amount": 13500, "category": "Продукты", "time": 1660685634}, + {"id": 11, "name": "Азбука Вкуса", "amount": 10000, "category": "Продукты", "time": 1660772034}, + {"id": 12, "name": "Азбука Вкуса", "amount": 8000, "category": "Продукты", "time": 1660858434}, + {"id": 13, "name": "Азбука Вкуса", "amount": 7500, "category": "Продукты", "time": 1660944834}, + {"id": 14, "name": "Азбука Вкуса", "amount": 6000, "category": "Продукты", "time": 1661031234}, + {"id": 15, "name": "Азбука Вкуса", "amount": 6000, "category": "Продукты", "time": 1661117634}, + + + {"id": 16, "name": "Ригла", "amount": 0, "category": "Здоровье", "time": 1659908034}, + {"id": 18, "name": "Ригла", "amount": 2500, "category": "Здоровье", "time": 1660080834}, + {"id": 19, "name": "Ригла", "amount": 3000, "category": "Здоровье", "time": 1660167234}, + {"id": 20, "name": "Ригла", "amount": 3000, "category": "Здоровье", "time": 1660253634}, + {"id": 21, "name": "Ригла", "amount": 3100, "category": "Здоровье", "time": 1660340034}, + {"id": 22, "name": "Ригла", "amount": 3900, "category": "Здоровье", "time": 1660426434}, + {"id": 23, "name": "Ригла", "amount": 5000, "category": "Здоровье", "time": 1660512834}, + {"id": 24, "name": "Ригла", "amount": 8700, "category": "Здоровье", "time": 1660599234}, + {"id": 25, "name": "Ригла", "amount": 12100, "category": "Здоровье", "time": 1660685634}, + {"id": 26, "name": "Ригла", "amount": 8300, "category": "Здоровье", "time": 1660772034}, + {"id": 27, "name": "Ригла", "amount": 8000, "category": "Здоровье", "time": 1660858434}, + {"id": 28, "name": "Ригла", "amount": 7300, "category": "Здоровье", "time": 1660944834}, + {"id": 29, "name": "Ригла", "amount": 6900, "category": "Здоровье", "time": 1661031234}, + {"id": 30, "name": "Ригла", "amount": 6000, "category": "Здоровье", "time": 1661117634}, + + {"id": 31, "name": "Truffo", "amount": 2000, "category": "Кафе и рестораны", "time": 1659378834}, + {"id": 32, "name": "Truffo", "amount": 2200, "category": "Кафе и рестораны", "time": 1659551634}, + {"id": 33, "name": "Truffo", "amount": 4800, "category": "Кафе и рестораны", "time": 1659638034}, + {"id": 34, "name": "Truffo", "amount": 5300, "category": "Кафе и рестораны", "time": 1659810834}, + {"id": 35, "name": "Truffo", "amount": 5350, "category": "Кафе и рестораны", "time": 1659908034}, + {"id": 36, "name": "Truffo", "amount": 9850, "category": "Кафе и рестораны", "time": 1660080834}, + {"id": 37, "name": "Truffo", "amount": 10300, "category": "Кафе и рестораны", "time": 1660167234}, + {"id": 38, "name": "Truffo", "amount": 10000, "category": "Кафе и рестораны", "time": 1660340034}, + {"id": 39, "name": "Truffo", "amount": 12500, "category": "Кафе и рестораны", "time": 1660426434}, + {"id": 40, "name": "Truffo", "amount": 12700, "category": "Кафе и рестораны", "time": 1660685634}, + {"id": 41, "name": "Truffo", "amount": 7400, "category": "Кафе и рестораны", "time": 1660944834}, + {"id": 42, "name": "Truffo", "amount": 1200, "category": "Кафе и рестораны", "time": 1661117634}, + + {"id": 43, "name": "Uber", "amount": 1200, "category": "Транспорт", "time": 1659378834}, + {"id": 44, "name": "Uber", "amount": 5500, "category": "Транспорт", "time": 1659551634}, + {"id": 45, "name": "Uber", "amount": 2700, "category": "Транспорт", "time": 1659724434}, + {"id": 46, "name": "Uber", "amount": 4000, "category": "Транспорт", "time": 1659810834}, + {"id": 47, "name": "Uber", "amount": 4000, "category": "Транспорт", "time": 1659908034}, + {"id": 2, "name": "Uber", "amount": 12000, "category": "Транспорт", "time": 1659994434}, + {"id": 3, "name": "Uber", "amount": 12000, "category": "Транспорт", "time": 1660080834}, + {"id": 4, "name": "Uber", "amount": 16700, "category": "Транспорт", "time": 1660167234}, + {"id": 5, "name": "Uber", "amount": 17000, "category": "Транспорт", "time": 1660253634}, + {"id": 6, "name": "Uber", "amount": 17000, "category": "Транспорт", "time": 1660340034}, + {"id": 7, "name": "Uber", "amount": 15400, "category": "Транспорт", "time": 1660426434}, + {"id": 8, "name": "Uber", "amount": 14700, "category": "Транспорт", "time": 1660512834}, + {"id": 9, "name": "Uber", "amount": 15000, "category": "Транспорт", "time": 1660599234}, + {"id": 10, "name": "Uber", "amount": 16100, "category": "Транспорт", "time": 1660685634}, + {"id": 11, "name": "Uber", "amount": 12000, "category": "Транспорт", "time": 1660772034}, + {"id": 12, "name": "Uber", "amount": 10000, "category": "Транспорт", "time": 1660858434}, + {"id": 13, "name": "Uber", "amount": 7500, "category": "Транспорт", "time": 1660944834}, + {"id": 14, "name": "Uber", "amount": 7300, "category": "Транспорт", "time": 1661031234}, + {"id": 15, "name": "Uber", "amount": 6900, "category": "Транспорт", "time": 1661117634} +] \ No newline at end of file diff --git a/app/src/main/res/raw/test_pie_chart_payload.json b/app/src/main/res/raw/test_pie_chart_payload.json new file mode 100644 index 00000000..1518b95f --- /dev/null +++ b/app/src/main/res/raw/test_pie_chart_payload.json @@ -0,0 +1,37 @@ +[ + { + "id": 1, + "name": "Азбука Вкуса", + "amount": 19.8912, + "category": "Продукты", + "time": 1623318531 + }, + { + "id": 2, + "name": "Ригла", + "amount": 11.1888, + "category": "Здоровье", + "time": 1623322251 + }, + { + "id": 3, + "name": "Пятерочка", + "amount": 7.4592, + "category": "Продукты", + "time": 1623322371 + }, + { + "id": 4, + "name": "Truffo", + "amount": 14.9184, + "category": "Кафе и рестораны", + "time": 1623326031 + }, + { + "id": 5, + "name": "Simple Wine", + "amount": 8.7024, + "category": "Алкоголь", + "time": 1623329631 + } +] \ No newline at end of file diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml new file mode 100644 index 00000000..44155ed2 --- /dev/null +++ b/app/src/main/res/values/dimens.xml @@ -0,0 +1,5 @@ + + + 192dp + 192dp + \ No newline at end of file