From 99e3514e2bdca08af36698eee46a254ec72c0543 Mon Sep 17 00:00:00 2001 From: GeorgeBozon Date: Sat, 20 Sep 2025 18:11:42 +0300 Subject: [PATCH] =?UTF-8?q?1.=20=D0=A1=D0=BE=D0=B7=D0=B4=D0=B0=D1=82=D1=8C?= =?UTF-8?q?=20PieChartView?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle | 12 + app/src/main/AndroidManifest.xml | 3 +- app/src/main/assets/payload.json | 88 +++++++ .../main/java/otus/homework/customview/App.kt | 17 ++ .../otus/homework/customview/MainActivity.kt | 11 - .../customview/data/UiDataFakeRepoImpl.kt | 19 ++ .../customview/data/UiDataJsonParser.kt | 20 ++ .../homework/customview/data/UiDataMapper.kt | 43 ++++ .../homework/customview/data/UiDataRepo.kt | 7 + .../homework/customview/di/AppComponent.kt | 17 ++ .../otus/homework/customview/di/AppModule.kt | 22 ++ .../customview/models/PieChartData.kt | 22 ++ .../customview/presentation/MainActivity.kt | 39 +++ .../customview/presentation/PieChart.kt | 235 ++++++++++++++++++ .../customview/presentation/PieChartModel.kt | 19 ++ app/src/main/res/layout/activity_main.xml | 6 +- gradle/libs.versions.toml | 7 +- 17 files changed, 570 insertions(+), 17 deletions(-) create mode 100644 app/src/main/assets/payload.json create mode 100644 app/src/main/java/otus/homework/customview/App.kt delete mode 100644 app/src/main/java/otus/homework/customview/MainActivity.kt create mode 100644 app/src/main/java/otus/homework/customview/data/UiDataFakeRepoImpl.kt create mode 100644 app/src/main/java/otus/homework/customview/data/UiDataJsonParser.kt create mode 100644 app/src/main/java/otus/homework/customview/data/UiDataMapper.kt create mode 100644 app/src/main/java/otus/homework/customview/data/UiDataRepo.kt create mode 100644 app/src/main/java/otus/homework/customview/di/AppComponent.kt create mode 100644 app/src/main/java/otus/homework/customview/di/AppModule.kt create mode 100644 app/src/main/java/otus/homework/customview/models/PieChartData.kt create mode 100644 app/src/main/java/otus/homework/customview/presentation/MainActivity.kt create mode 100644 app/src/main/java/otus/homework/customview/presentation/PieChart.kt create mode 100644 app/src/main/java/otus/homework/customview/presentation/PieChartModel.kt diff --git a/app/build.gradle b/app/build.gradle index f22e7497..d819b5b6 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,6 +1,9 @@ plugins { alias(libs.plugins.androidApplication) alias(libs.plugins.kotlinAndroid) + alias(libs.plugins.kotlin.serialization) + id 'kotlin-parcelize' + id 'kotlin-kapt' } android { @@ -29,6 +32,7 @@ android { } kotlinOptions { jvmTarget = '17' + freeCompilerArgs = ['-XXLanguage:+PropertyParamAnnotationDefaultTargetMode'] } buildFeatures { viewBinding true @@ -50,6 +54,14 @@ dependencies { implementation libs.bundles.network implementation libs.androidx.datastore implementation libs.androidx.datastore.preferences + implementation libs.kotlin.serialization.json + + implementation "com.google.dagger:dagger-android:2.50" + implementation "com.google.dagger:dagger-android-support:2.50" + kapt "com.google.dagger:dagger-android-processor:2.50" + + implementation 'com.google.dagger:dagger:2.50' + kapt 'com.google.dagger:dagger-compiler:2.50' testImplementation libs.junit androidTestImplementation libs.androidx.test.ext.junit diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c539d0bf..1d34de42 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,13 +4,14 @@ > - diff --git a/app/src/main/assets/payload.json b/app/src/main/assets/payload.json new file mode 100644 index 00000000..1a58a34f --- /dev/null +++ b/app/src/main/assets/payload.json @@ -0,0 +1,88 @@ +{ + "items": [ + { + "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/App.kt b/app/src/main/java/otus/homework/customview/App.kt new file mode 100644 index 00000000..4b6e48b4 --- /dev/null +++ b/app/src/main/java/otus/homework/customview/App.kt @@ -0,0 +1,17 @@ +package otus.homework.customview + +import android.app.Application +import otus.homework.customview.di.AppComponent +import otus.homework.customview.di.DaggerAppComponent + +class App: Application() { + lateinit var appComponent: AppComponent + + override fun onCreate() { + super.onCreate() + + appComponent = DaggerAppComponent.factory().newAppComponent(baseContext) + } +} + +fun Application.getComponent() = (this as App).appComponent \ 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 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/data/UiDataFakeRepoImpl.kt b/app/src/main/java/otus/homework/customview/data/UiDataFakeRepoImpl.kt new file mode 100644 index 00000000..e392345e --- /dev/null +++ b/app/src/main/java/otus/homework/customview/data/UiDataFakeRepoImpl.kt @@ -0,0 +1,19 @@ +package otus.homework.customview.data + +import otus.homework.customview.presentation.PieChartUiInfo +import javax.inject.Inject + +class UiDataFakeRepoImpl @Inject constructor( + private val jsonParser: UiDataJsonParser, + private val mapper: UiDataMapper +) : UiDataRepo { + companion object { + private const val FILE_NAME = "payload.json" + } + + override fun getUiData(): PieChartUiInfo = mapper.map( + data = jsonParser.parseFromAssets( + FILE_NAME + ) + ) +} \ No newline at end of file diff --git a/app/src/main/java/otus/homework/customview/data/UiDataJsonParser.kt b/app/src/main/java/otus/homework/customview/data/UiDataJsonParser.kt new file mode 100644 index 00000000..3568c22f --- /dev/null +++ b/app/src/main/java/otus/homework/customview/data/UiDataJsonParser.kt @@ -0,0 +1,20 @@ +package otus.homework.customview.data + +import android.content.Context +import kotlinx.serialization.json.Json +import otus.homework.customview.models.PieChartData +import javax.inject.Inject + +class UiDataJsonParser @Inject constructor(private val context: Context, private val json: Json) { + + fun parseFromAssets(fileName: String): PieChartData { + + val jsonString = context + .assets + .open(fileName) + .bufferedReader() + .use { it.readText() } + + return json.decodeFromString(jsonString) + } +} \ No newline at end of file diff --git a/app/src/main/java/otus/homework/customview/data/UiDataMapper.kt b/app/src/main/java/otus/homework/customview/data/UiDataMapper.kt new file mode 100644 index 00000000..7381ffa9 --- /dev/null +++ b/app/src/main/java/otus/homework/customview/data/UiDataMapper.kt @@ -0,0 +1,43 @@ +package otus.homework.customview.data + +import android.graphics.Color +import otus.homework.customview.models.PieChartData +import otus.homework.customview.presentation.PieChartSpendingItem +import otus.homework.customview.presentation.PieChartUiInfo +import javax.inject.Inject + +class UiDataMapper @Inject constructor() { + val colorsMap = mutableMapOf( + "Алкоголь" to Color.BLACK, + "Продукты" to Color.RED, + "Здоровье" to Color.BLUE, + "Кафе и рестораны" to Color.YELLOW, + "Доставка еды" to Color.GREEN, + "Транспорт" to Color.CYAN, + "Спорт" to Color.MAGENTA + ) + + fun map(data: PieChartData): PieChartUiInfo { + var allSum = 0 + val generalCategorySumMap = mutableMapOf() + + data.items.forEach { item -> + allSum += item.amount + var currentSum = generalCategorySumMap.getOrDefault(item.category, 0) + currentSum += item.amount + generalCategorySumMap.put(item.category, currentSum) + } + + val uiItems = generalCategorySumMap.map { entry -> + PieChartSpendingItem( + color = colorsMap.getOrDefault(entry.key, Color.TRANSPARENT), + amount = generalCategorySumMap.getOrDefault(entry.key, 0), + total = allSum, + category = entry.key + ) + } + return PieChartUiInfo( + spendingInfo = uiItems + ) + } +} diff --git a/app/src/main/java/otus/homework/customview/data/UiDataRepo.kt b/app/src/main/java/otus/homework/customview/data/UiDataRepo.kt new file mode 100644 index 00000000..9c1cf8c9 --- /dev/null +++ b/app/src/main/java/otus/homework/customview/data/UiDataRepo.kt @@ -0,0 +1,7 @@ +package otus.homework.customview.data + +import otus.homework.customview.presentation.PieChartUiInfo + +interface UiDataRepo{ + fun getUiData(): PieChartUiInfo +} \ No newline at end of file diff --git a/app/src/main/java/otus/homework/customview/di/AppComponent.kt b/app/src/main/java/otus/homework/customview/di/AppComponent.kt new file mode 100644 index 00000000..70521bf0 --- /dev/null +++ b/app/src/main/java/otus/homework/customview/di/AppComponent.kt @@ -0,0 +1,17 @@ +package otus.homework.customview.di + +import android.content.Context +import dagger.BindsInstance +import dagger.Component +import otus.homework.customview.presentation.MainActivity + +@Component(modules = [AppModule::class]) +interface AppComponent { + + fun inject(activity: MainActivity) + + @Component.Factory + interface Factory{ + fun newAppComponent(@BindsInstance context: Context): AppComponent + } +} diff --git a/app/src/main/java/otus/homework/customview/di/AppModule.kt b/app/src/main/java/otus/homework/customview/di/AppModule.kt new file mode 100644 index 00000000..0f05b4de --- /dev/null +++ b/app/src/main/java/otus/homework/customview/di/AppModule.kt @@ -0,0 +1,22 @@ +package otus.homework.customview.di + +import dagger.Binds +import dagger.Module +import dagger.Provides +import kotlinx.serialization.json.Json +import otus.homework.customview.data.UiDataFakeRepoImpl +import otus.homework.customview.data.UiDataRepo + +@Module +interface AppModule { + + @Binds + fun bindFakeRepo(repo: UiDataFakeRepoImpl): UiDataRepo + +companion object{ + + @Provides + fun providesJson(): Json = Json { ignoreUnknownKeys = true} + +} +} \ No newline at end of file diff --git a/app/src/main/java/otus/homework/customview/models/PieChartData.kt b/app/src/main/java/otus/homework/customview/models/PieChartData.kt new file mode 100644 index 00000000..827b5e98 --- /dev/null +++ b/app/src/main/java/otus/homework/customview/models/PieChartData.kt @@ -0,0 +1,22 @@ +package otus.homework.customview.models + +import com.google.gson.annotations.SerializedName +import kotlinx.serialization.Serializable + +@Serializable +data class PieChartData( + val items: List +){ + @Serializable + data class Item( + @field:SerializedName("id") + val id: Int, + @field:SerializedName("amount") + val amount: Int, + @field:SerializedName("category") + val category: String, + @field:SerializedName("time") + val time: Long, + ) +} + diff --git a/app/src/main/java/otus/homework/customview/presentation/MainActivity.kt b/app/src/main/java/otus/homework/customview/presentation/MainActivity.kt new file mode 100644 index 00000000..011ebbb9 --- /dev/null +++ b/app/src/main/java/otus/homework/customview/presentation/MainActivity.kt @@ -0,0 +1,39 @@ +package otus.homework.customview.presentation + +import androidx.appcompat.app.AppCompatActivity +import android.os.Bundle +import com.google.android.material.snackbar.Snackbar +import otus.homework.customview.data.UiDataRepo +import otus.homework.customview.databinding.ActivityMainBinding +import otus.homework.customview.getComponent +import javax.inject.Inject + +class MainActivity : AppCompatActivity() { + private lateinit var binding: ActivityMainBinding + + @Inject + lateinit var repo: UiDataRepo + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + application.getComponent().inject(this) + + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + + binding.root.post { + binding.pieChart.setSegments(repo.getUiData()) + binding.pieChart.setOnClickEvent { text -> + + Snackbar.make( + this, + binding.root, + text, + 1500 + ).show() + + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/otus/homework/customview/presentation/PieChart.kt b/app/src/main/java/otus/homework/customview/presentation/PieChart.kt new file mode 100644 index 00000000..284c9097 --- /dev/null +++ b/app/src/main/java/otus/homework/customview/presentation/PieChart.kt @@ -0,0 +1,235 @@ +package otus.homework.customview.presentation + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.RectF +import android.os.Build +import android.os.Bundle +import android.os.Parcelable +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View +import androidx.annotation.RequiresApi +import kotlinx.parcelize.Parcelize +import kotlin.math.atan2 +import kotlin.math.max +import kotlin.math.min +import kotlin.math.sqrt + +class PieChart @JvmOverloads constructor( + context: Context, + attr: AttributeSet? = null, + deffStyleAttr: Int = 0, +) : View(context, attr, deffStyleAttr) { + + private companion object { + const val SAVED_STATE_KEY = "SAVED_STATE" + const val INSTANCE_STATE_KEY = "INSTANCE_KEY" + } + + private val paint: Paint = Paint(Paint.ANTI_ALIAS_FLAG) + private val rect = RectF() + + private var onClickActionEvent: ((String) -> Unit)? = null + + private var metaInfo: MetaInfo = MetaInfo(0, 0, 0f) + + private var segments = mutableListOf() + + private val defaultSize = (300 * resources.displayMetrics.density).toInt() + + fun setSegments(uiData: PieChartUiInfo) { + segments.clear() + + var startAngle = 0f + + uiData.spendingInfo.forEach { + + val sweepAngle = 360f * it.amount / it.total + + segments.add( + Segment( + startAngle = startAngle, + sweepAngle = sweepAngle, + category = it.category, + color = it.color, + amount = it.amount + ) + ) + + startAngle += sweepAngle + } + + invalidate() + } + + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + + + val wMode = MeasureSpec.getMode(widthMeasureSpec) + val wSize = MeasureSpec.getSize(widthMeasureSpec) + + val hMode = MeasureSpec.getMode(heightMeasureSpec) + val hSize = MeasureSpec.getSize(heightMeasureSpec) + + val desiredWidth = max(defaultSize + paddingStart + paddingEnd, wSize) + val desiredHeight = max(defaultSize + paddingTop + paddingBottom, hSize) + + val measuredWidth = when (wMode) { + MeasureSpec.EXACTLY -> wSize + MeasureSpec.AT_MOST -> desiredWidth.coerceAtMost(wSize) + MeasureSpec.UNSPECIFIED -> desiredWidth + else -> desiredWidth + } + + val measuredHeight = when (hMode) { + MeasureSpec.EXACTLY -> hSize + MeasureSpec.AT_MOST -> desiredHeight.coerceAtMost(wSize) + MeasureSpec.UNSPECIFIED -> desiredHeight + else -> desiredWidth + } + + val resultSize = min(measuredHeight, measuredWidth) + + setMeasuredDimension(resultSize, resultSize) + } + + fun setOnClickEvent(event: (String) -> Unit) { + this.onClickActionEvent = event + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + + if (segments.isEmpty()) return + + val centerX = width / 2 + val centerY = height / 2 + + val radius = minOf(height, width) * 0.4f + + metaInfo.centerX = centerX + metaInfo.centerY = centerY + + metaInfo.radius = radius + + rect.set( + centerX - radius, + centerY - radius, + centerX + radius, + centerY + radius + ) + + segments.forEach { segment -> + paint.color = segment.color + + canvas.drawArc( + rect, + segment.startAngle, + segment.sweepAngle, + true, + paint + ) + } + } + + override fun onTouchEvent(event: MotionEvent?): Boolean { + + if (event != null && event.action == MotionEvent.ACTION_DOWN) { + + val pieChartIsClicked = isPointInsidePieChart(event.x, event.y) + + if (pieChartIsClicked) { + val segment = getClickedSegment(event.x, event.y) + onClickActionEvent?.invoke(segment.category + " ${segment.amount}") + } + performClick() + + return true + } + return super.onTouchEvent(event) + } + + private fun isPointInsidePieChart(x: Float, y: Float): Boolean { + val xLeg = x - metaInfo.centerX + val yLeg = y - metaInfo.centerY + + val distance = sqrt(xLeg * xLeg + yLeg * yLeg) + + return distance < metaInfo.radius + } + + private fun getClickedSegment(x: Float, y: Float): Segment { + val xLeg = x - metaInfo.centerX + val yLeg = y - metaInfo.centerY + + val result = Segment(0f, 0f, "", 0, 0) + + var angle = Math.toDegrees(atan2(yLeg.toDouble(), xLeg.toDouble())) + + if (angle < 0) angle += 360 + + segments.forEach { segment -> + val endAngle = segment.startAngle + segment.sweepAngle + if (angle > segment.startAngle && angle <= endAngle) return segment + } + return result + } + + override fun performClick(): Boolean { + super.performClick() + return true + } + + override fun onSaveInstanceState(): Parcelable? { + val bundle = Bundle().apply { + putParcelable( + SAVED_STATE_KEY, + SavedState(segments = this@PieChart.segments, metaInfo = this@PieChart.metaInfo) + ) + putParcelable( + INSTANCE_STATE_KEY, + super.onSaveInstanceState() + ) + } + + return bundle + } + + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + override fun onRestoreInstanceState(state: Parcelable?) { + val bundle = state as Bundle + val savedState = bundle.getParcelable(SAVED_STATE_KEY, SavedState::class.java) + savedState?.let { saved -> + metaInfo = saved.metaInfo + } + super.onRestoreInstanceState( + bundle.getParcelable( + INSTANCE_STATE_KEY, + Parcelable::class.java + ) + ) + } + + @Parcelize + private class Segment( + val startAngle: Float, + val sweepAngle: Float, + val category: String, + val amount: Int, + val color: Int + ) : Parcelable + + @Parcelize + private class MetaInfo( + var centerX: Int, + var centerY: Int, + var radius: Float + ) : Parcelable + + @Parcelize + private data class SavedState(val segments: MutableList, val metaInfo: MetaInfo) : + Parcelable +} \ No newline at end of file diff --git a/app/src/main/java/otus/homework/customview/presentation/PieChartModel.kt b/app/src/main/java/otus/homework/customview/presentation/PieChartModel.kt new file mode 100644 index 00000000..b32b0725 --- /dev/null +++ b/app/src/main/java/otus/homework/customview/presentation/PieChartModel.kt @@ -0,0 +1,19 @@ +package otus.homework.customview.presentation + +import android.os.Parcelable +import androidx.annotation.ColorInt +import kotlinx.parcelize.Parcelize + + +@Parcelize +data class PieChartUiInfo( + val spendingInfo: List +): Parcelable + +@Parcelize +data class PieChartSpendingItem( + @field:ColorInt val color: Int, + val amount: Int, + val total: Int, + val category: String +): Parcelable \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 79ae6993..7e0c9952 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -5,12 +5,12 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" - tools:context=".MainActivity"> + tools:context=".presentation.MainActivity"> -