diff --git a/app/build.gradle b/app/build.gradle index b4711913..e265041e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -27,6 +27,9 @@ android { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } + buildFeatures { + viewBinding true + } kotlinOptions { jvmTarget = '1.8' } @@ -42,4 +45,5 @@ dependencies { testImplementation 'junit:junit:4.+' androidTestImplementation 'androidx.test.ext:junit:1.1.2' androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' -} \ No newline at end of file + implementation 'com.google.code.gson:gson:2.10' +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index efd1e519..fcf92f2a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,5 @@ - + @@ -19,4 +21,4 @@ - \ No newline at end of file + diff --git a/app/src/main/java/otus/homework/customview/DetailsActivity.kt b/app/src/main/java/otus/homework/customview/DetailsActivity.kt new file mode 100644 index 00000000..338ce70d --- /dev/null +++ b/app/src/main/java/otus/homework/customview/DetailsActivity.kt @@ -0,0 +1,30 @@ +package otus.homework.customview + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import otus.homework.customview.databinding.ActivityDetailsBinding + +class DetailsActivity : AppCompatActivity() { + + private lateinit var binding: ActivityDetailsBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityDetailsBinding.inflate(layoutInflater) + setContentView(binding.root) + + val type = object : TypeToken>() {}.type + binding.chartDetails.setup( + Gson().fromJson(intent.getStringExtra(KEY_LIST), type)?: listOf() + ) + + } + + + companion object { + const val KEY_LIST = "KEY_LIST" + } + +} diff --git a/app/src/main/java/otus/homework/customview/DetailsChart.kt b/app/src/main/java/otus/homework/customview/DetailsChart.kt new file mode 100644 index 00000000..eef2a4ff --- /dev/null +++ b/app/src/main/java/otus/homework/customview/DetailsChart.kt @@ -0,0 +1,286 @@ +package otus.homework.customview + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.CornerPathEffect +import android.graphics.DashPathEffect +import android.graphics.Paint +import android.graphics.Paint.Align +import android.graphics.Path +import android.os.Bundle +import android.os.Parcel +import android.os.Parcelable +import android.util.AttributeSet +import android.view.View +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import java.lang.Integer.min +import java.util.Calendar +import kotlin.math.ceil +import kotlin.math.max + +class DetailsChart @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +): View(context, attrs) { + + private var items: List = listOf() + + private var chartData: List = listOf() + private var yStep = 5000 + private var xStep = 1 + private var xCount = 10 + + private val defaultHeight = 100.dp + private val defaultColumnWidth = 32.dp + private val defaultColumnHeight = 100.dp + + private val calendar = Calendar.getInstance() + + private val noDataPaint: Paint = Paint().apply { + textSize = 72.sp.toFloat() + color = Color.BLACK + textAlign = Paint.Align.CENTER + } + + private val lineStrokePaint = Paint().apply { + color = Color.GRAY + strokeWidth = 1.dp.toFloat() + style = Paint.Style.STROKE + pathEffect = DashPathEffect(floatArrayOf(5f, 10f, 5f, 10f), 25f) + } + + private val textPaint: Paint = Paint().apply { + color = Color.GRAY + style = Paint.Style.STROKE + textSize = 14.sp.toFloat() + } + + private val chartPaint: Paint = Paint().apply { + color = Color.RED + style = Paint.Style.STROKE + strokeWidth = 4.dp.toFloat() + pathEffect = CornerPathEffect(2.dp.toFloat()) + } + + val path = Path() + + init { + if (isInEditMode) { + setup(listOf( + PayItem(350, 1694476800), + PayItem(589, 1694476800), + PayItem(369, 1694563200), + PayItem(1000, 1694736000), + PayItem(349, 1694822400), + )) + } + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val wMode = MeasureSpec.getMode(widthMeasureSpec) + val hMode = MeasureSpec.getMode(heightMeasureSpec) + val wSize = MeasureSpec.getSize(widthMeasureSpec) + val hSize = MeasureSpec.getSize(heightMeasureSpec) + + if (items.isEmpty()) { + setMeasuredDimension(wSize, max(defaultHeight, hSize)) + return + } + + val newW: Int = when (wMode) { + MeasureSpec.EXACTLY -> { + wSize + } + MeasureSpec.AT_MOST -> { + min(defaultColumnWidth * xCount, wSize) + } + else -> { + defaultColumnWidth * xCount + } + } + + when (hMode) { + MeasureSpec.EXACTLY -> { + setMeasuredDimension(newW, hSize) + } + MeasureSpec.AT_MOST -> { + setMeasuredDimension(newW, min(defaultColumnHeight * 4, hSize)) + } + else -> { + setMeasuredDimension(newW, defaultColumnHeight * 4) + } + } + } + + override fun onDraw(canvas: Canvas) { + + if (chartData.isEmpty()) { + val textHeight = noDataPaint.fontMetrics.descent - noDataPaint.fontMetrics.ascent + canvas.drawText("No data!", (width / 2).toFloat(), height / 2 + textHeight / 3, noDataPaint) + return + } + + val x0 = 5f + val y0 = 5f + val xEnd = width - 10f + val yEnd = height - 10f - 14.dp + + val xStepP = (xEnd - x0) / (xCount - 1) + val yStepP = (yEnd - x0) / 4 + + path.reset() + for (i in 0..4) { + val y = yStepP * i + y0 + path.moveTo(x0, y) + path.lineTo(xEnd, y) + } + + for (i in 0 until xCount) { + val x = xStepP * i + x0 + path.moveTo(x, y0) + path.lineTo(x, yEnd) + } + + canvas.drawPath(path, lineStrokePaint) + + for (i in 0 until xCount) { + + calendar.timeInMillis = chartData[i * xStep].time * 60 * 60 * 24 * 1000 + val str = "${calendar.get(Calendar.DAY_OF_MONTH)}" + if (i == 0) { + textPaint.textAlign = Align.LEFT + canvas.drawText(str, x0, height - 5f, textPaint) + continue + } + if (i == xCount - 1) { + textPaint.textAlign = Align.RIGHT + canvas.drawText(str, xStepP * i + x0, height - 5f, textPaint) + continue + } + + textPaint.textAlign = Align.CENTER + canvas.drawText(str, xStepP * i + x0, height - 5f, textPaint) + } + + for (i in 4 downTo 1) { + textPaint.textAlign = Align.RIGHT + canvas.drawText("${yStep * i} р. ", xEnd, (4 - i) * yStepP + 14.dp, textPaint) + } + + + path.reset() + var x1 = x0 + var y1 = yEnd - (chartData[0].amount.toFloat() / yStep * 4) * yStepP / yStep + path.moveTo(x1, y1) + + for (i in chartData.withIndex()) { + if (i.index == 0) { + + continue + } + + val x2 = xStepP / xStep * i.index + val y2 = yEnd - i.value.amount * yStepP / yStep + val xMid = (x2 - x1) / 2 + x1 + path.cubicTo(xMid, y1, xMid, y2, x2, y2) + x1 = x2 + y1 = y2 + } + canvas.drawPath(path, chartPaint) + + } + + override fun onSaveInstanceState(): Parcelable { + val bundle = Bundle() + bundle.putString(SavedState.keyOneCategory, Gson().toJson(items)) + val superState = super.onSaveInstanceState() + return SavedState(superState, bundle) + } + + override fun onRestoreInstanceState(state: Parcelable?) { + if (state is SavedState) { + super.onRestoreInstanceState(state.superState) + val type = object : TypeToken>() {}.type + items = Gson().fromJson(state.data, type) + setup(items) + } else { + super.onRestoreInstanceState(state) + } + } + + fun setup(items: List) { + this.items = items + val data = items.sortedBy { it.time } + .map { it.copy(time = it.time / 60 / 60 / 24) } + .groupBy { it.time } + .map { (day, amounts) -> PayItem(amounts.sumBy { it.amount }, day) } + + val maxAmount = data.maxOf { it.amount } + yStep = ceil(maxAmount / 4 / 100f).toInt() * 100 + + val maxDay = data.maxOf { it.time } + val minDay = data.minOf { it.time } + + val verticalCount: Int = when (val tmpCount = maxDay - minDay + 1) { + in 0..2 -> tmpCount.toInt() + 4 + in 3..4 -> tmpCount.toInt() + 2 + in 5..10 -> tmpCount.toInt() + else -> ceil(tmpCount / 10f).toInt() * 10 + } + + if (verticalCount > 10) { + xCount = 10 + xStep = verticalCount / 10 + } else { + xCount = verticalCount + xStep = 1 + } + + val prev = (verticalCount - data.size) / 2 + val startDateDaysScience1970 = data[0].time - prev + + val list: MutableList = mutableListOf() + repeat(verticalCount) { + val d = startDateDaysScience1970 + it + list.add(data.find { it.time == d } ?: PayItem(0, d)) + } + chartData = list + } + + data class PayItem(val amount: Int, val time: Long) + + private class SavedState : BaseSavedState { + + var data: String = "" + + constructor(superState: Parcelable?, bundle: Bundle) : super(superState) { + data = bundle.getString(keyOneCategory, "") + } + + constructor(parcel: Parcel) : super(parcel) { + data = parcel.readString() ?: "" + } + + override fun writeToParcel(parcel: Parcel, flags: Int) { + super.writeToParcel(parcel, flags) + parcel.writeString(data) + } + + companion object CREATOR : Parcelable.Creator { + + const val keyOneCategory = "keyOneCategory" + + override fun createFromParcel(parcel: Parcel): SavedState { + return SavedState(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } + } + +} diff --git a/app/src/main/java/otus/homework/customview/Ectensions.kt b/app/src/main/java/otus/homework/customview/Ectensions.kt new file mode 100644 index 00000000..d13ea254 --- /dev/null +++ b/app/src/main/java/otus/homework/customview/Ectensions.kt @@ -0,0 +1,7 @@ +package otus.homework.customview + +import android.content.res.Resources.getSystem + + +val Int.dp: Int get() = (this * getSystem().displayMetrics.density).toInt() +val Int.sp: Int get() = (this * getSystem().displayMetrics.scaledDensity).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..a06b768a 100644 --- a/app/src/main/java/otus/homework/customview/MainActivity.kt +++ b/app/src/main/java/otus/homework/customview/MainActivity.kt @@ -1,11 +1,71 @@ package otus.homework.customview -import androidx.appcompat.app.AppCompatActivity +import android.content.Intent import android.os.Bundle +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import otus.homework.customview.DetailsActivity.Companion.KEY_LIST +import otus.homework.customview.databinding.ActivityMainBinding +import java.io.BufferedReader +import java.io.InputStreamReader class MainActivity : AppCompatActivity() { + + private lateinit var binding: ActivityMainBinding + + private lateinit var data: List + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.activity_main) + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + + if (savedInstanceState == null) { + val inputStream = resources.openRawResource(R.raw.payload) + val reader = BufferedReader(InputStreamReader(inputStream)) + val json = StringBuilder() + var line: String? + while (reader.readLine().also { line = it } != null) { + json.append(line) + } + reader.close() + + val gson = Gson() + val type = object : TypeToken>() {}.type + data = gson.fromJson(json.toString(), type) + + binding.chart.setup( + data + .groupBy { it.category } + .map { (cat, amounts) -> + PieChart.Category.OneCategory(cat, amounts.sumBy { it.amount }) }) + } + + binding.chart.onCategoryClickListener = object : PieChart.OnCategoryClickListener { + override fun onClick(category: PieChart.Category) { + val text = when (category) { + is PieChart.Category.MultipleCategories -> "${category.names} -> ${category.value}" + is PieChart.Category.OneCategory -> "${category.name} -> ${category.value}" + } + Toast.makeText(this@MainActivity, text, Toast.LENGTH_SHORT).show() + + val list = data.filter { + when (category) { + is PieChart.Category.MultipleCategories -> it.category in category.names + is PieChart.Category.OneCategory -> it.category == category.name + } + }.map { DetailsChart.PayItem(it.amount, it.time) } + + + + val intent = Intent(this@MainActivity, DetailsActivity::class.java) + intent.putExtra(KEY_LIST, Gson().toJson(list)) + startActivity(intent) + } + } + + } -} \ No newline at end of file +} diff --git a/app/src/main/java/otus/homework/customview/PayloadItem.kt b/app/src/main/java/otus/homework/customview/PayloadItem.kt new file mode 100644 index 00000000..cfcd25ca --- /dev/null +++ b/app/src/main/java/otus/homework/customview/PayloadItem.kt @@ -0,0 +1,9 @@ +package otus.homework.customview + +data class PayloadItem( + val id: Int, + val name: String, + val amount: Int, + val category: String, + val time: Long +) diff --git a/app/src/main/java/otus/homework/customview/PieChart.kt b/app/src/main/java/otus/homework/customview/PieChart.kt new file mode 100644 index 00000000..bd5a5830 --- /dev/null +++ b/app/src/main/java/otus/homework/customview/PieChart.kt @@ -0,0 +1,296 @@ +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.Bundle +import android.os.Parcel +import android.os.Parcelable +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import java.lang.Integer.min +import kotlin.math.atan2 +import kotlin.math.max + +class PieChart @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +): View(context, attrs) { + + var onCategoryClickListener: OnCategoryClickListener? = null + + private val colors = arrayListOf( + Color.RED, + Color.GREEN, + Color.GRAY, + Color.CYAN, + Color.MAGENTA, + Color.YELLOW, + Color.BLUE, + Color.DKGRAY, + Color.BLACK, + Color.LTGRAY, + Color.BLUE + ) + + private val defaultRadius = 50.dp + private var radius = defaultRadius + private val strokeWidth = 50.dp.toFloat() + private val maxCategories = 10 // Сколько категорий отображать. Остальные сгруппируются + + private var data: List = listOf() + private var chartData: List = listOf() + private var allSum = 0 + + private var circleX = 0 + private var circleY = 0 + + private val noDataPaint: Paint = Paint().apply { + textSize = 72.sp.toFloat() + color = Color.BLACK + textAlign = Paint.Align.CENTER + } + private val defaultHeight = 100.dp + + private val categoryPaint: Paint = Paint().apply { + style = Paint.Style.STROKE + strokeWidth = this@PieChart.strokeWidth + } + + private val chartRect = RectF() + + init { + + if (isInEditMode) { + setup(listOf( + Category.OneCategory("Продукты", 3500), + Category.OneCategory("Транспорт", 1000), + Category.OneCategory("Спорт", 1648), + Category.OneCategory("Кафе и рестораны", 800), + Category.OneCategory("Доставка еды", 364), + Category.OneCategory("Здоровье", 981), + Category.OneCategory("Образование", 2500), + Category.OneCategory("Развлечения", 884), + Category.OneCategory("Благотворительность", 1200), + Category.OneCategory("Разное", 3000) + )) + } + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val wMode = MeasureSpec.getMode(widthMeasureSpec) + val hMode = MeasureSpec.getMode(heightMeasureSpec) + val wSize = MeasureSpec.getSize(widthMeasureSpec) + val hSize = MeasureSpec.getSize(heightMeasureSpec) + + if (chartData.isEmpty()) { + setMeasuredDimension(wSize, max(defaultHeight, hSize)) + return + } + + val newW: Int = when (wMode) { + MeasureSpec.EXACTLY -> { + wSize + } + MeasureSpec.AT_MOST -> { + min(hSize, wSize) + } + else -> { + if (hMode == MeasureSpec.UNSPECIFIED) + (defaultRadius + strokeWidth * 2).toInt() + else + hSize + } + } + + when (hMode) { + MeasureSpec.EXACTLY -> { + setMeasuredDimension(newW, hSize) + } + MeasureSpec.AT_MOST -> { + if (newW < hSize) { + setMeasuredDimension(newW, newW) + } else { + setMeasuredDimension(newW, hSize) + } + } + else -> { + setMeasuredDimension(newW, newW) + } + } + } + + override fun onDraw(canvas: Canvas) { + if (chartData.isEmpty()) { + val textHeight = noDataPaint.fontMetrics.descent - noDataPaint.fontMetrics.ascent + canvas.drawText("No data!", (width / 2).toFloat(), height / 2 + textHeight / 3, noDataPaint) + return + } + + radius = max(min(width, height) - strokeWidth.toInt() * 2, defaultRadius) / 2 + + circleX = width / 2 + circleY = height / 2 + + chartRect.left = circleX - strokeWidth / 2 - radius + chartRect.top = circleY - strokeWidth / 2 - radius + chartRect.right = circleX + strokeWidth / 2 + radius + chartRect.bottom = circleY + strokeWidth / 2 + radius + + var startAngle = 0f + var endAngle: Float + + chartData.forEachIndexed { i, it -> + endAngle = if (i == chartData.size) { + 360f + } else { + startAngle + (it.value / allSum.toFloat() * 360) + } + drawChartPart(canvas, startAngle, endAngle, colors[i]) + startAngle = endAngle + } + + } + + private fun drawChartPart(canvas: Canvas, startAngle: Float, endAngle: Float, color: Int) { + categoryPaint.color = color + + canvas.drawArc(chartRect, startAngle - 90, endAngle - startAngle, false, categoryPaint) + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + if (event.action == MotionEvent.ACTION_DOWN) { + val tX = event.x.toDouble() + val tY = event.y.toDouble() + + val r1 = radius + val r2 = radius + strokeWidth + + val l = (tX - circleX) * (tX - circleX) + (tY - circleY) * (tY - circleY) + + if (l >= r1 * r1 && l <= r2 * r2) { + var angle = Math.toDegrees(atan2(tY - circleY, tX - circleX)) + 90 + if (angle < 0) + angle += 360 + + var startAngle = 0.0 + + chartData.forEachIndexed { i, it -> + val endAngle: Double = if (i == chartData.size) { + 360.0 + } else { + startAngle + (it.value / allSum.toFloat() * 360) + } + + if (angle > startAngle && angle < endAngle) { + onCategoryClickListener?.onClick(it) + return true + } + + startAngle = endAngle + } + } + } + + return false + } + + override fun onSaveInstanceState(): Parcelable { + val bundle = Bundle() + bundle.putString(SavedState.keyOneCategory, Gson().toJson(data)) + val superState = super.onSaveInstanceState() + return SavedState(superState, bundle) + } + + override fun onRestoreInstanceState(state: Parcelable?) { + if (state is SavedState) { + super.onRestoreInstanceState(state.superState) + val type = object : TypeToken>() {}.type + data = Gson().fromJson(state.oneCategoryList, type) + setup(data) + } else { + super.onRestoreInstanceState(state) + } + } + + fun setup(categories: List) { + data = categories + + // Сортируем по убыванию + val sorted = categories + .sortedBy { it.value} + .reversed() + + allSum = 0 + val list = mutableListOf() + val otherCategory = Category.MultipleCategories(arrayListOf(), 0) + + sorted.forEachIndexed { i, cat -> + if (i < maxCategories) { + list.add(cat) + } else { + otherCategory.names.add(cat.name) + otherCategory.value += cat.value + } + allSum += cat.value + } + + if (otherCategory.value > 0) { + list.add(otherCategory) + } + + chartData = list + } + + sealed class Category { + + abstract val value: Int + + data class OneCategory(val name: String, override val value: Int): Category() + + data class MultipleCategories(val names: MutableList, override var value: Int): Category() + + } + + interface OnCategoryClickListener { + fun onClick(category: Category) + } + + private class SavedState : BaseSavedState { + + var oneCategoryList: String = "" + + constructor(superState: Parcelable?, bundle: Bundle) : super(superState) { + oneCategoryList = bundle.getString(keyOneCategory, "") + } + + constructor(parcel: Parcel) : super(parcel) { + oneCategoryList = parcel.readString() ?: "" + } + + override fun writeToParcel(parcel: Parcel, flags: Int) { + super.writeToParcel(parcel, flags) + parcel.writeString(oneCategoryList) + } + + companion object CREATOR : Parcelable.Creator { + + const val keyOneCategory = "keyOneCategory" + + override fun createFromParcel(parcel: Parcel): SavedState { + return SavedState(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } + } + +} diff --git a/app/src/main/res/layout/activity_details.xml b/app/src/main/res/layout/activity_details.xml new file mode 100644 index 00000000..c49492d0 --- /dev/null +++ b/app/src/main/res/layout/activity_details.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 79ae6993..6b57c432 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"> - - \ No newline at end of file +