Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
012d911
chore: upgrade gradle to v7.3, AGP to v7.1.3
bmv-2143 Aug 29, 2024
9ad1e52
chore: add/upgrade build.gradle dependencies
bmv-2143 Sep 1, 2024
c3cc009
feat: add json payload loading logic
bmv-2143 Sep 1, 2024
25084ff
feat: add utility classes
bmv-2143 Sep 2, 2024
1b307c7
feat: add PieChartView with onMeasure(), WIP
bmv-2143 Sep 2, 2024
60d7c63
feat: draw colorful pie chart from payload data
bmv-2143 Sep 2, 2024
30b710d
feat: select sectors on clicks
bmv-2143 Sep 2, 2024
6b2a289
refactor: clean up view chart view classes
bmv-2143 Sep 2, 2024
f52cc7a
feat: implement save/restore view state
bmv-2143 Sep 3, 2024
28246f9
refactor: extract PieChartAngleCalculator from PieChartView
bmv-2143 Sep 3, 2024
f537bbc
refactor: extract PieChartDrawer from PieChartView
bmv-2143 Sep 3, 2024
2005f5e
feat: add callback for clicks on sectors in PieChartView
bmv-2143 Sep 3, 2024
f685793
feat: add activity_main landscape layout
bmv-2143 Sep 3, 2024
dd0daae
feat: add ExpensesGraphView with axis, axis ticks, WIP
bmv-2143 Sep 3, 2024
536b259
feat: add grid, axis ticks to expenses graph view, WIP
bmv-2143 Sep 3, 2024
72f126d
refactor: grid dots calculation
bmv-2143 Sep 4, 2024
dd16380
feat: draw points at (day, expenses) for each category
bmv-2143 Sep 4, 2024
fa2fd5f
feat: draw expenses graph
bmv-2143 Sep 4, 2024
02d04c5
feat: draw dashed lines through graph peaks
bmv-2143 Sep 4, 2024
47e1bab
feat: draw amount values above dashes lines
bmv-2143 Sep 4, 2024
2a0a857
feat: add save/restore expenses graph view state
bmv-2143 Sep 5, 2024
6c2757a
refactor: move payload calculations from PieChartView
bmv-2143 Sep 5, 2024
a6b7cc4
refactor: move data calculations from ExpensesGraphView
bmv-2143 Sep 5, 2024
05b5b11
refactor: clean up layout files
bmv-2143 Sep 5, 2024
5764f72
refactor: clean up ExpensesGraphView
bmv-2143 Sep 5, 2024
d73065d
refactor: provide DateUtils, GraphCalculator via DI
bmv-2143 Sep 5, 2024
1437ce4
refactor: clean up PieChartDrawer
bmv-2143 Sep 5, 2024
5b9ad3c
refactor: clean up PieChartView.onTouchEvent()
bmv-2143 Sep 5, 2024
d20d992
refactor: simplify tax extension fun
bmv-2143 Sep 6, 2024
ca21571
style: minor style formatting
bmv-2143 Sep 6, 2024
29cf57a
refactor: inject json data source into repository
bmv-2143 Sep 6, 2024
95f7fbf
refactor: move PieChartColor to model package
bmv-2143 Sep 6, 2024
a78492c
refactor: reorganize packages
bmv-2143 Sep 6, 2024
ef311bd
refactor: clean up PieChartView
bmv-2143 Sep 6, 2024
8aff1dd
refactor: extract math consts
bmv-2143 Sep 6, 2024
edd17de
refactor: clean up GeometryHelper
bmv-2143 Sep 6, 2024
c0db0f3
refactor: extract GraphDrawer from ExpensesGraphView, WIP
bmv-2143 Sep 6, 2024
ea58015
refactor: extract GraphDrawer from ExpensesGraphView
bmv-2143 Sep 6, 2024
ecbcb70
refactor: extract AxisDrawer from GraphDrawer
bmv-2143 Sep 6, 2024
fee22fb
refactor: remove redundant logging
bmv-2143 Sep 6, 2024
f2e6084
refactor: clean up GraphDrawer
bmv-2143 Sep 6, 2024
5ec4d71
refactor: clean up AxisDrawer
bmv-2143 Sep 6, 2024
0015981
refactor: clean up ExpensesGraphView
bmv-2143 Sep 6, 2024
7937daa
feat: add the result screenshot, update readme
bmv-2143 Sep 6, 2024
3ba3b4a
fix: inject resources instead of app context
bmv-2143 Sep 6, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,8 @@
4. Реализовывать масштабирование/скроллинг/обработку тач евентов не нужно

Примеры дизайна которыми можно вдохновляться:
![Pie Chart](art/second.png)
![Pie Chart](art/second.png)

### Результат

![RESULT_SCREENSHOT.png](RESULT_SCREENSHOT.png)
Binary file added RESULT_SCREENSHOT.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
25 changes: 23 additions & 2 deletions app/build.gradle
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
plugins {
id 'com.android.application'
id 'kotlin-android'

id 'kotlin-kapt'
id 'dagger.hilt.android.plugin'
}

android {
compileSdkVersion 30
compileSdkVersion 31
buildToolsVersion "30.0.3"

defaultConfig {
Expand All @@ -30,6 +33,10 @@ android {
kotlinOptions {
jvmTarget = '1.8'
}

buildFeatures {
viewBinding true
}
}

dependencies {
Expand All @@ -42,4 +49,18 @@ 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.10.1'
implementation 'androidx.activity:activity-ktx:1.3.0'
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.5.1"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1" // Add this line
implementation "androidx.fragment:fragment-ktx:1.5.2"

implementation "com.google.dagger:hilt-android:2.44" // Add this line
kapt "com.google.dagger:hilt-android-compiler:2.44" // Add this line
}

// Allow references to generated code
kapt {
correctErrorTypes = true
}
3 changes: 2 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:name=".CustomViewApp"
android:theme="@style/Theme.CustomView">
<activity android:name=".MainActivity">
<activity android:name=".presentation.MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

Expand Down
7 changes: 7 additions & 0 deletions app/src/main/java/otus/homework/customview/CustomViewApp.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package otus.homework.customview

import android.app.Application
import dagger.hilt.android.HiltAndroidApp

@HiltAndroidApp
class CustomViewApp : Application()
11 changes: 0 additions & 11 deletions app/src/main/java/otus/homework/customview/MainActivity.kt

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package otus.homework.customview.data

import androidx.annotation.RawRes
import javax.inject.Inject

class CustomViewRepository @Inject constructor(private val jsonDataSource: JsonDataSource) {

fun loadPayloads(@RawRes payloadId: Int): List<Payload> =
jsonDataSource.loadPayloads(payloadId)
}
29 changes: 29 additions & 0 deletions app/src/main/java/otus/homework/customview/data/JsonDataSource.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package otus.homework.customview.data

import android.content.res.Resources
import com.google.gson.Gson
import java.io.InputStreamReader
import javax.inject.Inject

class JsonDataSource @Inject constructor(private val resources: Resources) {

fun loadPayloads(payloadId: Int): List<Payload> {
readJsonFile(payloadId).let {
return parseJson(it)
}
}

private fun readJsonFile(resId: Int): String {
val inputStream = resources.openRawResource(resId)
val reader = InputStreamReader(inputStream)
return reader.readText().also {
reader.close()
}
}

private fun parseJson(jsonString: String): List<Payload> {
val gson = Gson()
return gson.fromJson(jsonString, Array<Payload>::class.java).toList()
}

}
9 changes: 9 additions & 0 deletions app/src/main/java/otus/homework/customview/data/Payload.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package otus.homework.customview.data

data class Payload(
val id: Int,
val name: String,
val amount: Int,
val category: String,
val time: Long,
)
22 changes: 22 additions & 0 deletions app/src/main/java/otus/homework/customview/di/ApplicationModule.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package otus.homework.customview.di

import android.content.Context
import android.content.res.Resources
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers

@Module
@InstallIn(SingletonComponent::class)
object ApplicationModule {

@Provides
fun provideDispatcherIO() : CoroutineDispatcher = Dispatchers.IO

@Provides
fun provideResources(@ApplicationContext context: Context): Resources = context.resources
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package otus.homework.customview.presentation

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import otus.homework.customview.R
import otus.homework.customview.data.CustomViewRepository
import otus.homework.customview.data.Payload
import otus.homework.customview.presentation.helpers.GraphCalculator
import otus.homework.customview.presentation.model.PieChartAngle
import otus.homework.customview.presentation.mapper.toPieChartAngles
import otus.homework.customview.utils.DateUtils
import javax.inject.Inject

@HiltViewModel
class CustomViewViewModel @Inject constructor(
private val repository: CustomViewRepository,
private val dateUtils: DateUtils,
private val graphCalculator: GraphCalculator,
dataLoadDispatcher: CoroutineDispatcher,
) : ViewModel() {

private lateinit var _payloads: List<Payload>
private val _pieChartAngles = MutableStateFlow<List<PieChartAngle>>(emptyList())
val pieChartAngles = _pieChartAngles.asStateFlow()

init {
viewModelScope.launch(dataLoadDispatcher) {
_payloads = repository.loadPayloads(R.raw.payload)
_pieChartAngles.value = _payloads.toPieChartAngles()
}
}

fun getMaxDailyExpenseOfAllCategories(): Int =
graphCalculator.getMaxDailyExpenseOfAllCategories(_payloads)

fun getDaysToExpenses(payloadCategory: String): Map<Int, Int> {
val daysToExpenses = mutableMapOf<Int, Int>().withDefault { 0 }

for (i in 0..MAX_DAYS) {
daysToExpenses[i] = 0
}

for (payload in getPayloadsForCategory(payloadCategory)) {
val day: Int = dateUtils.timestampToDayOfMonth(payload.time)
val amount = payload.amount
daysToExpenses[day] = daysToExpenses.getValue(day) + amount
}

return daysToExpenses
}

private fun getPayloadsForCategory(category: String): List<Payload> {
return _payloads.filter { it.category == category }
}

companion object {
private const val MAX_DAYS = 31
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package otus.homework.customview.presentation

import android.os.Bundle
import android.widget.Toast
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import otus.homework.customview.databinding.ActivityMainBinding

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

private val viewModel by viewModels<CustomViewViewModel>()
private lateinit var binding: ActivityMainBinding

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
observePieChartAngles()
setPayloadCategorySelectionListener()
}

private fun observePieChartAngles() {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.pieChartAngles.collect {
binding.pieChartView.setPieChartAngles(it)
}
}
}
}

private fun setPayloadCategorySelectionListener() {
binding.pieChartView.setSelectionListener { selectedCategory ->
Toast.makeText(this, selectedCategory, Toast.LENGTH_LONG).show()

binding.expensesGraphView.setMaxDailyExpenseOfAllCategories(viewModel.getMaxDailyExpenseOfAllCategories())
binding.expensesGraphView.setDaysToExpenses(viewModel.getDaysToExpenses(selectedCategory))
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package otus.homework.customview.presentation.expensesgraph

import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import otus.homework.customview.utils.dp
import otus.homework.customview.utils.px

class AxisDrawer(
private val view: ExpensesGraphView
) {
private val axisPaddingPx = 32.dp.px
private val axisTickHeightPx = 10.dp.px
private val gridDotSizePx = 2.dp.px
private val tickCountX = 10
private val tickCountY = 5

private val axisPaint = Paint().apply {
color = Color.BLACK
strokeWidth = 15f
style = Paint.Style.FILL
}

private val axisTickPaint = Paint().apply {
color = Color.DKGRAY
strokeWidth = 5f
style = Paint.Style.FILL
}

private val gridDotPaint = Paint().apply {
color = Color.GRAY
strokeWidth = 1f
style = Paint.Style.FILL
}

internal fun drawAxis(canvas: Canvas) {
val xAxisYPos = (view.height - axisPaddingPx).toFloat()
canvas.drawLine(
axisPaddingPx.toFloat(), xAxisYPos, (view.width - axisPaddingPx).toFloat(), xAxisYPos, axisPaint
)
canvas.drawLine(
axisPaddingPx.toFloat(),
axisPaddingPx.toFloat(),
axisPaddingPx.toFloat(),
(view.height - axisPaddingPx).toFloat(),
axisPaint
)
}

internal fun drawTicksOnXAxis(canvas: Canvas) {
val xAxisYPos = (view.height - axisPaddingPx).toFloat()
val xAxisXPos = (view.width - axisPaddingPx).toFloat()

val tickStep = (xAxisXPos - axisPaddingPx) / tickCountX
var currentX: Float = axisPaddingPx.toFloat() + tickStep

repeat(tickCountX) {
drawAxisXTick(canvas, currentX, xAxisYPos, axisTickHeightPx)
currentX += tickStep
}
}

private fun drawAxisXTick(canvas: Canvas, tickX: Float, tickStartY: Float, tickHeight: Int) =
canvas.drawLine(tickX, tickStartY, tickX, tickStartY - tickHeight, axisTickPaint)

internal fun drawTicksOnYAxis(canvas: Canvas) {
val yAxisXPos = axisPaddingPx.toFloat()
val yAxisYPos = (view.height - axisPaddingPx).toFloat()

val tickStep = (yAxisYPos - axisPaddingPx) / tickCountY
var currentY: Float = yAxisYPos - tickStep

repeat(tickCountY) {
drawAxisYTick(canvas, yAxisXPos, currentY, axisTickHeightPx)
currentY -= tickStep
}
}

private fun drawAxisYTick(canvas: Canvas, tickX: Float, tickStartY: Float, tickHeight: Int) =
canvas.drawLine(tickX, tickStartY, tickX + tickHeight, tickStartY, axisTickPaint)

internal fun drawDotsAtTickIntersections(canvas: Canvas) {
val gridDotsX = calculateGridDotsX()
val gridDotsY = calculateGridDotsY()
for (x in gridDotsX) {
for (y in gridDotsY) {
canvas.drawCircle(x, y, gridDotSizePx.toFloat(), gridDotPaint)
}
}
}

private fun calculateGridDotsX(): MutableList<Float> {
val xTickPositions = mutableListOf<Float>()
val xAxisXPos = (view.width - axisPaddingPx).toFloat()
val tickStepX = (xAxisXPos - axisPaddingPx) / tickCountX
var currentX: Float = axisPaddingPx.toFloat() + tickStepX
repeat(tickCountX) {
xTickPositions.add(currentX)
currentX += tickStepX
}
return xTickPositions
}

private fun calculateGridDotsY(): MutableList<Float> {
val yTickPositions = mutableListOf<Float>()
val yAxisYPos = (view.height - axisPaddingPx).toFloat()
val tickStepY = (yAxisYPos - axisPaddingPx) / tickCountY
var currentY: Float = yAxisYPos - tickStepY
repeat(tickCountY) {
yTickPositions.add(currentY)
currentY -= tickStepY
}
return yTickPositions
}
}
Loading