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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +