diff --git a/app/build.gradle b/app/build.gradle index b4711913..bcc476e0 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,6 +1,8 @@ plugins { id 'com.android.application' id 'kotlin-android' + id 'kotlin-kapt' + id 'kotlin-parcelize' } android { @@ -42,4 +44,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..3c99be11 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -9,7 +9,7 @@ android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" - android:theme="@style/Theme.CustomView"> + android:theme="@style/Theme.AppCompat"> diff --git a/app/src/main/java/otus/homework/customview/Expense.kt b/app/src/main/java/otus/homework/customview/Expense.kt new file mode 100644 index 00000000..7018afc1 --- /dev/null +++ b/app/src/main/java/otus/homework/customview/Expense.kt @@ -0,0 +1,13 @@ +package otus.homework.customview + +import com.google.gson.annotations.SerializedName +import java.util.* + +data class Expense( + @SerializedName("amount") val amount: Int, + @SerializedName("category") val category: String, + @SerializedName("time") val time: Long +) { + val date: Date + get() = Date(time * 1000L).removeTime() +} \ No newline at end of file diff --git a/app/src/main/java/otus/homework/customview/GraphView.kt b/app/src/main/java/otus/homework/customview/GraphView.kt new file mode 100644 index 00000000..34cae729 --- /dev/null +++ b/app/src/main/java/otus/homework/customview/GraphView.kt @@ -0,0 +1,191 @@ +package otus.homework.customview + +import android.content.Context +import android.graphics.* +import android.os.Parcel +import android.os.Parcelable +import android.util.AttributeSet +import android.view.View +import java.util.* + +class GraphView(context: Context, attr: AttributeSet) : View(context, attr) { + + private val path = Path() + + private val separatorsPaint = Paint().apply { + strokeWidth = resources.getDimension(R.dimen.graph_separator_width) + style = Paint.Style.STROKE + color = Color.LTGRAY + } + + private val periodNamePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + textSize = resources.getDimension(R.dimen.graph_axis_name_text_size) + color = Color.LTGRAY + } + + private val graphPaint = Paint().apply { + color = Color.BLUE + style = Paint.Style.STROKE + strokeWidth = resources.getDimension(R.dimen.graph_line_stroke_width) + } + + private val graphPointPaint = Paint().apply { + color = Color.GREEN + style = Paint.Style.FILL + } + + private var periodWidth = resources.getDimensionPixelSize(R.dimen.graph_period_width) + private val rowHeight = resources.getDimensionPixelSize(R.dimen.graph_row_height) + + private val rowTextPadding = 10f + private val rowPadding + get() = periodNamePaint.measureText(yPeriods.maxOrNull().toString()) + rowTextPadding + + private val periodPadding = + resources.getDimensionPixelSize(R.dimen.graph_period_padding).toFloat() + + private val pointHeight + get() = rowHeight.toFloat() / yPeriods[yPeriods.size - 2].toFloat() + + private var xPeriods: List = emptyList() + + private var yPeriods: List = emptyList() + + private var points: List = emptyList() + + private var data: Map = emptyMap() + + private var defaultWidth = resources.getDimensionPixelSize(R.dimen.graph_period_default_width) + private var defaultMinWidth = resources.getDimensionPixelSize(R.dimen.graph_period_default_min_width) + + override fun onSaveInstanceState(): Parcelable { + return SavedState(super.onSaveInstanceState()).also { state -> + state.data = data + } + } + + override fun onRestoreInstanceState(state: Parcelable?) { + if (state is SavedState) { + super.onRestoreInstanceState(state.superState) + setData(state.data) + } else { + super.onRestoreInstanceState(state) + } + } + + fun setData(data: Map) { + this.data = data + this.initAxisX(data.keys.toList()) + this.initAxisY(data.values.maxOrNull() ?: 0) + this.initPoints(data) + + requestLayout() + invalidate() + } + + private fun initPoints(data: Map) { + points = data.map { pair -> + val x = rowPadding + xPeriods.indexOf(pair.key.format("MMM d")) * periodWidth + val y = (yPeriods.size * rowHeight) - pair.value * pointHeight + + Point(x, y) + } + } + + private fun initAxisX(dates: List) { + this.xPeriods = dates.sorted().toMutableList().apply { + add(0, this.first().addDays(-1)) + add(this.last().addDays(1)) + }.map { it.format("MMM d") } + } + + private fun initAxisY(maxAmount: Int) { + this.yPeriods = (0..maxAmount step maxAmount / 3).toList().sortedDescending() + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val width = when (MeasureSpec.getMode(widthMeasureSpec)) { + MeasureSpec.UNSPECIFIED -> + if (data.isEmpty()) 0 else { + Math.max(defaultWidth * data.size, defaultMinWidth) + } else -> MeasureSpec.getSize(widthMeasureSpec) + } + + val contentHeight = rowHeight * (yPeriods.size + 1) + val heightSpecSize = MeasureSpec.getSize(heightMeasureSpec) + val height = when (MeasureSpec.getMode(widthMeasureSpec)) { + MeasureSpec.UNSPECIFIED -> contentHeight + MeasureSpec.EXACTLY -> heightSpecSize + MeasureSpec.AT_MOST -> contentHeight.coerceAtMost(heightSpecSize) + else -> error("Unreachable") + } + + setMeasuredDimension(width, height) + } + + override fun onDraw(canvas: Canvas?) { + if (data.isEmpty() || canvas == null) return + + with(canvas) { + drawGrid() + drawLine() + } + } + + private fun Paint.getTextBaselineByCenter(center: Float) = center - (descent() + ascent()) / 2 + + private fun Canvas.drawGrid() { + yPeriods.forEachIndexed { index, rowName -> + val startY = rowHeight.toFloat() * index + periodPadding + val stopX = periodWidth.toFloat() * (xPeriods.size - 1) + rowPadding + drawLine(rowPadding, startY, stopX, startY, separatorsPaint) + + val name = rowName.toString() + val nameX = rowPadding - rowTextPadding - periodNamePaint.measureText(name) + drawText(name, nameX, periodNamePaint.getTextBaselineByCenter(startY), periodNamePaint) + } + + xPeriods.forEachIndexed { index, periodName -> + val startX = periodWidth.toFloat() * index + rowPadding + val stopY = yPeriods.size * rowHeight.toFloat() + drawLine(startX, periodPadding, startX, stopY, separatorsPaint) + + val nameX = startX - periodNamePaint.measureText(periodName) / 2 + val nameY = + rowHeight.toFloat() * yPeriods.size - (periodNamePaint.descent() + periodNamePaint.ascent()) + 14f + drawText(periodName, nameX, nameY, periodNamePaint) + } + } + + private fun Canvas.drawLine() { + path.reset() + path.moveTo(points[0].x, points[0].y) + points.forEach { + path.lineTo(it.x, it.y) + drawPath(path, graphPaint) + drawCircle(it.x, it.y, 5f, graphPointPaint) + } + } + + private inner class Point(val x: Float, val y: Float) + + private class SavedState : BaseSavedState { + + var data: Map = emptyMap() + + constructor(parcelable: Parcelable?) : super(parcelable) + + private constructor(parcel: Parcel?) : super(parcel) { + parcel?.let { parcel.readMap(data, Map::class.java.classLoader) } + } + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeMap(data) + } + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel) = SavedState(parcel) + override fun newArray(size: Int): Array = arrayOfNulls(size) + } + } +} \ 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..6139a44b 100644 --- a/app/src/main/java/otus/homework/customview/MainActivity.kt +++ b/app/src/main/java/otus/homework/customview/MainActivity.kt @@ -1,11 +1,65 @@ package otus.homework.customview -import androidx.appcompat.app.AppCompatActivity +import android.animation.AnimatorSet +import android.animation.ObjectAnimator import android.os.Bundle +import android.view.View +import android.view.animation.BounceInterpolator +import androidx.appcompat.app.AppCompatActivity +import com.google.gson.Gson class MainActivity : AppCompatActivity() { + + private lateinit var pieChart: PieChartView + private lateinit var graph: GraphView + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) + + pieChart = findViewById(R.id.pie_chart_view) + graph = findViewById(R.id.graph_view) + + setupPieChart() + } + + private fun setupPieChart() { + val payload: String = + resources.openRawResource(R.raw.payload).bufferedReader().use { it.readText() } + + val payments = Gson().fromJson(payload, Array::class.java).toList() + val total = payments.sumBy { it.amount } + + val byCategory = payments.groupBy { it.category } + + val pieChartData = byCategory.mapTo(mutableListOf()) { category -> + Pair(category.key, category.value.sumOf { it.amount } * 100f/ total ) + } + + pieChart.setData(total, pieChartData) + runAnimation() + pieChart.onClickListener = { category -> + byCategory[category]?.let { list -> graph.setData(list.sortedBy { it.time }.associate { it.date to it.amount }) } + } + } + + private fun runAnimation() { + val rotationAnimator = ObjectAnimator.ofFloat(pieChart, View.ROTATION, 0F, 360F) + val scaleXAnimator = ObjectAnimator.ofFloat(pieChart, View.SCALE_X, 0F, 1F) + .apply { + interpolator = BounceInterpolator() + } + val scaleYAnimator = ObjectAnimator.ofFloat(pieChart, View.SCALE_Y, 0F, 1F) + .apply { + interpolator = BounceInterpolator() + } + val animatorSet = AnimatorSet() + animatorSet.apply { + startDelay = 300 + duration = 3000 + } + animatorSet.playTogether(rotationAnimator, scaleXAnimator, scaleYAnimator) + pieChart.visibility = View.VISIBLE + animatorSet.start() } } \ 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..368013ac --- /dev/null +++ b/app/src/main/java/otus/homework/customview/PieChartView.kt @@ -0,0 +1,230 @@ +package otus.homework.customview + +import android.content.Context +import android.graphics.* +import android.os.Parcel +import android.os.Parcelable +import android.text.TextPaint +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View +import java.lang.Math.min +import kotlin.math.absoluteValue +import kotlin.math.atan2 +import kotlin.math.pow +import kotlin.math.sqrt + +class PieChartView(context: Context, attr: AttributeSet) : View(context, attr) { + + var onClickListener: ((key: String) -> Unit)? = null + + private var strokeWidth: Float = resources.getDimension(R.dimen.pie_chart_stroke_width) + + private val slicePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = this@PieChartView.strokeWidth + } + + private val centerTextPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { + textAlign = Paint.Align.CENTER + } + + private var circleRect = RectF() + + private var circleRadius = 0f + + private var slices: List = emptyList() + + private var totalAmount: Int? = null + + private val sliceColors: List = listOf( + Color.BLUE, + Color.CYAN, + Color.GREEN, + Color.WHITE, + Color.MAGENTA, + Color.RED, + Color.YELLOW, + Color.DKGRAY, + Color.LTGRAY, + Color.GRAY + ) + + fun setData(total: Int, data: List>) { + var startAngle = 0f + this.slices = data + .sortedBy { it.second } + .mapIndexed { index, pair -> + Slice(pair.first, pair.second, startAngle, sliceColors[index]).also { + startAngle = startAngle.plus(it.sweepAngle) + } + } + + this.totalAmount = total + + requestLayout() + invalidate() + } + + override fun onSaveInstanceState(): Parcelable { + return SavedState(super.onSaveInstanceState()).also { state -> + state.total = totalAmount ?: 0 + state.data = slices.map { Pair(it.name, it.percent) } + } + } + + override fun onRestoreInstanceState(state: Parcelable?) { + if (state is SavedState) { + super.onRestoreInstanceState(state.superState) + setData(state.total, state.data) + } else { + super.onRestoreInstanceState(state) + } + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val width = MeasureSpec.getSize(widthMeasureSpec) + val heightInSpec = MeasureSpec.getSize(heightMeasureSpec) + + val height = when (MeasureSpec.getMode(heightMeasureSpec)) { + MeasureSpec.UNSPECIFIED -> width + MeasureSpec.AT_MOST -> width.coerceAtMost(heightInSpec) + MeasureSpec.EXACTLY -> heightInSpec + else -> error("Unreachable") + } + + setMeasuredDimension(width, height) + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + + val side = min(w, h) + + val left = paddingLeft + strokeWidth / 2f + val top = paddingTop + strokeWidth / 2f + val right = side - paddingRight - strokeWidth / 2f + val bottom = side - paddingBottom - strokeWidth / 2f + + circleRect.set(left, top, right, bottom) + circleRadius = (circleRect.width() + strokeWidth) / 2 + } + + + override fun onDraw(canvas: Canvas?) { + if (canvas == null || slices.isEmpty()) return + + with(canvas) { + drawSlice() + drawCenterText() + } + + } + + private fun Canvas.drawSlice() { + slices.forEach { + drawArc( + circleRect, + it.startAngle, + it.sweepAngle, + false, + slicePaint.apply { color = it.color }) + } + } + + private fun Canvas.drawCenterText() { + totalAmount.let { + val x = circleRect.centerX() + val y = circleRect.centerY() + + centerTextPaint.apply { + color = Color.RED + textSize = resources.getDimension(R.dimen.pie_chart_center_text_size) + typeface = Typeface.DEFAULT_BOLD + } + + drawText(it.toString(), x, y, centerTextPaint) + + val subtext = resources.getString(R.string.pai_chart_center_text) + val subtextPadding = resources.getDimension(R.dimen.pie_chart_center_text_padding) + + centerTextPaint.apply { + color = Color.GRAY + textSize = resources.getDimension(R.dimen.pie_chart_center_subtext_size) + typeface = Typeface.DEFAULT + } + + drawText( + subtext, + x, + y.plus(centerTextPaint.textSize).plus(subtextPadding), + centerTextPaint + ) + } + + } + + override fun onTouchEvent(event: MotionEvent?): Boolean { + event ?: return false + + return when (event.action) { + MotionEvent.ACTION_DOWN -> { + val x = event.x - circleRect.centerX() + val y = event.y - circleRect.centerY() + + val r = sqrt(x.pow(2) + y.pow(2)) + + if (r in circleRadius.minus(strokeWidth)..circleRadius) { + var rAngle = ((atan2(y, x) * 180) / Math.PI) + rAngle = if (rAngle < 0) 360 - rAngle.absoluteValue else rAngle + + val slice = slices.first { + rAngle in it.startAngle..it.startAngle + it.sweepAngle + } + + onClickListener?.invoke(slice.name) + + true + } else { + false + } + } + else -> false + } + } + + private inner class Slice( + val name: String, + val percent: Float, + val startAngle: Float, + val color: Int + ) { + val sweepAngle: Float + get() = percent * 360f / 100f + } + + private class SavedState : BaseSavedState { + + var data: List> = emptyList() + var total: Int = 0 + + constructor(parcelable: Parcelable?) : super(parcelable) + + private constructor(parcel: Parcel?) : super(parcel) { + parcel?.let { + parcel.readList(data, Pair::class.java.classLoader) + total = parcel.readInt() + } + } + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeList(data) + parcel.writeInt(total) + } + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel) = SavedState(parcel) + override fun newArray(size: Int): Array = arrayOfNulls(size) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/otus/homework/customview/UtilsDate.kt b/app/src/main/java/otus/homework/customview/UtilsDate.kt new file mode 100644 index 00000000..eff7c318 --- /dev/null +++ b/app/src/main/java/otus/homework/customview/UtilsDate.kt @@ -0,0 +1,22 @@ +package otus.homework.customview + +import java.text.SimpleDateFormat +import java.util.* + +fun Date.removeTime(): Date = + Calendar.getInstance().apply { + time = this@removeTime + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + }.time + +fun Date.addDays(amount: Int): Date = + Calendar.getInstance().apply { + time = this@addDays + add(Calendar.DATE, amount) + }.time + +fun Date.format(pattern: String): String = + SimpleDateFormat(pattern, Locale.getDefault()).format(this) \ 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..39ec37e7 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,19 +1,29 @@ - - - + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + android:visibility="gone"/> + + + \ No newline at end of file diff --git a/app/src/main/res/raw/payload.json b/app/src/main/res/raw/payload.json index 5492267a..540b6947 100644 --- a/app/src/main/res/raw/payload.json +++ b/app/src/main/res/raw/payload.json @@ -81,6 +81,54 @@ "name": "Uber", "amount": 389, "category": "Транспорт", - "time": 1623419934 + "time": 1623473958 + }, + { + "id": 13, + "name": "Uber", + "amount": 500, + "category": "Транспорт", + "time": 1623646758 + }, + { + "id": 14, + "name": "Uber", + "amount": 120, + "category": "Транспорт", + "time": 1623992358 + }, + { + "id": 15, + "name": "Uber", + "amount": 1020, + "category": "Транспорт", + "time": 1624640418 + }, + { + "id": 16, + "name": "Uber", + "amount": 1520, + "category": "Транспорт", + "time": 1623240000 + }, + { + "id": 17, + "name": "Бензин", + "amount": 933, + "category": "Автомобиль", + "time": 1623240000 + }, + { + "id": 18, + "name": "Wildberry", + "amount": 2000, + "category": "Товары", + "time": 1623240000 + },{ + "id": 19, + "name": "HDD 1ТБ", + "amount": 5690, + "category": "Комьютер", + "time": 1623240000 } ] \ No newline at end of file diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml new file mode 100644 index 00000000..c5978bf1 --- /dev/null +++ b/app/src/main/res/values/attrs.xml @@ -0,0 +1,6 @@ + + + + + + \ 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..3ef72e31 --- /dev/null +++ b/app/src/main/res/values/dimens.xml @@ -0,0 +1,17 @@ + + + 50dp + 12sp + 32sp + 8dp + + 60dp + 20dp + 1dp + 8sp + 2sp + 20dp + 8dp + 100dp + 300dp + \ 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..a02539f5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,4 @@ Custom View + Сумма затрат \ No newline at end of file