diff --git a/app/src/main/java/app/cashadvisor/analytics/presentation/MockData.kt b/app/src/main/java/app/cashadvisor/analytics/presentation/MockData.kt new file mode 100644 index 00000000..7dddf87c --- /dev/null +++ b/app/src/main/java/app/cashadvisor/analytics/presentation/MockData.kt @@ -0,0 +1,245 @@ +package app.cashadvisor.analytics.presentation + +import app.cashadvisor.analytics.presentation.model.Account +import app.cashadvisor.analytics.presentation.model.AnalyticType +import app.cashadvisor.analytics.presentation.model.CategorySummary +import app.cashadvisor.analytics.presentation.model.SubcategorySummary +import app.cashadvisor.analytics.presentation.model.User +import java.math.BigDecimal +import java.util.Date + +object MockData { + fun getCategorySummaryList(beginDate: Date, endDate: Date) = categorySummaryList //emptyList()// +} + + +val categorySummaryList :List = listOf ( + // Доходы факт + CategorySummary( + id = 3, + name = "Зарплата", + analyticType = AnalyticType.INCOME, + planned = false, + amount = BigDecimal.valueOf(75000.00), + subcategoryList = listOf( + SubcategorySummary( + name = "Перевод", + amount = BigDecimal.valueOf(50000.00) + ), + SubcategorySummary( + name = "Отпускные", + amount = BigDecimal.valueOf(20000.00) + ), + SubcategorySummary( + name = "Премия", + amount = BigDecimal.valueOf(5000.00) + ), + ) + ), + CategorySummary( + id = 4, + name = "Наставничество", + analyticType = AnalyticType.INCOME, + planned = false, + amount = BigDecimal.valueOf(20000.00), + subcategoryList = listOf( + SubcategorySummary( + name = "Перевод", + amount = BigDecimal.valueOf(20000.00) + ), + ) + ), + + // Расходы факт + CategorySummary( + id = 22, + name = "Еда", + analyticType = AnalyticType.EXPENSE, + planned = false, + amount = BigDecimal.valueOf(7563.00), + subcategoryList = listOf( + SubcategorySummary( + name = "Продукты", + amount = BigDecimal.valueOf(7245.00) + ), + SubcategorySummary( + name = "Ресторан", + amount = BigDecimal.valueOf(318.00) + ), + ) + ), + CategorySummary( + id = 18, + name = "Развлечения", + analyticType = AnalyticType.EXPENSE, + planned = false, + amount = BigDecimal.valueOf(533.00), + subcategoryList = listOf( + SubcategorySummary( + name = "Телеграм премиум", + amount = BigDecimal.valueOf(299.00) + ), + SubcategorySummary( + name = "Яндекс плюс", + amount = BigDecimal.valueOf(199.00) + ), + SubcategorySummary( + name = "Whouse", + amount = BigDecimal.valueOf(35.00) + ), + ) + ), + // Фонд благосостояния факт + CategorySummary( + id = 25, + name = "Инвестиции", + analyticType = AnalyticType.SAVING, + planned = false, + amount = BigDecimal.valueOf(15000.00), + subcategoryList = listOf( + SubcategorySummary( + name = "Акции", + amount = BigDecimal.valueOf(10000.00) + ), + SubcategorySummary( + name = "Облигации", + amount = BigDecimal.valueOf(5000.00) + ), + SubcategorySummary( + name = "Даньги аыврало ыварыл фыдалфыжал фываолдыва", + amount = BigDecimal.valueOf(0.00) + ), + ) + ), + CategorySummary( + id = 2, + name = "Накопления", + analyticType = AnalyticType.SAVING, + planned = false, + amount = BigDecimal.valueOf(30000.00), + subcategoryList = listOf( + SubcategorySummary( + name = "фин подушка", + amount = BigDecimal.valueOf(25000.00) + ), + SubcategorySummary( + name = "Амортизация", + amount = BigDecimal.valueOf(5000.00) + ), + ) // List + ), + CategorySummary( + id = 24, + name = "Валюта", + analyticType = AnalyticType.SAVING, + planned = false, + amount = BigDecimal.valueOf(0.00), + subcategoryList = listOf( + SubcategorySummary( + name = "USD", + amount = BigDecimal.valueOf(0.00) + ), + ) // List + ), + // Доходы план + CategorySummary( + id = 3, + name = "Зарплата", + analyticType = AnalyticType.INCOME, + planned = true, + amount = BigDecimal.valueOf(75000.00), + completePercent = 100.0, + subcategoryList = listOf() // List + ), + CategorySummary( + id = 4, + name = "Наставничество", + analyticType = AnalyticType.INCOME, + planned = true, + amount = BigDecimal.valueOf(25000.00), + completePercent = 80.0, + subcategoryList = listOf() // List + ), + + // Расходы план + CategorySummary( + id = 22, + name = "Еда", + analyticType = AnalyticType.EXPENSE, + planned = true, + amount = BigDecimal.valueOf(20000.00), + completePercent = 80.0, + subcategoryList = listOf() // List + ), + CategorySummary( + id = 17, + name = "Техника", + analyticType = AnalyticType.EXPENSE, + planned = true, + amount = BigDecimal.valueOf(10000.00), + completePercent = 00.0, + subcategoryList = listOf() // List + ), + CategorySummary( + id = 18, + name = "Развлечения", + analyticType = AnalyticType.EXPENSE, + planned = true, + amount = BigDecimal.valueOf(10000.00), + completePercent = 10.0, + subcategoryList = listOf() // List + ), + CategorySummary( + id = 12, + name = "Транспорт", + analyticType = AnalyticType.EXPENSE, + planned = true, + amount = BigDecimal.valueOf(5000.00), + completePercent = 0.0, + subcategoryList = listOf() // List + ), + CategorySummary( + id = 19, + name = "Прочее", + analyticType = AnalyticType.EXPENSE, + planned = true, + amount = BigDecimal.valueOf(5000.00), + completePercent = 0.0, + subcategoryList = listOf() // List + ), + // Фонд благосостояния план + /* CategorySummary( + id = 25, + name = "Инвестиции", + analyticType = AnalyticType.SAVING, + planned = true, + amount = BigDecimal.valueOf(15000.00), + subcategoryList = listOf() // List + ), + CategorySummary( + id = 2, + name = "Накопления", + analyticType = AnalyticType.SAVING, + planned = true, + amount = BigDecimal.valueOf(30000.00), + subcategoryList = listOf() // List + ), + CategorySummary( + id = 24, + name = "Валюта", + analyticType = AnalyticType.SAVING, + planned = true, + amount = BigDecimal.valueOf(0.00), + subcategoryList = listOf() // List + ),*/ +) + +val account = Account( + amount = BigDecimal.valueOf(8600000.00) +) + +val user = User( + id = 100, + name = "Андрей", + lastName = "Иванов" +) \ No newline at end of file diff --git a/app/src/main/java/app/cashadvisor/analytics/presentation/model/Account.kt b/app/src/main/java/app/cashadvisor/analytics/presentation/model/Account.kt new file mode 100644 index 00000000..34450734 --- /dev/null +++ b/app/src/main/java/app/cashadvisor/analytics/presentation/model/Account.kt @@ -0,0 +1,8 @@ +package app.cashadvisor.analytics.presentation.model + +import java.math.BigDecimal + +data class Account( + val amount: BigDecimal, + val currency: String? = null, +) diff --git a/app/src/main/java/app/cashadvisor/analytics/presentation/model/AnalyticType.kt b/app/src/main/java/app/cashadvisor/analytics/presentation/model/AnalyticType.kt new file mode 100644 index 00000000..4c6c0a87 --- /dev/null +++ b/app/src/main/java/app/cashadvisor/analytics/presentation/model/AnalyticType.kt @@ -0,0 +1,29 @@ +package app.cashadvisor.analytics.presentation.model + +import androidx.annotation.ColorRes +import app.cashadvisor.uikit.R + +enum class AnalyticType(val value: Int) { + INCOME(0), + EXPENSE(1), + SAVING(2); + + companion object { + fun of(value: Int): AnalyticType { + return when (value) { + 0 -> INCOME + 1 -> EXPENSE + else -> SAVING + } + } + } +} + +@ColorRes +fun AnalyticType.getCategoryCurrencyTextColor(): Int { + return when (this) { + AnalyticType.INCOME -> R.color.m1 + AnalyticType.EXPENSE -> R.color.m2 + else -> R.color.m3 + } +} \ No newline at end of file diff --git a/app/src/main/java/app/cashadvisor/analytics/presentation/model/CategorySummary.kt b/app/src/main/java/app/cashadvisor/analytics/presentation/model/CategorySummary.kt new file mode 100644 index 00000000..e9feedcd --- /dev/null +++ b/app/src/main/java/app/cashadvisor/analytics/presentation/model/CategorySummary.kt @@ -0,0 +1,13 @@ +package app.cashadvisor.analytics.presentation.model + +import java.math.BigDecimal + +data class CategorySummary( + val id: Long, + val name: String, + val analyticType: AnalyticType, + val planned: Boolean, + val amount: BigDecimal, + val completePercent: Double? = null, + val subcategoryList: List?, +) diff --git a/app/src/main/java/app/cashadvisor/analytics/presentation/model/FilterParams.kt b/app/src/main/java/app/cashadvisor/analytics/presentation/model/FilterParams.kt new file mode 100644 index 00000000..288ae2f3 --- /dev/null +++ b/app/src/main/java/app/cashadvisor/analytics/presentation/model/FilterParams.kt @@ -0,0 +1,22 @@ +package app.cashadvisor.analytics.presentation.model + +import java.util.Calendar +import java.util.Date + +data class FilterParams( + val beginDate: Date, + val endDate: Date, + val analyticType: AnalyticType, + val planned: Boolean +) { + + companion object { + fun getDefault(): FilterParams { + return FilterParams( + beginDate = Calendar.getInstance().time, + endDate = Calendar.getInstance().time, + analyticType = AnalyticType.INCOME, + planned = false) + } + } +} diff --git a/app/src/main/java/app/cashadvisor/analytics/presentation/model/SubcategorySummary.kt b/app/src/main/java/app/cashadvisor/analytics/presentation/model/SubcategorySummary.kt new file mode 100644 index 00000000..964ca80f --- /dev/null +++ b/app/src/main/java/app/cashadvisor/analytics/presentation/model/SubcategorySummary.kt @@ -0,0 +1,8 @@ +package app.cashadvisor.analytics.presentation.model + +import java.math.BigDecimal + +data class SubcategorySummary( + val name: String, + val amount: BigDecimal, +) diff --git a/app/src/main/java/app/cashadvisor/analytics/presentation/model/User.kt b/app/src/main/java/app/cashadvisor/analytics/presentation/model/User.kt new file mode 100644 index 00000000..ecee4c94 --- /dev/null +++ b/app/src/main/java/app/cashadvisor/analytics/presentation/model/User.kt @@ -0,0 +1,8 @@ +package app.cashadvisor.analytics.presentation.model + +data class User( + val id: Long, + val name: String, + val lastName: String, + val avatarUrl: String? = null, +) diff --git a/app/src/main/java/app/cashadvisor/analytics/presentation/ui/AnalyticsFragment.kt b/app/src/main/java/app/cashadvisor/analytics/presentation/ui/AnalyticsFragment.kt index be051c8b..4cc59c64 100644 --- a/app/src/main/java/app/cashadvisor/analytics/presentation/ui/AnalyticsFragment.kt +++ b/app/src/main/java/app/cashadvisor/analytics/presentation/ui/AnalyticsFragment.kt @@ -1,19 +1,40 @@ package app.cashadvisor.analytics.presentation.ui import android.os.Bundle -import android.view.LayoutInflater import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment +import androidx.annotation.DrawableRes +import androidx.annotation.IdRes +import androidx.appcompat.content.res.AppCompatResources +import androidx.core.view.isVisible +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.LinearLayoutManager import app.cashadvisor.R +import app.cashadvisor.analytics.presentation.model.AnalyticType +import app.cashadvisor.analytics.presentation.model.FilterParams +import app.cashadvisor.analytics.presentation.ui.adapter.AnalyticInfoAdapter +import app.cashadvisor.analytics.presentation.ui.state.AnalyticsUiState +import app.cashadvisor.common.ui.BaseFragment +import app.cashadvisor.common.utils.MoneyFormatter import app.cashadvisor.databinding.FragmentAnalyticsBinding +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import java.math.BigDecimal +import java.util.Date +import javax.inject.Inject -class AnalyticsFragment : Fragment() { - private var _binding: FragmentAnalyticsBinding? = null - private val binding get() = _binding!! +@AndroidEntryPoint +class AnalyticsFragment : BaseFragment(FragmentAnalyticsBinding::inflate) { + override val viewModel: AnalyticsViewModel by viewModels() + private val analyticInfoAdapter by lazy { AnalyticInfoAdapter(formatter) } + @Inject + lateinit var formatter: MoneyFormatter override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -22,14 +43,6 @@ class AnalyticsFragment : Fragment() { } } - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentAnalyticsBinding.inflate(layoutInflater, container, false) - return binding.root - } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -37,60 +50,199 @@ class AnalyticsFragment : Fragment() { findNavController().navigate(R.id.action_analyticsFragment_to_profileSettingsFragment) } - binding.btnAddBank.setOnClickListener { - findNavController().navigate(R.id.action_analyticsFragment_to_addBankSelectionFragment) - } + initRecyclerView() + } + override fun onConfigureViews() { + with (binding) { + cgAnalyticType.setOnCheckedStateChangeListener { chipGroup, _ -> + setAnalyticType(chipGroup.checkedChipId) + } + cgPlanned.setOnCheckedStateChangeListener { chipGroup, _ -> + setPlanned(chipGroup.checkedChipId) + } + // добавить выбор периода setPeriod + } + } - val planIncome = R.id.action_analyticsFragment_to_planIncomeSelectionFragment + override fun onSubscribe() { + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.analyticsUiState.collectLatest { state -> + renderState(state) + } + } + } + } - val planExpense = R.id.action_analyticsFragment_to_planExpenseSelectionFragment + private fun initRecyclerView() { + with(binding.rvAnalyticInfo) { + layoutManager = LinearLayoutManager( + requireContext(), + LinearLayoutManager.VERTICAL, + false + ) + adapter = analyticInfoAdapter + } + } - val planSaving = R.id.action_analyticsFragment_to_planSavingSelectionFragment + private fun renderState(state: AnalyticsUiState) { + binding.ltProgressView.root.isVisible = state is AnalyticsUiState.Loading + binding.svAnalyticInfo.isVisible = state is AnalyticsUiState.Content + binding.tvAnalyticInfoHeader.isVisible = state is AnalyticsUiState.Content + + if (state is AnalyticsUiState.Content) { + val content = state as AnalyticsUiState.Content + setAnalyticInfoHeader(content.filterParams) + tuneAnalyticInfoPlaceHolder(content.data.isNullOrEmpty(), content.filterParams) + analyticInfoAdapter.submitList(content.data) + setTotalAnalyticAmount(content.getTotalAmountByFilter()) + tuneCategoryProgressBar(content) + tuneNavigationForState(content.filterParams) + } + } - val addIncome = R.id.action_analyticsFragment_to_addIncomeSelectionFragment + private fun setAnalyticInfoHeader(filterParams: FilterParams) { + binding.tvAnalyticInfoHeader.text = if (!filterParams.planned) { + getString(app.cashadvisor.uikit.R.string.mp_fact_analytic_info_header) + } else { + when (filterParams.analyticType) { + AnalyticType.INCOME -> getString(app.cashadvisor.uikit.R.string.mp_plan_income_analytic_info_header) + AnalyticType.EXPENSE -> getString(app.cashadvisor.uikit.R.string.mp_plan_expense_analytic_info_header) + else -> getString(app.cashadvisor.uikit.R.string.mp_plan_saving_analytic_info_header) + } + } + } - val addExpense = R.id.action_analyticsFragment_to_addExpenseSelectionFragment + private fun tuneAnalyticInfoPlaceHolder(isVisible: Boolean, filterParams: FilterParams) { + binding.tvAnalyticInfoPlaceHolder.text = getAnalyticInfoPlaceHolderText( + filterParams.analyticType, + filterParams.planned + ) + binding.tvAnalyticInfoPlaceHolder.isVisible = isVisible + } - val addSaving = R.id.action_analyticsFragment_to_addSavingSelectionFragment + private fun getAnalyticInfoPlaceHolderText(type: AnalyticType, planned: Boolean): String { + return if (planned) { + when (type) { + AnalyticType.INCOME -> getString(app.cashadvisor.uikit.R.string.mp_plan_income_analytic_info_zero) + AnalyticType.EXPENSE -> getString(app.cashadvisor.uikit.R.string.mp_plan_expense_analytic_info_zero) + else -> getString(app.cashadvisor.uikit.R.string.mp_plan_saving_analytic_info_zero) + } + } else { + when (type) { + AnalyticType.INCOME -> getString(app.cashadvisor.uikit.R.string.mp_fact_income_analytic_info_zero) + AnalyticType.EXPENSE -> getString(app.cashadvisor.uikit.R.string.mp_fact_expense_analytic_info_zero) + else -> getString(app.cashadvisor.uikit.R.string.mp_fact_saving_analytic_info_zero) + } + } + } + private fun tuneCategoryProgressBar(state: AnalyticsUiState.Content) { + binding.grProgress.isVisible = false + if (state.filterParams.planned) { + with (binding) { + tvProgressInfoLabel.text = getProgressInfoLabelText(state.filterParams.analyticType) + val percent = state.completePercent + val defaultDrawable = state.getTotalAmountByFilter().compareTo(BigDecimal.ZERO) == 0 + piAnalyticProgress.progressDrawable = AppCompatResources.getDrawable( + requireContext(), + getProgressDrawable(defaultDrawable) + ) + piAnalyticProgress.progress = percent.toInt() + tvProgressPercent.text = String.format("%s %%", formatter.format(percent)) + tvProgressInfoAmount.text = formatter.format(state.remainAmount) + grProgress.isVisible = true + } - binding.btnAddManually.setOnClickListener { - with(binding) { - when { - rbIncome.isChecked && rbPlan.isChecked -> { - findNavController().navigate(planIncome) - } + } + } - rbIncome.isChecked && rbFact.isChecked -> { - findNavController().navigate(addIncome) - } + @DrawableRes + private fun getProgressDrawable(defaultDrawable: Boolean): Int { + return if (defaultDrawable) { + app.cashadvisor.uikit.R.drawable.gradient_linear_progress_bar + } else { + app.cashadvisor.uikit.R.drawable.linear_progress_bar + } + } - rbExpense.isChecked && rbPlan.isChecked -> { - findNavController().navigate(planExpense) - } + private fun getProgressInfoLabelText(type: AnalyticType): String { + return when (type) { + AnalyticType.INCOME -> getString(app.cashadvisor.uikit.R.string.mp_progress_info_income_label) + AnalyticType.EXPENSE -> getString(app.cashadvisor.uikit.R.string.mp_progress_info_expense_label) + else -> getString(app.cashadvisor.uikit.R.string.mp_progress_info_saving_label) + } + } - rbExpense.isChecked && rbFact.isChecked -> { - findNavController().navigate(addExpense) - } + private fun setTotalAnalyticAmount(amount: BigDecimal) { + binding.tvAnalyticAmount.text = formatter.format(amount) + } - rbSaving.isChecked && rbPlan.isChecked -> { - findNavController().navigate(planSaving) - } + private fun tuneNavigationForState(params: FilterParams) { + with (binding) { + btnAdd.text = getAddButtonText(params.analyticType, params.planned) + btnAdd.setOnClickListener { + findNavController().navigate(getAddButtonNavigationId(params.analyticType, params.planned)) + } + btnExAdd.isVisible = !params.planned + if (btnExAdd.isVisible) { + btnExAdd.setOnClickListener { + findNavController().navigate(getAddExButtonNavigationId(params.analyticType)) + } + } + } + } - rbSaving.isChecked && rbFact.isChecked -> { - findNavController().navigate(addSaving) - } + private fun getAddButtonText(type: AnalyticType, planned: Boolean): String { + return if (!planned) getString(app.cashadvisor.uikit.R.string.mp_add_bank_button) else { + when (type) { + AnalyticType.INCOME -> getString(app.cashadvisor.uikit.R.string.mp_add_income_button) + AnalyticType.EXPENSE -> getString(app.cashadvisor.uikit.R.string.mp_add_expense_button) + else -> getString(app.cashadvisor.uikit.R.string.mp_add_saving_button) + } + } + } - } + @IdRes + private fun getAddButtonNavigationId(type: AnalyticType, planned: Boolean): Int { + return if (planned) { + when (type) { + AnalyticType.INCOME -> R.id.action_analyticsFragment_to_planIncomeSelectionFragment + AnalyticType.EXPENSE -> R.id.action_analyticsFragment_to_planExpenseSelectionFragment + else -> R.id.action_analyticsFragment_to_planSavingSelectionFragment } + } else { + R.id.action_analyticsFragment_to_addBankSelectionFragment + } + + } + + @IdRes + private fun getAddExButtonNavigationId(type: AnalyticType): Int { + return when (type) { + AnalyticType.INCOME -> R.id.action_analyticsFragment_to_addIncomeSelectionFragment + AnalyticType.EXPENSE -> R.id.action_analyticsFragment_to_addExpenseSelectionFragment + else -> R.id.action_analyticsFragment_to_addSavingSelectionFragment } + } + private fun setAnalyticType(@IdRes checkedChipId: Int) { + val type = when (checkedChipId) { + binding.btnIncome.id -> AnalyticType.INCOME + binding.btnExpense.id -> AnalyticType.EXPENSE + binding.btnSaving.id -> AnalyticType.SAVING + else -> AnalyticType.INCOME + } + viewModel.setAnalyticType(type) + } + private fun setPlanned(@IdRes checkedChipId: Int) { + val planned = checkedChipId == binding.btnPlanFilter.id + viewModel.setPlanned(planned) } - override fun onDestroyView() { - super.onDestroyView() - _binding = null + private fun setPeriod(beginDate: Date, endDate: Date) { + viewModel.setPeriod(beginDate, endDate) } } \ No newline at end of file diff --git a/app/src/main/java/app/cashadvisor/analytics/presentation/ui/AnalyticsViewModel.kt b/app/src/main/java/app/cashadvisor/analytics/presentation/ui/AnalyticsViewModel.kt new file mode 100644 index 00000000..4a5a3884 --- /dev/null +++ b/app/src/main/java/app/cashadvisor/analytics/presentation/ui/AnalyticsViewModel.kt @@ -0,0 +1,180 @@ +package app.cashadvisor.analytics.presentation.ui + +import androidx.lifecycle.viewModelScope +import app.cashadvisor.analytics.presentation.MockData +import app.cashadvisor.analytics.presentation.model.Account +import app.cashadvisor.analytics.presentation.model.AnalyticType +import app.cashadvisor.analytics.presentation.model.CategorySummary +import app.cashadvisor.analytics.presentation.model.FilterParams +import app.cashadvisor.analytics.presentation.ui.state.AnalyticsUiState +import app.cashadvisor.common.ui.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import java.math.BigDecimal +import java.util.Calendar +import java.util.Date +import javax.inject.Inject + +@HiltViewModel +class AnalyticsViewModel @Inject constructor() : BaseViewModel() { + private val _analyticsUiState = MutableStateFlow(AnalyticsUiState.Default) + val analyticsUiState: StateFlow + get() = _analyticsUiState + private val _filterParams = MutableStateFlow(FilterParams.getDefault()) + private val filterParams: StateFlow + get() = _filterParams + private val _categorySummaryList = MutableStateFlow?>(null) + private val categorySummaryList: StateFlow?> + get() = _categorySummaryList + + init { + setPeriod(getFirstDayOfMonth(), getLastDayOfMonth()) + observeFilterParams() + observeCategorySummaryList() + } + + private fun observeFilterParams() { + viewModelScope.launch { + filterParams.collectLatest{ params -> applyFilter(params) } + } + } + + private fun observeCategorySummaryList() { + viewModelScope.launch { + _categorySummaryList.collectLatest{ list -> setContent(list) } + } + } + + private fun applyFilter(params: FilterParams) { + when (_analyticsUiState.value) { + is AnalyticsUiState.Default -> loadData(params) + is AnalyticsUiState.Loading -> loadData(params) + is AnalyticsUiState.Content -> { + val state = _analyticsUiState.value as AnalyticsUiState.Content + if (params.beginDate == state.filterParams.beginDate && + params.endDate == state.filterParams.endDate) { + _analyticsUiState.value = state.copy( + filterParams = params.copy(), + data = _categorySummaryList.value?.filter { + categorySummary -> categorySummary.analyticType == params.analyticType && + categorySummary.planned == params.planned + }, + completePercent = getTotalCategoryProgress(true), + remainAmount = getTotalCategoryProgress(false) + ) + } else { + loadData(params) + } + } + } + } + + private fun loadData(params: FilterParams) { + fetchCategorySummaryList(params.beginDate, params.endDate) + } + + private fun fetchCategorySummaryList(beginDate: Date, endDate: Date) { + viewModelScope.launch { + _analyticsUiState.value = AnalyticsUiState.Loading( + user = null, + account = Account(BigDecimal.ZERO), + filterParams = filterParams.value.copy() + ) + delay(3000L) + // заменить на получение данных из репозитория + _categorySummaryList.value = MockData.getCategorySummaryList(beginDate, endDate) + } + } + + private fun setContent(list: List?) { + if (_analyticsUiState.value is AnalyticsUiState.Default) { + return + } + _analyticsUiState.value = AnalyticsUiState.Content( + user = null, + account = Account(BigDecimal.ZERO), + filterParams = filterParams.value.copy(), + data = list?.filter { categorySummary -> + categorySummary.analyticType == filterParams.value.analyticType && + categorySummary.planned == filterParams.value.planned + }, + completePercent = getTotalCategoryProgress(true), + remainAmount = getTotalCategoryProgress(false) + ) + } + + fun setAnalyticType(analyticType: AnalyticType) { + _filterParams.value = _filterParams.value.copy( + analyticType = analyticType + ) + } + + fun setPlanned(planned: Boolean) { + _filterParams.value = _filterParams.value.copy( + planned = planned + ) + } + + fun setPeriod(beginDate: Date, endDate: Date) { + _filterParams.value = _filterParams.value.copy( + beginDate = beginDate, + endDate = endDate + ) + } + + private fun getTotalCategoryProgress(asPercentage: Boolean): BigDecimal { + var progress = BigDecimal.valueOf(0) + if (!filterParams.value.planned) { + return progress + } + + var sumPlan = BigDecimal.valueOf(0) + categorySummaryList.value?.let { data -> + data + .filter { categorySummary -> categorySummary.analyticType == filterParams.value.analyticType } + .filter { categorySummary -> categorySummary.planned } + .forEach { categorySummary -> + sumPlan = sumPlan.add(categorySummary.amount) + } + } + if (sumPlan.compareTo(BigDecimal.ZERO) == 0) { + return progress + } + + var sumFact = BigDecimal.valueOf(0) + categorySummaryList.value?.let { data -> + data + .filter { categorySummary -> categorySummary.analyticType == filterParams.value.analyticType } + .filter { categorySummary -> !categorySummary.planned } + .forEach { categorySummary -> + sumFact = sumFact.add(categorySummary.amount) + } + } + progress = if (asPercentage) { + sumFact.divide(sumPlan).movePointRight(2) + } else { + sumPlan.minus(sumFact) + } + + return progress + } + + private fun getLastDayOfMonth() : Date { + val current = Calendar.getInstance() + val lastDay = Calendar.getInstance() + lastDay.set(current.get(Calendar.YEAR), current.get(Calendar.MONTH), + current.getActualMaximum(Calendar.DATE)) + return lastDay.time + } + + private fun getFirstDayOfMonth() : Date { + val current = Calendar.getInstance() + val firstDay = Calendar.getInstance() + firstDay.set(current.get(Calendar.YEAR), current.get(Calendar.MONTH), 1) + return firstDay.time + } +} \ No newline at end of file diff --git a/app/src/main/java/app/cashadvisor/analytics/presentation/ui/adapter/AnalyticInfoAdapter.kt b/app/src/main/java/app/cashadvisor/analytics/presentation/ui/adapter/AnalyticInfoAdapter.kt new file mode 100644 index 00000000..95f1e408 --- /dev/null +++ b/app/src/main/java/app/cashadvisor/analytics/presentation/ui/adapter/AnalyticInfoAdapter.kt @@ -0,0 +1,169 @@ +package app.cashadvisor.analytics.presentation.ui.adapter + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.annotation.ColorRes +import androidx.core.content.ContextCompat +import androidx.core.content.ContextCompat.getColor +import androidx.core.content.ContextCompat.getString +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import app.cashadvisor.analytics.presentation.model.AnalyticType +import app.cashadvisor.analytics.presentation.model.CategorySummary +import app.cashadvisor.analytics.presentation.model.SubcategorySummary +import app.cashadvisor.analytics.presentation.model.getCategoryCurrencyTextColor +import app.cashadvisor.categories.presentation.ui.CategoriesIcon +import app.cashadvisor.common.utils.MoneyFormatter +import app.cashadvisor.databinding.ItemAnalyticsFactBinding +import app.cashadvisor.databinding.ItemAnalyticsFactRowBinding +import app.cashadvisor.databinding.ItemAnalyticsPlanBinding +import app.cashadvisor.uikit.R +import com.bumptech.glide.Glide + +class AnalyticInfoAdapter(private val moneyFormatter: MoneyFormatter) : ListAdapter( + CategorySummaryDiffUtilCallback() +) { + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): RecyclerView.ViewHolder { + val layout = LayoutInflater.from(parent.context) + return when(viewType) { + PLAN_VIEW_TYPE -> { + val binding = + ItemAnalyticsPlanBinding.inflate(layout, parent, false) + SimpleCategorySummaryViewHolder(binding, moneyFormatter) + } + FACT_VIEW_TYPE -> { + val binding = + ItemAnalyticsFactBinding.inflate(layout, parent, false) + CategorySummaryViewHolder(binding, moneyFormatter) + } + else -> { + val binding = + ItemAnalyticsFactBinding.inflate(layout, parent, false) + CategorySummaryViewHolder(binding, moneyFormatter) + } + } + } + + override fun onBindViewHolder( + holder: RecyclerView.ViewHolder, + position: Int + ) { + when (holder) { + is CategorySummaryViewHolder -> holder.bind(getItem(position)) + is SimpleCategorySummaryViewHolder -> holder.bind(getItem(position)) + } + } + + override fun getItemViewType(position: Int): Int { + val item = getItem(position) + return if (item.planned) PLAN_VIEW_TYPE else FACT_VIEW_TYPE + } + + inner class SimpleCategorySummaryViewHolder( + private val binding: ItemAnalyticsPlanBinding, + private val moneyFormatter: MoneyFormatter + ) : RecyclerView.ViewHolder(binding.root) { + fun bind(categorySummary: CategorySummary) = with(binding) { + tvCategoryName.text = categorySummary.name + tvCategoryAmount.text = moneyFormatter.format(categorySummary.amount) + tvCategoryCurrency.text = getCategoryCurrencyText(categorySummary.analyticType) + val imageDrawableRes = CategoriesIcon.getCategoriesImageResIdFromId(categorySummary.id.toInt()) + if (imageDrawableRes != null) { + Glide.with(itemView) + .load(ContextCompat.getDrawable( + itemView.context, + imageDrawableRes + )) + .into(ivCategory) + } else { + ivCategory.setImageDrawable(null) + } + piAnalyticProgress.progress = 0 + categorySummary.completePercent?.let { percent -> + piAnalyticProgress.progress = percent.toInt() + } + tvCategoryCurrency.setTextColor( + getColor( + itemView.context, + getCategoryCurrencyTextColor(piAnalyticProgress.progress) + ) + ) + } + + private fun getCategoryCurrencyText(type: AnalyticType): String { + return when (type) { + AnalyticType.INCOME -> getString(itemView.context, R.string.mp_plus_currency_symbol) + AnalyticType.EXPENSE -> getString(itemView.context,R.string.mp_minus_currency_symbol) + else -> getString(itemView.context,R.string.mp_currency_symbol) + } + } + + @ColorRes + private fun getCategoryCurrencyTextColor(percent: Int): Int { + return if (percent == 100) { + R.color.m5 + } else { + R.color.subcolour2 + } + } + } + + inner class CategorySummaryViewHolder( + private val binding: ItemAnalyticsFactBinding, + private val moneyFormatter: MoneyFormatter + ) : RecyclerView.ViewHolder(binding.root) { + fun bind(categorySummary: CategorySummary) = with(binding) { + tvCategoryName.text = categorySummary.name + tvCategoryAmount.text = moneyFormatter.format(categorySummary.amount) + tvCategoryCurrency.text = getCategoryCurrencyText(categorySummary.analyticType) + tvCategoryCurrency.setTextColor( + getColor( + itemView.context, + categorySummary.analyticType.getCategoryCurrencyTextColor() + ) + ) + val imageDrawableRes = + CategoriesIcon.getCategoriesImageResIdFromId(categorySummary.id.toInt()) + if (imageDrawableRes != null) { + Glide.with(itemView) + .load(ContextCompat.getDrawable( + itemView.context, + imageDrawableRes + )) + .into(ivCategory) + } else { + ivCategory.setImageDrawable(null) + } + llCategoryDetails.removeAllViews() + categorySummary.subcategoryList?.let { + fillSubcategories(it, llCategoryDetails) + } + } + + private fun fillSubcategories(list: List, parent: ViewGroup) { + list.forEach { subcategorySummary -> + val layout = LayoutInflater.from(parent.context) + val binding = ItemAnalyticsFactRowBinding.inflate(layout, parent, false) + binding.tvCategoryItemName.text = subcategorySummary.name + binding.tvCategoryItemAmount.text = moneyFormatter.format(subcategorySummary.amount) + parent.addView(binding.root) + } + } + + private fun getCategoryCurrencyText(type: AnalyticType): String { + return when (type) { + AnalyticType.INCOME -> getString(itemView.context, R.string.mp_plus_currency_symbol) + AnalyticType.EXPENSE -> getString(itemView.context,R.string.mp_minus_currency_symbol) + else -> getString(itemView.context,R.string.mp_currency_symbol) + } + } + } + + companion object { + private const val PLAN_VIEW_TYPE = 101 + private const val FACT_VIEW_TYPE = 102 + } +} \ No newline at end of file diff --git a/app/src/main/java/app/cashadvisor/analytics/presentation/ui/adapter/CategorySummaryDiffUtilCallback.kt b/app/src/main/java/app/cashadvisor/analytics/presentation/ui/adapter/CategorySummaryDiffUtilCallback.kt new file mode 100644 index 00000000..89be006b --- /dev/null +++ b/app/src/main/java/app/cashadvisor/analytics/presentation/ui/adapter/CategorySummaryDiffUtilCallback.kt @@ -0,0 +1,12 @@ +package app.cashadvisor.analytics.presentation.ui.adapter + +import androidx.recyclerview.widget.DiffUtil +import app.cashadvisor.analytics.presentation.model.CategorySummary + +class CategorySummaryDiffUtilCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: CategorySummary, newItem: CategorySummary): Boolean = + oldItem.id == newItem.id + + override fun areContentsTheSame(oldItem: CategorySummary, newItem: CategorySummary): Boolean = + oldItem == newItem +} \ No newline at end of file diff --git a/app/src/main/java/app/cashadvisor/analytics/presentation/ui/state/AnalyticsUiState.kt b/app/src/main/java/app/cashadvisor/analytics/presentation/ui/state/AnalyticsUiState.kt new file mode 100644 index 00000000..a1829178 --- /dev/null +++ b/app/src/main/java/app/cashadvisor/analytics/presentation/ui/state/AnalyticsUiState.kt @@ -0,0 +1,36 @@ +package app.cashadvisor.analytics.presentation.ui.state + +import app.cashadvisor.analytics.presentation.model.Account +import app.cashadvisor.analytics.presentation.model.CategorySummary +import app.cashadvisor.analytics.presentation.model.FilterParams +import app.cashadvisor.analytics.presentation.model.User +import java.math.BigDecimal + +sealed interface AnalyticsUiState { + data object Default : AnalyticsUiState + + data class Loading( + val user: User?, + val account: Account, + val filterParams: FilterParams + ) : AnalyticsUiState + + data class Content( + val user: User?, + val account: Account, + val filterParams: FilterParams, + val data: List?, + val completePercent: BigDecimal, + val remainAmount: BigDecimal, + ) : AnalyticsUiState { + fun getTotalAmountByFilter(): BigDecimal { + var result = BigDecimal.valueOf(0) + data?.let { data -> + data.forEach { categorySummary -> + result = result.add(categorySummary.amount) + } + } + return result + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/cashadvisor/common/di/UtilsModule.kt b/app/src/main/java/app/cashadvisor/common/di/UtilsModule.kt new file mode 100644 index 00000000..1885c2be --- /dev/null +++ b/app/src/main/java/app/cashadvisor/common/di/UtilsModule.kt @@ -0,0 +1,17 @@ +package app.cashadvisor.common.di + +import app.cashadvisor.common.utils.MoneyFormatter +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +class UtilsModule { + + @Provides + @Singleton + fun providesMoneyFormatter(): MoneyFormatter = MoneyFormatter() +} \ No newline at end of file diff --git a/app/src/main/java/app/cashadvisor/common/utils/MoneyFormatter.kt b/app/src/main/java/app/cashadvisor/common/utils/MoneyFormatter.kt new file mode 100644 index 00000000..0aaf9811 --- /dev/null +++ b/app/src/main/java/app/cashadvisor/common/utils/MoneyFormatter.kt @@ -0,0 +1,19 @@ +package app.cashadvisor.common.utils + +import java.math.BigDecimal +import java.text.NumberFormat + +class MoneyFormatter { + private val formatter: NumberFormat = NumberFormat.getNumberInstance() + + fun format(amount: BigDecimal): String { + formatter.maximumFractionDigits = MAX_FRACTION_DIGITS + formatter.minimumFractionDigits = MIN_FRACTION_DIGITS + return formatter.format(amount) + } + + companion object { + private const val MIN_FRACTION_DIGITS = 2 + private const val MAX_FRACTION_DIGITS = 2 + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_analytics.xml b/app/src/main/res/layout/fragment_analytics.xml index 36724d3b..95b2759f 100644 --- a/app/src/main/res/layout/fragment_analytics.xml +++ b/app/src/main/res/layout/fragment_analytics.xml @@ -4,105 +4,288 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" + android:background="?attr/whitecolour" tools:context=".analytics.presentation.ui.AnalyticsFragment"> + + + + - - - - + + android:layout_height="@dimen/mp_filter_group_height" + app:chipSpacingHorizontal="@dimen/mp_analytics_filter_spacing" + app:singleSelection="true" + app:selectionRequired="true"> - + - + - + + + - - - - - - - + android:layout_marginVertical="@dimen/mp_24dp" + android:gravity="center_horizontal" + android:paddingVertical="@dimen/mp_8dp" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toBottomOf="@id/svMainBarAnalytics" + android:text="@string/mp_zero_balance_amount"/> + app:layout_constraintEnd_toStartOf="@id/tvAnalyticAmount" + app:layout_constraintTop_toTopOf="@id/tvAnalyticAmount" + android:text="₽"/> -