diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index fd71dcf..c1b185a 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -21,10 +21,6 @@
-
-
@@ -78,6 +74,12 @@
+
+
-
\ No newline at end of file
diff --git a/android/app/src/main/java/app/priceguard/data/dto/deleteaccount/DeleteAccountRequest.kt b/android/app/src/main/java/app/priceguard/data/dto/deleteaccount/DeleteAccountRequest.kt
new file mode 100644
index 0000000..7a372b1
--- /dev/null
+++ b/android/app/src/main/java/app/priceguard/data/dto/deleteaccount/DeleteAccountRequest.kt
@@ -0,0 +1,9 @@
+package app.priceguard.data.dto.deleteaccount
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class DeleteAccountRequest(
+ val email: String,
+ val password: String
+)
diff --git a/android/app/src/main/java/app/priceguard/data/dto/deleteaccount/DeleteAccountResponse.kt b/android/app/src/main/java/app/priceguard/data/dto/deleteaccount/DeleteAccountResponse.kt
new file mode 100644
index 0000000..f4545da
--- /dev/null
+++ b/android/app/src/main/java/app/priceguard/data/dto/deleteaccount/DeleteAccountResponse.kt
@@ -0,0 +1,9 @@
+package app.priceguard.data.dto.deleteaccount
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class DeleteAccountResponse(
+ val statusCode: Int,
+ val message: String
+)
diff --git a/android/app/src/main/java/app/priceguard/data/network/UserAPI.kt b/android/app/src/main/java/app/priceguard/data/network/UserAPI.kt
index 270b0a4..6d79c74 100644
--- a/android/app/src/main/java/app/priceguard/data/network/UserAPI.kt
+++ b/android/app/src/main/java/app/priceguard/data/network/UserAPI.kt
@@ -1,5 +1,7 @@
package app.priceguard.data.network
+import app.priceguard.data.dto.deleteaccount.DeleteAccountRequest
+import app.priceguard.data.dto.deleteaccount.DeleteAccountResponse
import app.priceguard.data.dto.firebase.FirebaseTokenUpdateRequest
import app.priceguard.data.dto.firebase.FirebaseTokenUpdateResponse
import app.priceguard.data.dto.login.LoginRequest
@@ -24,6 +26,11 @@ interface UserAPI {
@Body request: SignupRequest
): Response
+ @POST("remove")
+ suspend fun deleteAccount(
+ @Body request: DeleteAccountRequest
+ ): Response
+
@PUT("firebase/token")
suspend fun updateFirebaseToken(
@Header("Authorization") authToken: String,
diff --git a/android/app/src/main/java/app/priceguard/data/repository/auth/AuthRepository.kt b/android/app/src/main/java/app/priceguard/data/repository/auth/AuthRepository.kt
index f75b37b..9acf871 100644
--- a/android/app/src/main/java/app/priceguard/data/repository/auth/AuthRepository.kt
+++ b/android/app/src/main/java/app/priceguard/data/repository/auth/AuthRepository.kt
@@ -9,4 +9,6 @@ interface AuthRepository {
suspend fun signUp(email: String, userName: String, password: String): RepositoryResult
suspend fun login(email: String, password: String): RepositoryResult
+
+ suspend fun deleteAccount(email: String, password: String): RepositoryResult
}
diff --git a/android/app/src/main/java/app/priceguard/data/repository/auth/AuthRepositoryImpl.kt b/android/app/src/main/java/app/priceguard/data/repository/auth/AuthRepositoryImpl.kt
index d007435..ed77230 100644
--- a/android/app/src/main/java/app/priceguard/data/repository/auth/AuthRepositoryImpl.kt
+++ b/android/app/src/main/java/app/priceguard/data/repository/auth/AuthRepositoryImpl.kt
@@ -1,5 +1,6 @@
package app.priceguard.data.repository.auth
+import app.priceguard.data.dto.deleteaccount.DeleteAccountRequest
import app.priceguard.data.dto.login.LoginRequest
import app.priceguard.data.dto.signup.SignupRequest
import app.priceguard.data.network.UserAPI
@@ -73,4 +74,20 @@ class AuthRepositoryImpl @Inject constructor(private val userAPI: UserAPI) : Aut
}
}
}
+
+ override suspend fun deleteAccount(email: String, password: String): RepositoryResult {
+ val response = getApiResult {
+ userAPI.deleteAccount(DeleteAccountRequest(email, password))
+ }
+
+ return when (response) {
+ is APIResult.Success -> {
+ RepositoryResult.Success(true)
+ }
+
+ is APIResult.Error -> {
+ handleError(response.code)
+ }
+ }
+ }
}
diff --git a/android/app/src/main/java/app/priceguard/ui/additem/setprice/SetTargetPriceDialogFragment.kt b/android/app/src/main/java/app/priceguard/ui/additem/setprice/SetTargetPriceDialogFragment.kt
new file mode 100644
index 0000000..51bb991
--- /dev/null
+++ b/android/app/src/main/java/app/priceguard/ui/additem/setprice/SetTargetPriceDialogFragment.kt
@@ -0,0 +1,123 @@
+package app.priceguard.ui.additem.setprice
+
+import android.app.Dialog
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.widget.addTextChangedListener
+import androidx.fragment.app.DialogFragment
+import androidx.fragment.app.viewModels
+import app.priceguard.R
+import app.priceguard.databinding.FragmentTargetPriceDialogBinding
+import app.priceguard.ui.util.lifecycle.repeatOnStarted
+import app.priceguard.ui.util.setTextColorWithEnabled
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+
+class SetTargetPriceDialogFragment : DialogFragment() {
+
+ private var _binding: FragmentTargetPriceDialogBinding? = null
+ private val binding get() = _binding!!
+
+ private val viewModel: SetTargetPriceDialogViewModel by viewModels()
+
+ private var resultListener: OnDialogResultListener? = null
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ _binding = FragmentTargetPriceDialogBinding.inflate(requireActivity().layoutInflater)
+ val view = binding.root
+
+ val title = arguments?.getString("title") ?: ""
+
+ val dialogBuilder = MaterialAlertDialogBuilder(
+ requireActivity(),
+ R.style.ThemeOverlay_App_MaterialAlertDialog
+ ).apply {
+ setTitle(title)
+ setView(view)
+ setNegativeButton(R.string.cancel) { _, _ -> dismiss() }
+ setPositiveButton(R.string.confirm) { _, _ ->
+ resultListener?.onDialogResult(viewModel.state.value.targetPrice.toInt())
+ dismiss()
+ }
+ }
+ val dialog = dialogBuilder.create()
+
+ repeatOnStarted {
+ viewModel.state.collect { state ->
+ viewModel.updateTextChangedEnabled(false)
+ binding.etTargetPriceDialog.setText(
+ getString(R.string.won, getString(R.string.comma_number, state.targetPrice))
+ )
+ val positiveButton = dialog.getButton(Dialog.BUTTON_POSITIVE)
+ positiveButton.isEnabled = !state.isErrorMessageVisible
+ positiveButton.setTextColorWithEnabled()
+
+ viewModel.updateTextChangedEnabled(true)
+ }
+ }
+
+ return dialog
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ super.onCreateView(inflater, container, savedInstanceState)
+
+ binding.viewModel = viewModel
+ binding.lifecycleOwner = viewLifecycleOwner
+
+ initListener()
+
+ val price = arguments?.getInt("price") ?: 0
+ viewModel.updateTargetPrice(price.toLong())
+
+ return binding.root
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+
+ private fun initListener() {
+ binding.etTargetPriceDialog.addTextChangedListener {
+ binding.etTargetPriceDialog.setSelection(it.toString().length - 1)
+
+ val price = extractAndConvertToInteger(it.toString())
+
+ if (viewModel.state.value.isTextChanged) {
+ if (price > MAX_TARGET_PRICE) {
+ viewModel.updateErrorMessageVisible(true)
+ } else {
+ viewModel.updateErrorMessageVisible(false)
+ }
+ viewModel.updateTargetPrice(price)
+ }
+ }
+
+ binding.etTargetPriceDialog.setOnClickListener {
+ binding.etTargetPriceDialog.setSelection(binding.etTargetPriceDialog.text.toString().length - 1)
+ }
+ }
+
+ private fun extractAndConvertToInteger(text: String): Long {
+ val digits = text.filter { it.isDigit() }
+ return (digits.toLongOrNull() ?: 0).coerceIn(0, MAX_TARGET_PRICE * 10 - 1)
+ }
+
+ fun setOnDialogResultListener(listener: OnDialogResultListener) {
+ resultListener = listener
+ }
+
+ interface OnDialogResultListener {
+ fun onDialogResult(result: Int)
+ }
+
+ companion object {
+ const val MAX_TARGET_PRICE = 1_000_000_000L
+ }
+}
diff --git a/android/app/src/main/java/app/priceguard/ui/additem/setprice/SetTargetPriceDialogViewModel.kt b/android/app/src/main/java/app/priceguard/ui/additem/setprice/SetTargetPriceDialogViewModel.kt
new file mode 100644
index 0000000..a7285e1
--- /dev/null
+++ b/android/app/src/main/java/app/priceguard/ui/additem/setprice/SetTargetPriceDialogViewModel.kt
@@ -0,0 +1,29 @@
+package app.priceguard.ui.additem.setprice
+
+import androidx.lifecycle.ViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+class SetTargetPriceDialogViewModel : ViewModel() {
+
+ data class SetTargetPriceDialogState(
+ val targetPrice: Long = 0,
+ val isTextChanged: Boolean = false,
+ val isErrorMessageVisible: Boolean = false
+ )
+
+ private val _state = MutableStateFlow(SetTargetPriceDialogState())
+ val state = _state.asStateFlow()
+
+ fun updateTargetPrice(price: Long) {
+ _state.value = _state.value.copy(targetPrice = price)
+ }
+
+ fun updateTextChangedEnabled(isEnabled: Boolean) {
+ _state.value = _state.value.copy(isTextChanged = isEnabled)
+ }
+
+ fun updateErrorMessageVisible(isEnabled: Boolean) {
+ _state.value = _state.value.copy(isErrorMessageVisible = isEnabled)
+ }
+}
diff --git a/android/app/src/main/java/app/priceguard/ui/additem/setprice/SetTargetPriceFragment.kt b/android/app/src/main/java/app/priceguard/ui/additem/setprice/SetTargetPriceFragment.kt
index 6b1f287..509fd64 100644
--- a/android/app/src/main/java/app/priceguard/ui/additem/setprice/SetTargetPriceFragment.kt
+++ b/android/app/src/main/java/app/priceguard/ui/additem/setprice/SetTargetPriceFragment.kt
@@ -1,12 +1,10 @@
package app.priceguard.ui.additem.setprice
import android.os.Bundle
-import android.text.Editable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.addCallback
-import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
@@ -16,17 +14,16 @@ import app.priceguard.data.repository.token.TokenRepository
import app.priceguard.databinding.FragmentSetTargetPriceBinding
import app.priceguard.ui.additem.setprice.SetTargetPriceViewModel.SetTargetPriceEvent
import app.priceguard.ui.data.DialogConfirmAction
+import app.priceguard.ui.slider.RoundSliderState
import app.priceguard.ui.util.lifecycle.repeatOnStarted
import app.priceguard.ui.util.showDialogWithAction
import app.priceguard.ui.util.showDialogWithLogout
-import com.google.android.material.slider.Slider
-import com.google.android.material.slider.Slider.OnSliderTouchListener
import dagger.hilt.android.AndroidEntryPoint
import java.text.NumberFormat
import javax.inject.Inject
@AndroidEntryPoint
-class SetTargetPriceFragment : Fragment() {
+class SetTargetPriceFragment : Fragment(), SetTargetPriceDialogFragment.OnDialogResultListener {
@Inject
lateinit var tokenRepository: TokenRepository
@@ -53,6 +50,7 @@ class SetTargetPriceFragment : Fragment() {
setBackPressedCallback()
binding.initView()
binding.initListener()
+ initCollector()
handleEvent()
}
@@ -73,9 +71,9 @@ class SetTargetPriceFragment : Fragment() {
val productCode = arguments.getString("productCode") ?: ""
val title = arguments.getString("productTitle") ?: ""
val price = arguments.getInt("productPrice")
- var targetPrice = arguments.getInt("productTargetPrice")
+ val targetPrice = arguments.getInt("productTargetPrice")
- setTargetPriceViewModel.updateTargetPrice(targetPrice)
+ setTargetPriceViewModel.setProductInfo(productShop, productCode, title, price, targetPrice)
tvSetPriceCurrentPrice.text =
String.format(
@@ -85,10 +83,22 @@ class SetTargetPriceFragment : Fragment() {
tvSetPriceCurrentPrice.contentDescription =
getString(R.string.current_price_info, tvSetPriceCurrentPrice.text)
- setTargetPriceViewModel.setProductInfo(productShop, productCode, title, price)
- etTargetPrice.setText(targetPrice.toString())
+ rsTargetPrice.setMaxPercentValue(MAX_PERCENT)
+ rsTargetPrice.setStepSize(STEP_SIZE)
- updateSlideValueWithPrice(targetPrice.toFloat())
+ btnTargetPriceDecrease.setOnClickListener {
+ val sliderValue = (rsTargetPrice.sliderValue - STEP_SIZE).coerceIn(0, MAX_PERCENT)
+ rsTargetPrice.setValue(sliderValue)
+ setTargetPriceViewModel.updateTargetPriceFromPercent(sliderValue)
+ }
+
+ btnTargetPriceIncrease.setOnClickListener {
+ val sliderValue = (rsTargetPrice.sliderValue + STEP_SIZE).coerceIn(0, MAX_PERCENT)
+ rsTargetPrice.setValue(sliderValue)
+ setTargetPriceViewModel.updateTargetPriceFromPercent(sliderValue)
+ }
+
+ calculatePercentAndSetSliderValue(price, targetPrice)
}
private fun FragmentSetTargetPriceBinding.initListener() {
@@ -99,58 +109,39 @@ class SetTargetPriceFragment : Fragment() {
findNavController().navigateUp()
}
}
+
btnConfirmItemNext.setOnClickListener {
val isAdding = requireArguments().getBoolean("isAdding")
if (isAdding) setTargetPriceViewModel.addProduct() else setTargetPriceViewModel.patchProduct()
}
- slTargetPrice.addOnChangeListener { _, value, _ ->
- if (!etTargetPrice.isFocused) {
- setTargetPriceAndPercent(value)
+
+ rsTargetPrice.setSliderValueChangeListener { value ->
+ if (setTargetPriceViewModel.state.value.isEnabledSliderListener) {
+ setTargetPriceViewModel.updateTargetPriceFromPercent(value)
}
}
- slTargetPrice.addOnSliderTouchListener(object : OnSliderTouchListener {
- override fun onStartTrackingTouch(slider: Slider) {
- etTargetPrice.clearFocus()
- setTargetPriceAndPercent(slider.value)
- }
- override fun onStopTrackingTouch(slider: Slider) {
- }
- })
- etTargetPrice.addTextChangedListener {
- updateTargetPriceUI(it)
+ tvTargetPriceContent.setOnClickListener {
+ showConfirmationDialogForResult()
}
}
- private fun updateTargetPriceUI(it: Editable?) {
- if (binding.etTargetPrice.isFocused) {
- val targetPrice = if (it.toString().matches("^\\d{1,9}$".toRegex())) {
- it.toString().toInt()
- } else if (it.toString().isEmpty()) {
- binding.etTargetPrice.setText(getString(R.string.min_price))
- 0
- } else {
- binding.etTargetPrice.setText(getString(R.string.max_price))
- 999999999
+ private fun initCollector() {
+ repeatOnStarted {
+ setTargetPriceViewModel.state.collect { state ->
+ if (state.targetPrice > state.productPrice) {
+ binding.rsTargetPrice.setSliderMode(RoundSliderState.ERROR)
+ } else {
+ binding.rsTargetPrice.setSliderMode(RoundSliderState.ACTIVE)
+ }
}
-
- setTargetPriceViewModel.updateTargetPrice(targetPrice)
- binding.updateSlideValueWithPrice(targetPrice.toFloat())
}
}
- private fun Int.roundAtFirstDigit(): Int {
- return ((this + 5) / 10) * 10
- }
-
- private fun FragmentSetTargetPriceBinding.setTargetPriceAndPercent(value: Float) {
- val targetPrice = ((setTargetPriceViewModel.state.value.productPrice) * value.toInt() / 100)
- tvTargetPricePercent.text =
- String.format(getString(R.string.current_price_percent), value.toInt())
- etTargetPrice.setText(
- targetPrice.toString()
- )
- setTargetPriceViewModel.updateTargetPrice(targetPrice)
+ private fun calculatePercentAndSetSliderValue(productPrice: Int, targetPrice: Int) {
+ setTargetPriceViewModel.setSliderChangeListenerEnabled(false)
+ binding.rsTargetPrice.setValue((targetPrice.toFloat() / productPrice.toFloat() * 100F).toInt())
+ setTargetPriceViewModel.setSliderChangeListenerEnabled(true)
}
private fun handleEvent() {
@@ -215,22 +206,17 @@ class SetTargetPriceFragment : Fragment() {
}
}
- private fun FragmentSetTargetPriceBinding.updateSlideValueWithPrice(targetPrice: Float) {
- val percent =
- ((targetPrice / setTargetPriceViewModel.state.value.productPrice) * MAX_PERCENT).toInt()
- val pricePercent = percent.coerceIn(MIN_PERCENT, MAX_PERCENT).roundAtFirstDigit()
- if (targetPrice > setTargetPriceViewModel.state.value.productPrice) {
- tvTargetPricePercent.text = getString(R.string.over_current_price)
- } else {
- tvTargetPricePercent.text =
- String.format(getString(R.string.current_price_percent), percent)
- }
- binding.tvTargetPricePercent.contentDescription = getString(
- R.string.target_price_percent_and_price,
- binding.tvTargetPricePercent.text,
- binding.tvSetPriceCurrentPrice.text
- )
- slTargetPrice.value = pricePercent.toFloat()
+ private fun showConfirmationDialogForResult() {
+ val tag = "set_target_price_dialog_fragment_from_fragment"
+ if (requireActivity().supportFragmentManager.findFragmentByTag(tag) != null) return
+
+ val dialogFragment = SetTargetPriceDialogFragment()
+ val bundle = Bundle()
+ bundle.putString("title", getString(R.string.set_target_price_dialog_title))
+ bundle.putInt("price", setTargetPriceViewModel.state.value.targetPrice)
+ dialogFragment.setOnDialogResultListener(this)
+ dialogFragment.arguments = bundle
+ dialogFragment.show(requireActivity().supportFragmentManager, tag)
}
override fun onDestroyView() {
@@ -238,8 +224,13 @@ class SetTargetPriceFragment : Fragment() {
_binding = null
}
+ override fun onDialogResult(result: Int) {
+ setTargetPriceViewModel.updateTargetPrice(result)
+ calculatePercentAndSetSliderValue(setTargetPriceViewModel.state.value.productPrice, result)
+ }
+
companion object {
- const val MIN_PERCENT = 0
- const val MAX_PERCENT = 100
+ const val STEP_SIZE = 10
+ const val MAX_PERCENT = 200
}
}
diff --git a/android/app/src/main/java/app/priceguard/ui/additem/setprice/SetTargetPriceViewModel.kt b/android/app/src/main/java/app/priceguard/ui/additem/setprice/SetTargetPriceViewModel.kt
index d837221..256656a 100644
--- a/android/app/src/main/java/app/priceguard/ui/additem/setprice/SetTargetPriceViewModel.kt
+++ b/android/app/src/main/java/app/priceguard/ui/additem/setprice/SetTargetPriceViewModel.kt
@@ -23,6 +23,7 @@ class SetTargetPriceViewModel @Inject constructor(private val productRepository:
val targetPrice: Int = 0,
val productName: String = "",
val productPrice: Int = 0,
+ val isEnabledSliderListener: Boolean = true,
val isReady: Boolean = true
)
@@ -62,6 +63,7 @@ class SetTargetPriceViewModel @Inject constructor(private val productRepository:
fun patchProduct() {
viewModelScope.launch {
+ _state.value = state.value.copy(isReady = false)
val response = productRepository.updateTargetPrice(
_state.value.productShop,
_state.value.productCode,
@@ -76,6 +78,7 @@ class SetTargetPriceViewModel @Inject constructor(private val productRepository:
_event.emit(SetTargetPriceEvent.FailurePriceUpdate(response.errorState))
}
}
+ _state.value = state.value.copy(isReady = true)
}
}
@@ -83,13 +86,24 @@ class SetTargetPriceViewModel @Inject constructor(private val productRepository:
_state.value = state.value.copy(targetPrice = price)
}
- fun setProductInfo(productShop: String, productCode: String, name: String, price: Int) {
+ fun updateTargetPriceFromPercent(percent: Int) {
+ _state.value = state.value.copy(
+ targetPrice = (_state.value.productPrice.toFloat() / 100F * percent.toFloat()).toInt()
+ )
+ }
+
+ fun setProductInfo(productShop: String, productCode: String, name: String, price: Int, targetPrice: Int) {
_state.value =
state.value.copy(
productShop = productShop,
productCode = productCode,
productName = name,
- productPrice = price
+ productPrice = price,
+ targetPrice = targetPrice
)
}
+
+ fun setSliderChangeListenerEnabled(isEnabled: Boolean) {
+ _state.value = state.value.copy(isEnabledSliderListener = isEnabled)
+ }
}
diff --git a/android/app/src/main/java/app/priceguard/ui/home/list/ProductListFragment.kt b/android/app/src/main/java/app/priceguard/ui/home/list/ProductListFragment.kt
index 59b4d71..f4a014b 100644
--- a/android/app/src/main/java/app/priceguard/ui/home/list/ProductListFragment.kt
+++ b/android/app/src/main/java/app/priceguard/ui/home/list/ProductListFragment.kt
@@ -81,8 +81,10 @@ class ProductListFragment : Fragment() {
val adapter = ProductSummaryAdapter(listener, ProductSummaryAdapter.userDiffUtil)
rvProductList.adapter = adapter
this@ProductListFragment.repeatOnStarted {
- productListViewModel.productList.collect { list ->
- adapter.submitList(list)
+ productListViewModel.state.collect { state ->
+ if (state.productList.isNotEmpty()) {
+ adapter.submitList(state.productList)
+ }
}
}
}
diff --git a/android/app/src/main/java/app/priceguard/ui/home/list/ProductListViewModel.kt b/android/app/src/main/java/app/priceguard/ui/home/list/ProductListViewModel.kt
index d1cdafc..bef5d8b 100644
--- a/android/app/src/main/java/app/priceguard/ui/home/list/ProductListViewModel.kt
+++ b/android/app/src/main/java/app/priceguard/ui/home/list/ProductListViewModel.kt
@@ -26,11 +26,14 @@ class ProductListViewModel @Inject constructor(
private val graphDataConverter: GraphDataConverter
) : ViewModel() {
- private var _isRefreshing: MutableStateFlow = MutableStateFlow(false)
- val isRefreshing: StateFlow = _isRefreshing.asStateFlow()
+ data class ProductListState(
+ val isRefreshing: Boolean = false,
+ val isUpdated: Boolean = false,
+ val productList: List = listOf()
+ )
- private var _productList = MutableStateFlow>(listOf())
- val productList: StateFlow> = _productList.asStateFlow()
+ private var _state: MutableStateFlow = MutableStateFlow(ProductListState())
+ val state: StateFlow = _state.asStateFlow()
private var _events = MutableSharedFlow()
val events: SharedFlow = _events.asSharedFlow()
@@ -38,12 +41,13 @@ class ProductListViewModel @Inject constructor(
fun getProductList(isRefresh: Boolean) {
viewModelScope.launch {
if (isRefresh) {
- _isRefreshing.value = true
+ _state.value = _state.value.copy(isRefreshing = true)
+ } else {
+ _state.value = _state.value.copy(isUpdated = false)
}
-
val result = productRepository.getProductList()
- _isRefreshing.value = false
+ _state.value = _state.value.copy(isRefreshing = false)
when (result) {
is RepositoryResult.Success -> {
@@ -54,36 +58,41 @@ class ProductListViewModel @Inject constructor(
_events.emit(result.errorState)
}
}
+ _state.value = _state.value.copy(isUpdated = true)
}
}
private fun updateProductList(refresh: Boolean, fetched: List) {
val productMap = mutableMapOf()
- _productList.value.forEach { product ->
+ state.value.productList.forEach { product ->
productMap[product.productCode] = product.isAlarmOn
}
- _productList.value = fetched.map { data ->
- UserProductSummary(
- data.shop,
- data.productName,
- data.price,
- data.productCode,
- graphDataConverter.packWithEdgeData(data.priceData, GraphMode.WEEK),
- calculateDiscountRate(data.targetPrice, data.price),
- if (refresh) productMap[data.productCode] ?: data.isAlert else data.isAlert
- )
- }
+ _state.value = _state.value.copy(
+ productList = fetched.map { data ->
+ UserProductSummary(
+ data.shop,
+ data.productName,
+ data.price,
+ data.productCode,
+ graphDataConverter.packWithEdgeData(data.priceData, GraphMode.WEEK),
+ calculateDiscountRate(data.targetPrice, data.price),
+ if (refresh) productMap[data.productCode] ?: data.isAlert else data.isAlert
+ )
+ }
+ )
}
fun updateProductAlarmToggle(productCode: String, checked: Boolean) {
- _productList.value = productList.value.mapIndexed { _, product ->
- if (product.productCode == productCode) {
- product.copy(isAlarmOn = checked)
- } else {
- product
+ _state.value = _state.value.copy(
+ productList = state.value.productList.mapIndexed { _, product ->
+ if (product.productCode == productCode) {
+ product.copy(isAlarmOn = checked)
+ } else {
+ product
+ }
}
- }
+ )
}
private fun calculateDiscountRate(targetPrice: Int, price: Int): Float {
diff --git a/android/app/src/main/java/app/priceguard/ui/home/mypage/DeleteAccountActivity.kt b/android/app/src/main/java/app/priceguard/ui/home/mypage/DeleteAccountActivity.kt
new file mode 100644
index 0000000..4a0c000
--- /dev/null
+++ b/android/app/src/main/java/app/priceguard/ui/home/mypage/DeleteAccountActivity.kt
@@ -0,0 +1,94 @@
+package app.priceguard.ui.home.mypage
+
+import android.content.Intent
+import android.os.Bundle
+import android.widget.Toast
+import androidx.activity.viewModels
+import androidx.appcompat.app.AppCompatActivity
+import app.priceguard.R
+import app.priceguard.databinding.ActivityDeleteAccountBinding
+import app.priceguard.ui.data.DialogConfirmAction
+import app.priceguard.ui.login.LoginActivity
+import app.priceguard.ui.util.SystemNavigationColorState
+import app.priceguard.ui.util.applySystemNavigationBarColor
+import app.priceguard.ui.util.lifecycle.repeatOnStarted
+import app.priceguard.ui.util.onThrottleClick
+import app.priceguard.ui.util.showConfirmDialog
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class DeleteAccountActivity : AppCompatActivity() {
+
+ private lateinit var binding: ActivityDeleteAccountBinding
+ private val deleteAccountViewModel: DeleteAccountViewModel by viewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ this.applySystemNavigationBarColor(SystemNavigationColorState.SURFACE)
+ binding = ActivityDeleteAccountBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ binding.viewModel = deleteAccountViewModel
+ binding.lifecycleOwner = this
+
+ deleteAccountViewModel.updateEmail(intent.getStringExtra("email") ?: "")
+
+ initView()
+ initCollector()
+ }
+
+ private fun initView() {
+ binding.mtDeleteAccountTopbar.setNavigationOnClickListener {
+ finish()
+ }
+
+ binding.btnDeleteAccount.onThrottleClick {
+ deleteAccountViewModel.deleteAccount()
+ }
+ }
+
+ private fun initCollector() {
+ repeatOnStarted {
+ deleteAccountViewModel.state.collect { state ->
+ binding.btnDeleteAccount.isEnabled = state.isDeleteEnabled
+ }
+ }
+
+ repeatOnStarted {
+ deleteAccountViewModel.event.collect { event ->
+ when (event) {
+ DeleteAccountViewModel.DeleteAccountEvent.Logout -> {
+ Toast.makeText(
+ this@DeleteAccountActivity,
+ getString(R.string.success_delete_account),
+ Toast.LENGTH_SHORT
+ ).show()
+ goBackToLoginActivity()
+ }
+ DeleteAccountViewModel.DeleteAccountEvent.WrongPassword -> {
+ showConfirmDialog(
+ getString(R.string.error_password),
+ getString(R.string.wrong_password),
+ DialogConfirmAction.NOTHING
+ )
+ }
+
+ DeleteAccountViewModel.DeleteAccountEvent.UndefinedError -> {
+ showConfirmDialog(
+ getString(R.string.error),
+ getString(R.string.undefined_error),
+ DialogConfirmAction.NOTHING
+ )
+ }
+ }
+ }
+ }
+ }
+
+ private fun goBackToLoginActivity() {
+ val intent = Intent(this, LoginActivity::class.java)
+ intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
+ startActivity(intent)
+ finish()
+ }
+}
diff --git a/android/app/src/main/java/app/priceguard/ui/home/mypage/DeleteAccountViewModel.kt b/android/app/src/main/java/app/priceguard/ui/home/mypage/DeleteAccountViewModel.kt
new file mode 100644
index 0000000..781913d
--- /dev/null
+++ b/android/app/src/main/java/app/priceguard/ui/home/mypage/DeleteAccountViewModel.kt
@@ -0,0 +1,82 @@
+package app.priceguard.ui.home.mypage
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import app.priceguard.data.repository.RepositoryResult
+import app.priceguard.data.repository.auth.AuthErrorState
+import app.priceguard.data.repository.auth.AuthRepository
+import app.priceguard.data.repository.token.TokenRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+
+@HiltViewModel
+class DeleteAccountViewModel @Inject constructor(
+ private val authRepository: AuthRepository,
+ private val tokenRepository: TokenRepository
+) : ViewModel() {
+
+ data class DeleteAccountState(
+ val email: String = "",
+ val password: String = "",
+ val isChecked: Boolean = false,
+ val isDeleteEnabled: Boolean = false
+ )
+
+ sealed class DeleteAccountEvent {
+ data object Logout : DeleteAccountEvent()
+ data object WrongPassword : DeleteAccountEvent()
+ data object UndefinedError : DeleteAccountEvent()
+ }
+
+ private val _state = MutableStateFlow(DeleteAccountState())
+ val state = _state.asStateFlow()
+
+ private val _event = MutableSharedFlow()
+ val event = _event.asSharedFlow()
+
+ fun deleteAccount() {
+ viewModelScope.launch {
+ when (val response = authRepository.deleteAccount(_state.value.email, _state.value.password)) {
+ is RepositoryResult.Error -> {
+ when (response.errorState) {
+ AuthErrorState.INVALID_REQUEST -> {
+ _event.emit(DeleteAccountEvent.WrongPassword)
+ }
+
+ else -> {
+ _event.emit(DeleteAccountEvent.UndefinedError)
+ }
+ }
+ }
+
+ is RepositoryResult.Success -> {
+ tokenRepository.clearTokens()
+ _event.emit(DeleteAccountEvent.Logout)
+ }
+ }
+ }
+ }
+
+ fun updateEmail(email: String) {
+ _state.value = _state.value.copy(email = email)
+ }
+
+ fun updatePassWord(password: String) {
+ _state.value = _state.value.copy(password = password)
+ updateDeleteEnabled()
+ }
+
+ fun updateChecked(isChecked: Boolean) {
+ _state.value = _state.value.copy(isChecked = isChecked)
+ updateDeleteEnabled()
+ }
+
+ private fun updateDeleteEnabled() {
+ _state.value = _state.value.copy(isDeleteEnabled = _state.value.password.isNotEmpty() && _state.value.isChecked)
+ }
+}
diff --git a/android/app/src/main/java/app/priceguard/ui/home/mypage/MyPageFragment.kt b/android/app/src/main/java/app/priceguard/ui/home/mypage/MyPageFragment.kt
index 77c0c7b..10ee66c 100644
--- a/android/app/src/main/java/app/priceguard/ui/home/mypage/MyPageFragment.kt
+++ b/android/app/src/main/java/app/priceguard/ui/home/mypage/MyPageFragment.kt
@@ -5,6 +5,7 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
+import androidx.annotation.StringRes
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
@@ -78,7 +79,18 @@ class MyPageFragment : Fragment(), ConfirmDialogFragment.OnDialogResultListener
}
Setting.LOGOUT -> {
- showConfirmationDialogForResult()
+ showConfirmationDialogForResult(
+ R.string.logout_confirm_title,
+ R.string.logout_confirm_message,
+ Setting.LOGOUT.ordinal
+ )
+ }
+
+ Setting.DELETE_ACCOUNT -> {
+ val intent =
+ Intent(requireActivity(), DeleteAccountActivity::class.java)
+ intent.putExtra("email", myPageViewModel.state.value.email)
+ startActivity(intent)
}
}
}
@@ -107,18 +119,27 @@ class MyPageFragment : Fragment(), ConfirmDialogFragment.OnDialogResultListener
Setting.LOGOUT,
ContextCompat.getDrawable(requireActivity(), R.drawable.ic_logout),
getString(R.string.logout)
+ ),
+ SettingItemInfo(
+ Setting.DELETE_ACCOUNT,
+ ContextCompat.getDrawable(requireActivity(), R.drawable.ic_close_red),
+ getString(R.string.delete_account)
)
)
}
- private fun showConfirmationDialogForResult() {
+ private fun showConfirmationDialogForResult(
+ @StringRes title: Int,
+ @StringRes message: Int,
+ requestCode: Int
+ ) {
val tag = "confirm_dialog_fragment_from_activity"
if (requireActivity().supportFragmentManager.findFragmentByTag(tag) != null) return
val dialogFragment = ConfirmDialogFragment()
val bundle = Bundle()
- bundle.putString("title", getString(R.string.logout_confirm_title))
- bundle.putString("message", getString(R.string.logout_confirm_message))
+ bundle.putString("title", getString(title))
+ bundle.putString("message", getString(message))
bundle.putString("actionString", DialogConfirmAction.CUSTOM.name)
dialogFragment.arguments = bundle
dialogFragment.setOnDialogResultListener(this)
diff --git a/android/app/src/main/java/app/priceguard/ui/home/mypage/MyPageSettingAdapter.kt b/android/app/src/main/java/app/priceguard/ui/home/mypage/MyPageSettingAdapter.kt
index a182807..7972d8a 100644
--- a/android/app/src/main/java/app/priceguard/ui/home/mypage/MyPageSettingAdapter.kt
+++ b/android/app/src/main/java/app/priceguard/ui/home/mypage/MyPageSettingAdapter.kt
@@ -1,5 +1,6 @@
package app.priceguard.ui.home.mypage
+import android.util.TypedValue
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
@@ -32,6 +33,12 @@ class MyPageSettingAdapter(
with(binding) {
settingItemInfo = item
listener = clickListener
+
+ if (item.id == Setting.DELETE_ACCOUNT) {
+ val typedValue = TypedValue()
+ root.context.theme.resolveAttribute(com.google.android.material.R.attr.colorError, typedValue, true)
+ tvMyPageItemTitle.setTextColor(typedValue.data)
+ }
}
}
}
diff --git a/android/app/src/main/java/app/priceguard/ui/home/mypage/MyPageViewModel.kt b/android/app/src/main/java/app/priceguard/ui/home/mypage/MyPageViewModel.kt
index 12e49dc..7811525 100644
--- a/android/app/src/main/java/app/priceguard/ui/home/mypage/MyPageViewModel.kt
+++ b/android/app/src/main/java/app/priceguard/ui/home/mypage/MyPageViewModel.kt
@@ -13,7 +13,7 @@ import kotlinx.coroutines.launch
@HiltViewModel
class MyPageViewModel @Inject constructor(
- val tokenRepository: TokenRepository
+ private val tokenRepository: TokenRepository
) : ViewModel() {
sealed class MyPageEvent {
diff --git a/android/app/src/main/java/app/priceguard/ui/home/mypage/SettingItemInfo.kt b/android/app/src/main/java/app/priceguard/ui/home/mypage/SettingItemInfo.kt
index 3b53bb6..6462bc3 100644
--- a/android/app/src/main/java/app/priceguard/ui/home/mypage/SettingItemInfo.kt
+++ b/android/app/src/main/java/app/priceguard/ui/home/mypage/SettingItemInfo.kt
@@ -12,5 +12,6 @@ enum class Setting {
NOTIFICATION,
THEME,
LICENSE,
- LOGOUT
+ LOGOUT,
+ DELETE_ACCOUNT
}
diff --git a/android/app/src/main/java/app/priceguard/ui/home/recommend/RecommendedProductFragment.kt b/android/app/src/main/java/app/priceguard/ui/home/recommend/RecommendedProductFragment.kt
index dff37c3..4908d18 100644
--- a/android/app/src/main/java/app/priceguard/ui/home/recommend/RecommendedProductFragment.kt
+++ b/android/app/src/main/java/app/priceguard/ui/home/recommend/RecommendedProductFragment.kt
@@ -70,8 +70,10 @@ class RecommendedProductFragment : Fragment() {
val adapter = ProductSummaryAdapter(listener, ProductSummaryAdapter.diffUtil)
rvRecommendedProduct.adapter = adapter
this@RecommendedProductFragment.repeatOnStarted {
- recommendedProductViewModel.recommendedProductList.collect { list ->
- adapter.submitList(list)
+ recommendedProductViewModel.state.collect { state ->
+ if (state.recommendedList.isNotEmpty()) {
+ adapter.submitList(state.recommendedList)
+ }
}
}
}
diff --git a/android/app/src/main/java/app/priceguard/ui/home/recommend/RecommendedProductViewModel.kt b/android/app/src/main/java/app/priceguard/ui/home/recommend/RecommendedProductViewModel.kt
index a3a9d34..5758579 100644
--- a/android/app/src/main/java/app/priceguard/ui/home/recommend/RecommendedProductViewModel.kt
+++ b/android/app/src/main/java/app/priceguard/ui/home/recommend/RecommendedProductViewModel.kt
@@ -24,13 +24,14 @@ class RecommendedProductViewModel @Inject constructor(
private val graphDataConverter: GraphDataConverter
) : ViewModel() {
- private var _isRefreshing = MutableStateFlow(false)
- val isRefreshing: StateFlow = _isRefreshing.asStateFlow()
+ data class RecommendedProductState(
+ val isRefreshing: Boolean = false,
+ val isUpdated: Boolean = false,
+ val recommendedList: List = listOf()
+ )
- private var _recommendedProductList =
- MutableStateFlow>(listOf())
- val recommendedProductList: StateFlow> =
- _recommendedProductList.asStateFlow()
+ private var _state: MutableStateFlow = MutableStateFlow(RecommendedProductState())
+ val state: StateFlow = _state.asStateFlow()
private var _events = MutableSharedFlow()
val events: SharedFlow = _events.asSharedFlow()
@@ -38,30 +39,35 @@ class RecommendedProductViewModel @Inject constructor(
fun getRecommendedProductList(isRefresh: Boolean) {
viewModelScope.launch {
if (isRefresh) {
- _isRefreshing.value = true
+ _state.value = _state.value.copy(isRefreshing = true)
+ } else {
+ _state.value = _state.value.copy(isUpdated = false)
}
-
val result = productRepository.getRecommendedProductList()
- _isRefreshing.value = false
+
+ _state.value = _state.value.copy(isRefreshing = false)
when (result) {
is RepositoryResult.Success -> {
- _recommendedProductList.value = result.data.map { data ->
- RecommendedProductSummary(
- data.shop,
- data.productName,
- data.price,
- data.productCode,
- graphDataConverter.packWithEdgeData(data.priceData, GraphMode.WEEK),
- data.rank
- )
- }
+ _state.value = _state.value.copy(
+ recommendedList = result.data.map { data ->
+ RecommendedProductSummary(
+ data.shop,
+ data.productName,
+ data.price,
+ data.productCode,
+ graphDataConverter.packWithEdgeData(data.priceData, GraphMode.WEEK),
+ data.rank
+ )
+ }
+ )
}
is RepositoryResult.Error -> {
_events.emit(result.errorState)
}
}
+ _state.value = _state.value.copy(isUpdated = true)
}
}
}
diff --git a/android/app/src/main/java/app/priceguard/ui/slider/ConvertUtil.kt b/android/app/src/main/java/app/priceguard/ui/slider/ConvertUtil.kt
new file mode 100644
index 0000000..e33a84d
--- /dev/null
+++ b/android/app/src/main/java/app/priceguard/ui/slider/ConvertUtil.kt
@@ -0,0 +1,45 @@
+package app.priceguard.ui.slider
+
+import android.content.Context
+import android.util.TypedValue
+
+data class Px(val value: Float)
+
+data class Dp(val value: Float)
+
+data class Sp(val value: Float)
+
+fun Dp.toPx(context: Context): Px {
+ val density = context.resources.displayMetrics.density
+ return Px(value * density)
+}
+
+fun Sp.toPx(context: Context): Px {
+ return Px(
+ TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_SP,
+ value,
+ context.resources.displayMetrics
+ )
+ )
+}
+
+fun Double.toRadian(): Double {
+ return this * Math.PI / 180
+}
+
+fun Float.toRadian(): Float {
+ return this * Math.PI.toFloat() / 180F
+}
+
+fun Int.toRadian(): Double {
+ return this * Math.PI / 180
+}
+
+fun Double.toDegree(): Double {
+ return this * 180 / Math.PI
+}
+
+fun Float.toDegree(): Float {
+ return this * 180F / Math.PI.toFloat()
+}
diff --git a/android/app/src/main/java/app/priceguard/ui/slider/RoundSlider.kt b/android/app/src/main/java/app/priceguard/ui/slider/RoundSlider.kt
new file mode 100644
index 0000000..b4bd91f
--- /dev/null
+++ b/android/app/src/main/java/app/priceguard/ui/slider/RoundSlider.kt
@@ -0,0 +1,413 @@
+package app.priceguard.ui.slider
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.graphics.Rect
+import android.graphics.RectF
+import android.util.AttributeSet
+import android.view.MotionEvent
+import android.view.View
+import app.priceguard.R
+import kotlin.math.abs
+import kotlin.math.atan
+import kotlin.math.cos
+import kotlin.math.min
+import kotlin.math.pow
+import kotlin.math.roundToInt
+import kotlin.math.sin
+import kotlin.math.sqrt
+
+class RoundSlider @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0,
+ defStyleRes: Int = 0
+) : View(context, attrs, defStyleAttr, defStyleRes) {
+
+ private var width = 0F
+ private var height = 0F
+
+ private val slideBarPaint = Paint()
+ private val activeSlideBarPaint = Paint()
+ private val controllerPaint = Paint()
+ private val sliderValuePaint = Paint()
+ private val axisCirclePaint = Paint()
+
+ // 슬라이더 컨트롤러 좌표
+ private var controllerPointX = 0F
+ private var controllerPointY = 0F
+
+ // 슬라이더 바의 중심 좌표 (호의 중점)
+ private var slideBarPointX = 0F
+ private var slideBarPointY = 0F
+
+ // 슬라이더 틱 간격
+ private var sliderValueStepSize = 0
+
+ private var sliderValueChangeListener: SliderValueChangeListener? = null
+
+ // 드래그 중인지, 드래그 중인 좌표가 슬라이더 뷰 안에 있는지 확인
+ private var isDraggingOnSlider = false
+
+ // 모드에 따른 슬라이더 바 및 활성화 색상 변경
+ private var sliderMode = RoundSliderState.ACTIVE
+
+ private val pi = Math.PI
+
+ var sliderValue = 0
+ private set(value) { // 슬라이더 value가 변경되면 리스너에 변경된 값과 함께 이벤트 보내기
+ if (field != value) {
+ field = value
+ handleValueChangeEvent(value)
+ }
+ }
+
+ private var slideBarStrokeWidth = Dp(8F).toPx(context).value
+
+ private var slideBarRadius = 0F
+ private var controllerRadius = Dp(12F).toPx(context).value
+
+ private var slideBarMargin = Dp(12F).toPx(context).value + controllerRadius
+
+ private var maxPercentValue = 100
+
+ // 하이라이트된 슬라이더 바를 그리기 위한 시작과 끝 각도
+ private var startDegree = 0F
+ private var endDegree = 0F
+
+ private var textValueSize = Sp(32F)
+
+ private val touchSizeMargin = Dp(8F).toPx(context).value
+
+ private var isTouchedOnSlideBar = false
+
+ // 현재 sliderValue값으로 위치해야하는 컨트롤러 좌표에 대한 라디안 값
+ private var currentRad = pi
+ set(value) {
+ field = value.coerceIn(0.0..pi)
+ updateValueWithRadian(field)
+ updateControllerPointWithRadian(field)
+ invalidate()
+ }
+
+ private var colorPrimary: Int
+ private var colorOnPrimaryContainer: Int
+ private var colorSurfaceVariant: Int
+ private var colorError: Int
+
+ init {
+ val typedArray = context.obtainStyledAttributes(
+ attrs,
+ R.styleable.RoundSlider,
+ defStyleAttr,
+ 0
+ )
+
+ colorPrimary = typedArray.getColor(
+ R.styleable.RoundSlider_colorPrimary,
+ Color.BLUE
+ )
+
+ colorOnPrimaryContainer = typedArray.getColor(
+ R.styleable.RoundSlider_colorOnPrimaryContainer,
+ Color.BLACK
+ )
+
+ colorSurfaceVariant = typedArray.getColor(
+ R.styleable.RoundSlider_colorSurfaceVariant,
+ Color.GRAY
+ )
+
+ colorError = typedArray.getColor(
+ R.styleable.RoundSlider_colorError,
+ Color.RED
+ )
+
+ typedArray.recycle()
+ }
+
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec)
+
+ // wrap_content인지 match_parent인지 고정된 값인지 확인
+ val viewWidthMode = MeasureSpec.getMode(widthMeasureSpec)
+ val viewHeightMode = MeasureSpec.getMode(heightMeasureSpec)
+
+ // 값이 있으면 설정된 값 할당 없으면 최대 값 할당
+ val viewWidthSize = MeasureSpec.getSize(widthMeasureSpec)
+ val viewHeightSize = MeasureSpec.getSize(heightMeasureSpec)
+
+ width = if (viewWidthMode == MeasureSpec.EXACTLY) {
+ viewWidthSize.toFloat()
+ } else if (viewHeightMode == MeasureSpec.EXACTLY) { // height만 크기 값이 설정된 경우 height에 맞게 width 설정
+ (viewHeightSize.toFloat() - controllerRadius - slideBarMargin) * 2
+ } else { // width, height 모두 wrap_content와 같이 설정된 크기 값이 없을 경우 700으로 고정
+ 700F
+ }
+ // width의 크기가 최대 크기보다 작거나 같아야 함
+ width = min(width, viewWidthSize.toFloat())
+
+ height = if (viewHeightMode == MeasureSpec.EXACTLY) {
+ viewHeightSize.toFloat()
+ } else {
+ width / 2 + slideBarMargin
+ }
+ height = min(height, viewHeightSize.toFloat())
+
+ setMeasuredDimension(width.toInt(), height.toInt())
+
+ // 높이가 필요한 크기보다 클 경우 크기 줄이기 (중앙 정렬을 위함)
+ if (viewHeightMode == MeasureSpec.EXACTLY) {
+ val temp = width / 2 + slideBarMargin
+ if (height > temp) {
+ height -= (height - temp) / 2
+ }
+ }
+ }
+
+ override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
+ super.onSizeChanged(w, h, oldw, oldh)
+
+ slideBarPointX = width / 2
+ slideBarPointY = height - slideBarMargin
+
+ slideBarRadius = if (isHeightEnough()) { // 높이가 충분히 높을 경우 너비에 맞게 슬라이더 크기 설정
+ width / 2 - slideBarMargin
+ } else { // 높이가 충분히 높지 않을 경우 높이에 맞게 슬라이더 크기 설정
+ height - slideBarMargin * 2
+ }
+
+ // 슬라이더 value에 맞게 라디안 설정
+ currentRad = valueToDegree(sliderValue).toRadian()
+ }
+
+ override fun onDraw(canvas: Canvas) {
+ super.onDraw(canvas)
+
+ drawSlideBar(canvas)
+ drawSliderBarTick(canvas)
+ drawController(canvas)
+ drawSlideValueText(canvas)
+ }
+
+ override fun onTouchEvent(event: MotionEvent?): Boolean {
+ if (event != null) {
+ when (event.action) {
+ MotionEvent.ACTION_DOWN -> {
+ isTouchedOnSlideBar = isTouchOnSlideBar(event.x, event.y)
+ if (!isTouchedOnSlideBar) {
+ parent.requestDisallowInterceptTouchEvent(false)
+ } else {
+ parent.requestDisallowInterceptTouchEvent(true)
+ }
+ }
+
+ MotionEvent.ACTION_MOVE -> {
+ if (!isTouchedOnSlideBar) return true
+
+ if (event.y > slideBarPointY) { // 드래그 좌표가 슬라이더를 벗어난 경우 value를 x좌표에 맞게 최소 or 최대 값으로 설정
+ if (isDraggingOnSlider) {
+ isDraggingOnSlider = false
+ currentRad = if (event.x >= slideBarPointX) 0.0 else pi
+ }
+ } else {
+ isDraggingOnSlider = true
+ updateRadianWithTouchPoint(event.x, event.y)
+ }
+ }
+
+ MotionEvent.ACTION_UP -> {
+ parent.requestDisallowInterceptTouchEvent(false)
+ isDraggingOnSlider = false
+ }
+ }
+ }
+ return true
+ }
+
+ fun setValue(value: Int) {
+ currentRad = (180 / maxPercentValue.toDouble() * (maxPercentValue - value)).toRadian()
+ sliderValue = value
+ }
+
+ fun setMaxPercentValue(value: Int) {
+ maxPercentValue = value
+ invalidate()
+ }
+
+ fun setHighlightRange(startValue: Int, endValue: Int) {
+ startDegree = (180F / maxPercentValue * startValue) + 180
+ endDegree = (180F / maxPercentValue * endValue) + 180
+ invalidate()
+ }
+
+ fun setSliderValueChangeListener(listener: SliderValueChangeListener) {
+ sliderValueChangeListener = listener
+ }
+
+ fun setSliderMode(mode: RoundSliderState) {
+ sliderMode = mode
+ }
+
+ fun setStepSize(step: Int) {
+ if (maxPercentValue.mod(step) != 0) return
+ sliderValueStepSize = step
+ invalidate()
+ }
+
+ private fun drawSlideBar(canvas: Canvas) {
+ slideBarPaint.style = Paint.Style.STROKE
+ slideBarPaint.strokeWidth = slideBarStrokeWidth
+ slideBarPaint.color = colorSurfaceVariant
+
+ activeSlideBarPaint.style = Paint.Style.STROKE
+ activeSlideBarPaint.strokeWidth = slideBarStrokeWidth
+ activeSlideBarPaint.color = when (sliderMode) {
+ RoundSliderState.ACTIVE -> colorPrimary
+ RoundSliderState.INACTIVE -> colorSurfaceVariant
+ RoundSliderState.ERROR -> colorError
+ }
+
+ val oval = RectF()
+ oval.set(
+ slideBarPointX - slideBarRadius,
+ slideBarPointY - slideBarRadius,
+ slideBarPointX + slideBarRadius,
+ slideBarPointY + slideBarRadius
+ )
+
+ canvas.drawArc(oval, 180F, 180F, false, slideBarPaint)
+ drawHighlightSlider(canvas)
+ canvas.drawArc(
+ oval,
+ 180F,
+ 180 - currentRad.toDegree().toFloat(),
+ false,
+ activeSlideBarPaint
+ )
+ }
+
+ private fun drawHighlightSlider(canvas: Canvas) {
+ slideBarPaint.color = Color.parseColor("#FFD7F3")
+ slideBarPaint.alpha
+
+ val oval = RectF()
+ oval.set(
+ slideBarPointX - slideBarRadius,
+ slideBarPointY - slideBarRadius,
+ slideBarPointX + slideBarRadius,
+ slideBarPointY + slideBarRadius
+ )
+
+ canvas.drawArc(oval, startDegree, endDegree - startDegree, false, slideBarPaint)
+ }
+
+ private fun drawSliderBarTick(canvas: Canvas) {
+ axisCirclePaint.color = colorOnPrimaryContainer
+
+ // stepSize에 맞게 틱 그리기
+ for (i in sliderValueStepSize until maxPercentValue step sliderValueStepSize) {
+ val rad = valueToDegree(i).toRadian()
+ val tickPointX = slideBarPointX + cos(rad).toFloat() * slideBarRadius
+ val tickPointY = slideBarPointY - sin(rad).toFloat() * slideBarRadius
+
+ canvas.drawCircle(tickPointX, tickPointY, Dp(2F).toPx(context).value, axisCirclePaint)
+ }
+ }
+
+ private fun drawController(canvas: Canvas) {
+ controllerPaint.style = Paint.Style.FILL
+ controllerPaint.color = when (sliderMode) {
+ RoundSliderState.ACTIVE -> colorPrimary
+ RoundSliderState.INACTIVE -> colorSurfaceVariant
+ RoundSliderState.ERROR -> colorError
+ }
+
+ canvas.drawCircle(controllerPointX, controllerPointY, controllerRadius, controllerPaint)
+ }
+
+ private fun drawSlideValueText(canvas: Canvas) {
+ sliderValuePaint.textSize = textValueSize.toPx(context).value
+ sliderValuePaint.color = when (sliderMode) {
+ RoundSliderState.ACTIVE -> colorOnPrimaryContainer
+ RoundSliderState.INACTIVE -> colorSurfaceVariant
+ RoundSliderState.ERROR -> colorError
+ }
+ val bounds = Rect()
+ val textString = "$sliderValue%"
+ sliderValuePaint.getTextBounds(textString, 0, textString.length, bounds)
+ val textWidth = bounds.width()
+ val textHeight = bounds.height()
+
+ canvas.drawText(
+ textString,
+ slideBarPointX - textWidth / 2,
+ slideBarPointY - slideBarRadius / 2 + textHeight,
+ sliderValuePaint
+ )
+ }
+
+ private fun isTouchOnSlideBar(x: Float, y: Float): Boolean {
+ val deltaX = abs(slideBarPointX - x)
+ val deltaY = abs(slideBarPointY - y)
+
+ val distance = sqrt(deltaX.pow(2) + deltaY.pow(2))
+
+ val minDistance = slideBarRadius - slideBarStrokeWidth - touchSizeMargin
+ val maxDistance = slideBarRadius + slideBarStrokeWidth + touchSizeMargin
+
+ return distance in minDistance..maxDistance
+ }
+
+ private fun updateControllerPointWithRadian(rad: Double) {
+ controllerPointX = slideBarPointX + cos(rad).toFloat() * slideBarRadius
+ controllerPointY = slideBarPointY - sin(rad).toFloat() * slideBarRadius
+ }
+
+ private fun updateValueWithRadian(rad: Double) {
+ sliderValue = degreeToValue(rad.toDegree())
+ }
+
+ private fun calculateRadianWithPoint(x: Float, y: Float): Double {
+ val arcTanRadian = atan((x - slideBarPointX) / (y - slideBarPointY))
+ return (pi / 2) + arcTanRadian
+ }
+
+ private fun updateRadianWithTouchPoint(x: Float, y: Float) {
+ val rad = calculateRadianWithPoint(x, y)
+ currentRad = findCloseStepValueRadian(degreeToValue(rad.toDegree()))
+ }
+
+ private fun isHeightEnough() = height >= width / 2 + slideBarMargin
+
+ private fun degreeToValue(degree: Double) =
+ (((180F - degree) * maxPercentValue / 180F)).roundToInt()
+
+ private fun valueToDegree(value: Int) =
+ (180 - ((180 * value.toDouble()) / maxPercentValue))
+
+ private fun findCloseStepValueRadian(currentValue: Int): Double {
+ var minDifference = maxPercentValue
+ var prevDifference = maxPercentValue
+
+ for (value in 0..maxPercentValue step sliderValueStepSize) {
+ val difference = abs(currentValue - value)
+
+ if (difference > prevDifference) { // difference 증가할 경우 이전 radian 반환
+ return valueToDegree(value - sliderValueStepSize).toRadian()
+ }
+
+ if (difference < minDifference) {
+ minDifference = difference
+ }
+ prevDifference = difference
+ }
+ return 0.0
+ }
+
+ private fun handleValueChangeEvent(value: Int) {
+ sliderValueChangeListener?.invoke(value)
+ }
+}
diff --git a/android/app/src/main/java/app/priceguard/ui/slider/RoundSliderState.kt b/android/app/src/main/java/app/priceguard/ui/slider/RoundSliderState.kt
new file mode 100644
index 0000000..1d18dc5
--- /dev/null
+++ b/android/app/src/main/java/app/priceguard/ui/slider/RoundSliderState.kt
@@ -0,0 +1,7 @@
+package app.priceguard.ui.slider
+
+enum class RoundSliderState {
+ ACTIVE,
+ INACTIVE,
+ ERROR
+}
diff --git a/android/app/src/main/java/app/priceguard/ui/slider/SliderValueChangeListener.kt b/android/app/src/main/java/app/priceguard/ui/slider/SliderValueChangeListener.kt
new file mode 100644
index 0000000..a41ba6f
--- /dev/null
+++ b/android/app/src/main/java/app/priceguard/ui/slider/SliderValueChangeListener.kt
@@ -0,0 +1,3 @@
+package app.priceguard.ui.slider
+
+internal typealias SliderValueChangeListener = (value: Int) -> Unit
diff --git a/android/app/src/main/java/app/priceguard/ui/util/Dialog.kt b/android/app/src/main/java/app/priceguard/ui/util/Dialog.kt
index b1f3e18..7b3de7e 100644
--- a/android/app/src/main/java/app/priceguard/ui/util/Dialog.kt
+++ b/android/app/src/main/java/app/priceguard/ui/util/Dialog.kt
@@ -27,7 +27,7 @@ fun Fragment.showDialogWithAction(
message: String,
action: DialogConfirmAction = DialogConfirmAction.NOTHING
) {
- val tag = "confirm_dialog_fragment_from_activity"
+ val tag = "confirm_dialog_fragment_from_fragment"
if (requireActivity().supportFragmentManager.findFragmentByTag(tag) != null) return
val dialogFragment = ConfirmDialogFragment()
@@ -40,11 +40,11 @@ fun Fragment.showDialogWithAction(
}
fun AppCompatActivity.showDialogWithLogout() {
- val tag = "error_dialog_fragment_from_fragment"
+ val tag = "error_dialog_fragment_from_activity"
if (supportFragmentManager.findFragmentByTag(tag) != null) return
val dialogFragment = ErrorDialogFragment()
- dialogFragment.show(supportFragmentManager, "error_dialog_fragment_from_activity")
+ dialogFragment.show(supportFragmentManager, tag)
}
fun Fragment.showDialogWithLogout() {
diff --git a/android/app/src/main/java/app/priceguard/ui/util/ThrottleClickListener.kt b/android/app/src/main/java/app/priceguard/ui/util/ThrottleClickListener.kt
new file mode 100644
index 0000000..9e40024
--- /dev/null
+++ b/android/app/src/main/java/app/priceguard/ui/util/ThrottleClickListener.kt
@@ -0,0 +1,28 @@
+package app.priceguard.ui.util
+
+import android.view.View
+
+class OnThrottleClickListener(
+ private val onClickListener: View.OnClickListener,
+ private val interval: Long = 500L
+) : View.OnClickListener {
+
+ private var clickable = true
+
+ override fun onClick(v: View?) {
+ if (clickable) {
+ clickable = false
+ v?.run {
+ postDelayed({
+ clickable = true
+ }, interval)
+ onClickListener.onClick(v)
+ }
+ }
+ }
+}
+
+fun View.onThrottleClick(action: (v: View) -> Unit) {
+ val listener = View.OnClickListener { action(it) }
+ setOnClickListener(OnThrottleClickListener(listener))
+}
diff --git a/android/app/src/main/java/app/priceguard/ui/util/ViewExtensions.kt b/android/app/src/main/java/app/priceguard/ui/util/ViewExtensions.kt
new file mode 100644
index 0000000..4083979
--- /dev/null
+++ b/android/app/src/main/java/app/priceguard/ui/util/ViewExtensions.kt
@@ -0,0 +1,16 @@
+package app.priceguard.ui.util
+
+import android.util.TypedValue
+import android.widget.Button
+
+fun Button.setTextColorWithEnabled() {
+ val value = TypedValue()
+
+ val color = if (!isEnabled) {
+ com.google.android.material.R.attr.colorOutline
+ } else {
+ com.google.android.material.R.attr.colorPrimary
+ }
+ context?.theme?.resolveAttribute(color, value, true)
+ setTextColor(value.data)
+}
diff --git a/android/app/src/main/java/app/priceguard/ui/util/drawable/ProgressIndicator.kt b/android/app/src/main/java/app/priceguard/ui/util/drawable/ProgressIndicator.kt
index af168d8..5a1dc86 100644
--- a/android/app/src/main/java/app/priceguard/ui/util/drawable/ProgressIndicator.kt
+++ b/android/app/src/main/java/app/priceguard/ui/util/drawable/ProgressIndicator.kt
@@ -5,12 +5,12 @@ import app.priceguard.R
import com.google.android.material.progressindicator.CircularProgressIndicatorSpec
import com.google.android.material.progressindicator.IndeterminateDrawable
-fun getCircularProgressIndicatorDrawable(context: Context): IndeterminateDrawable {
+fun getCircularProgressIndicatorDrawable(context: Context, style: Int = R.style.Theme_PriceGuard_CircularProgressIndicator): IndeterminateDrawable {
val spec = CircularProgressIndicatorSpec(
context,
null,
0,
- R.style.Theme_PriceGuard_CircularProgressIndicator
+ style
)
return IndeterminateDrawable.createCircularDrawable(context, spec)
diff --git a/android/app/src/main/res/drawable/ic_close_red.xml b/android/app/src/main/res/drawable/ic_close_red.xml
new file mode 100644
index 0000000..ea5e3a4
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_close_red.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_naver_logo.xml b/android/app/src/main/res/drawable/ic_naver_logo.xml
index e3f7427..b39eb8a 100644
--- a/android/app/src/main/res/drawable/ic_naver_logo.xml
+++ b/android/app/src/main/res/drawable/ic_naver_logo.xml
@@ -1,8 +1,8 @@
-
-
-
-
-
+ android:viewportWidth="391.3" android:width="62.608dp" xmlns:android="http://schemas.android.com/apk/res/android">
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_remove.xml b/android/app/src/main/res/drawable/ic_remove.xml
new file mode 100644
index 0000000..46c12d3
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_remove.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/android/app/src/main/res/layout/activity_delete_account.xml b/android/app/src/main/res/layout/activity_delete_account.xml
new file mode 100644
index 0000000..33003a5
--- /dev/null
+++ b/android/app/src/main/res/layout/activity_delete_account.xml
@@ -0,0 +1,151 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/layout/fragment_product_list.xml b/android/app/src/main/res/layout/fragment_product_list.xml
index 137cf48..a20e167 100644
--- a/android/app/src/main/res/layout/fragment_product_list.xml
+++ b/android/app/src/main/res/layout/fragment_product_list.xml
@@ -6,6 +6,8 @@
+
+
@@ -16,7 +18,7 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
app:onRefreshListener="@{() -> viewModel.getProductList(true)}"
- app:refreshing="@{viewModel.isRefreshing}">
+ app:refreshing="@{viewModel.state.refreshing}">
+
+
+
+
+
+
+
+
+
+
@@ -16,7 +18,7 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
app:onRefreshListener="@{() -> viewModel.getRecommendedProductList(true)}"
- app:refreshing="@{viewModel.isRefreshing}"
+ app:refreshing="@{viewModel.state.refreshing}"
tools:context=".ui.home.recommend.RecommendedProductFragment">
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/fragment_set_target_price.xml b/android/app/src/main/res/layout/fragment_set_target_price.xml
index af211be..18b08ad 100644
--- a/android/app/src/main/res/layout/fragment_set_target_price.xml
+++ b/android/app/src/main/res/layout/fragment_set_target_price.xml
@@ -38,7 +38,7 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="96dp"
- android:labelFor="@+id/et_target_price"
+ android:labelFor="@id/rs_target_price"
android:text="@string/set_price_title"
android:textAppearance="?attr/textAppearanceHeadlineMedium"
app:layout_constraintStart_toEndOf="@id/gl_vertical_start"
@@ -67,47 +67,49 @@
app:layout_constraintEnd_toStartOf="@id/gl_vertical_end"
app:layout_constraintTop_toTopOf="@id/tv_set_price_name" />
-
-
+ app:layout_constraintTop_toBottomOf="@id/rs_target_price" />
+
+
+ app:layout_constraintTop_toBottomOf="@id/btn_target_price_decrease" />