diff --git a/app/build.gradle b/app/build.gradle index 165c5da6..34dcdd46 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -3,6 +3,7 @@ plugins { id 'org.jetbrains.kotlin.android' id 'com.google.gms.google-services' id 'kotlin-kapt' + id 'kotlin-parcelize' id 'dagger.hilt.android.plugin' id 'androidx.navigation.safeargs.kotlin' } @@ -74,6 +75,8 @@ dependencies { // Glide implementation 'com.github.bumptech.glide:glide:4.13.0' kapt 'com.github.bumptech.glide:compiler:4.13.0' + // time 라이브러리 백포트 + implementation 'com.jakewharton.threetenabp:threetenabp:1.3.0' implementation 'androidx.core:core-ktx:1.7.0' implementation 'androidx.appcompat:appcompat:1.4.1' implementation 'com.google.android.material:material:1.6.0' diff --git a/app/src/main/kotlin/com/fakedevelopers/bidderbidder/MainActivity.kt b/app/src/main/kotlin/com/fakedevelopers/bidderbidder/MainActivity.kt index 2a258702..16b7279c 100644 --- a/app/src/main/kotlin/com/fakedevelopers/bidderbidder/MainActivity.kt +++ b/app/src/main/kotlin/com/fakedevelopers/bidderbidder/MainActivity.kt @@ -6,6 +6,7 @@ import androidx.appcompat.app.AppCompatActivity import androidx.navigation.NavController import androidx.navigation.fragment.NavHostFragment import com.fakedevelopers.bidderbidder.databinding.ActivityMainBinding +import com.jakewharton.threetenabp.AndroidThreeTen import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint @@ -19,6 +20,7 @@ class MainActivity : AppCompatActivity() { super.onCreate(savedInstanceState) _binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) + AndroidThreeTen.init(this) navController = (supportFragmentManager.findFragmentById(R.id.navigation_main) as NavHostFragment).navController navController.addOnDestinationChangedListener { _, destination, _ -> when (destination.id) { @@ -37,7 +39,6 @@ class MainActivity : AppCompatActivity() { } true } - // navController.navigate(R.id.productRegistrationFragment) } override fun onDestroy() { diff --git a/app/src/main/kotlin/com/fakedevelopers/bidderbidder/api/service/ProductRegistrationService.kt b/app/src/main/kotlin/com/fakedevelopers/bidderbidder/api/service/ProductRegistrationService.kt index c39443fa..bd2d7481 100644 --- a/app/src/main/kotlin/com/fakedevelopers/bidderbidder/api/service/ProductRegistrationService.kt +++ b/app/src/main/kotlin/com/fakedevelopers/bidderbidder/api/service/ProductRegistrationService.kt @@ -10,7 +10,7 @@ import retrofit2.http.PartMap interface ProductRegistrationService { @Multipart - @POST("board/write") + @POST("product/write") suspend fun postProductRegistration( @Part files: List, @PartMap params: HashMap diff --git a/app/src/main/kotlin/com/fakedevelopers/bidderbidder/ui/product_list/ProductListAdapter.kt b/app/src/main/kotlin/com/fakedevelopers/bidderbidder/ui/product_list/ProductListAdapter.kt index deb29605..326fbab4 100644 --- a/app/src/main/kotlin/com/fakedevelopers/bidderbidder/ui/product_list/ProductListAdapter.kt +++ b/app/src/main/kotlin/com/fakedevelopers/bidderbidder/ui/product_list/ProductListAdapter.kt @@ -11,13 +11,13 @@ import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.Glide import com.fakedevelopers.bidderbidder.R import com.fakedevelopers.bidderbidder.api.data.Constants.Companion.BASE_URL +import com.fakedevelopers.bidderbidder.api.data.Constants.Companion.dec import com.fakedevelopers.bidderbidder.databinding.RecyclerProductListBinding import com.fakedevelopers.bidderbidder.databinding.RecyclerProductListFooterBinding import com.fakedevelopers.bidderbidder.ui.product_list.ProductListViewModel.Companion.LIST_COUNT -import java.text.DecimalFormat import java.text.SimpleDateFormat import java.util.Date -import java.util.Locale +import java.util.TimeZone class ProductListAdapter( private val onClick: () -> Unit, @@ -26,7 +26,9 @@ class ProductListAdapter( ) : ListAdapter(diffUtil) { private var listSize = 0 - private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) + private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm").apply { + timeZone = TimeZone.getTimeZone("Asia/Seoul") + } inner class ItemViewHolder( private val binding: RecyclerProductListBinding, @@ -38,8 +40,7 @@ class ProductListAdapter( if (::timerTask.isInitialized) { timerTask.cancel() } - val timer = dateFormat.parse(item.expirationDate)!!.time - Date(System.currentTimeMillis()).time - timerTask = object : CountDownTimer(timer, 1000) { + timerTask = object : CountDownTimer(getRemainTimeMillisecond(item.expirationDate), 1000) { override fun onTick(millisUntilFinished: Long) { textviewProductListExpire.text = getRemainTimeString(millisUntilFinished) } @@ -66,6 +67,10 @@ class ProductListAdapter( textviewProductListParticipant.text = if (item.bidderCount != 0) "${item.bidderCount}명 입찰" else "" } } + + private fun getRemainTimeMillisecond(expirationDate: String) = + dateFormat.parse(expirationDate)!!.time - dateFormat.parse(dateFormat.format(Date()))!!.time + private fun getRemainTimeString(millisUntilFinished: Long): String { val totalMinute = millisUntilFinished / 60000 val day = totalMinute / 1440 @@ -81,7 +86,7 @@ class ProductListAdapter( } // 분, 초 if (day == 0L && hour < 3) { - val minute = totalMinute % 1440 % 60 + val minute = totalMinute % 60 if (minute != 0L) { remainTimeString.append("${minute}분 ") } @@ -149,8 +154,6 @@ class ProductListAdapter( const val TYPE_ITEM = 1 const val TYPE_FOOTER = 2 - val dec = DecimalFormat("#,###") - val diffUtil = object : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: ProductListDto, newItem: ProductListDto) = oldItem.productId == newItem.productId diff --git a/app/src/main/kotlin/com/fakedevelopers/bidderbidder/ui/product_registration/PriceTextWatcher.kt b/app/src/main/kotlin/com/fakedevelopers/bidderbidder/ui/product_registration/PriceTextWatcher.kt new file mode 100644 index 00000000..30b2ee0c --- /dev/null +++ b/app/src/main/kotlin/com/fakedevelopers/bidderbidder/ui/product_registration/PriceTextWatcher.kt @@ -0,0 +1,39 @@ +package com.fakedevelopers.bidderbidder.ui.product_registration + +import android.text.Editable +import android.text.Selection +import android.text.TextUtils +import android.text.TextWatcher +import android.widget.EditText +import com.fakedevelopers.bidderbidder.api.data.Constants.Companion.dec + +class PriceTextWatcher( + private val editText: EditText, + private val checkCondition: () -> Unit +) : TextWatcher { + + private var strAmount = "" + + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { + // 안해! + } + + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { + if (!TextUtils.isEmpty(s.toString()) && s.toString() != strAmount) { + strAmount = makeComma(s.toString()) + editText.setText(strAmount) + Selection.setSelection(editText.text, strAmount.length) + } + } + + override fun afterTextChanged(s: Editable?) { + checkCondition() + } + + private fun makeComma(price: String): String { + price.replace(",", "").toLongOrNull()?.let { + return dec.format(it) + } + return "" + } +} diff --git a/app/src/main/kotlin/com/fakedevelopers/bidderbidder/ui/product_registration/ProductRegistrationDto.kt b/app/src/main/kotlin/com/fakedevelopers/bidderbidder/ui/product_registration/ProductRegistrationDto.kt new file mode 100644 index 00000000..cee97df7 --- /dev/null +++ b/app/src/main/kotlin/com/fakedevelopers/bidderbidder/ui/product_registration/ProductRegistrationDto.kt @@ -0,0 +1,15 @@ +package com.fakedevelopers.bidderbidder.ui.product_registration + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class ProductRegistrationDto( + var urlList: List, + val title: String, + val hopePrice: String, + val openingBid: String, + val tick: String, + val expiration: String, + val content: String +) : Parcelable diff --git a/app/src/main/kotlin/com/fakedevelopers/bidderbidder/ui/product_registration/ProductRegistrationFragment.kt b/app/src/main/kotlin/com/fakedevelopers/bidderbidder/ui/product_registration/ProductRegistrationFragment.kt index 65ca7a56..fc1db781 100644 --- a/app/src/main/kotlin/com/fakedevelopers/bidderbidder/ui/product_registration/ProductRegistrationFragment.kt +++ b/app/src/main/kotlin/com/fakedevelopers/bidderbidder/ui/product_registration/ProductRegistrationFragment.kt @@ -2,12 +2,18 @@ package com.fakedevelopers.bidderbidder.ui.product_registration import android.Manifest import android.content.ContentResolver +import android.graphics.Color import android.net.Uri import android.os.Bundle import android.provider.OpenableColumns +import android.text.Editable +import android.text.InputFilter +import android.text.TextWatcher import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.EditText import android.widget.Toast import androidx.activity.OnBackPressedCallback import androidx.activity.result.ActivityResultLauncher @@ -17,14 +23,19 @@ import androidx.core.content.PermissionChecker.checkCallingOrSelfPermission import androidx.databinding.DataBindingUtil import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import androidx.recyclerview.widget.ItemTouchHelper import com.fakedevelopers.bidderbidder.R import com.fakedevelopers.bidderbidder.databinding.FragmentProductRegistrationBinding +import com.fakedevelopers.bidderbidder.ui.util.KeyboardVisibilityUtils import com.orhanobut.logger.Logger import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch import okhttp3.MediaType import okhttp3.MediaType.Companion.toMediaType import okhttp3.MultipartBody @@ -35,13 +46,13 @@ import okio.source @AndroidEntryPoint class ProductRegistrationFragment : Fragment() { + private lateinit var keyboardVisibilityUtils: KeyboardVisibilityUtils private lateinit var permissionLauncher: ActivityResultLauncher private var _binding: FragmentProductRegistrationBinding? = null private val binding get() = _binding!! private val viewModel: ProductRegistrationViewModel by viewModels() - private val backPressedCallback by lazy { object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { @@ -68,13 +79,32 @@ class ProductRegistrationFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val args: ProductRegistrationFragmentArgs by navArgs() - if (!args.selectedImageList.isNullOrEmpty()) { - viewModel.setImageList(args.selectedImageList!!.toList()) - ItemTouchHelper(DragAndDropCallback(viewModel.adapter)) - .attachToRecyclerView(binding.recyclerProductRegistration) + args.productRegistrationDto?.let { + viewModel.initState(it) + if (it.urlList.isNotEmpty()) { + ItemTouchHelper(DragAndDropCallback(viewModel.adapter)) + .attachToRecyclerView(binding.recyclerProductRegistration) + } + } + val arrayAdapter = object : ArrayAdapter( + requireContext(), + R.layout.spinner_product_registration, + viewModel.category + ) { + override fun getCount(): Int { + return super.getCount() - 1 + } + } + binding.spinnerProductRegistrationCategory.apply { + adapter = arrayAdapter + setSelection(arrayAdapter.count) } initListener() initCollector() + } + + override fun onStart() { + super.onStart() requireActivity().onBackPressedDispatcher.addCallback(backPressedCallback) } @@ -84,9 +114,7 @@ class ProductRegistrationFragment : Fragment() { if (permissionCheck == PermissionChecker.PERMISSION_GRANTED) { findNavController().navigate( ProductRegistrationFragmentDirections - .actionProductRegistrationFragmentToPictureSelectFragment( - viewModel.urlList.value.toTypedArray() - ) + .actionProductRegistrationFragmentToPictureSelectFragment(viewModel.getProductRegistrationDto()) ) } else { permissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE) @@ -94,17 +122,85 @@ class ProductRegistrationFragment : Fragment() { } private fun initListener() { + // 가격 필터 등록 + initEditTextFilter(binding.edittextProductRegistrationHopePrice, MAX_PRICE_LENGTH) + initEditTextFilter(binding.edittextProductRegistrationOpeningBid, MAX_PRICE_LENGTH) + initEditTextFilter(binding.edittextProductRegistrationTick, MAX_TICK_LENGTH) + val expirationFilter = InputFilter { source, _, _, _, dstart, _ -> + if (source == "0" && dstart == 0) "" else source.replace(IS_NOT_NUMBER.toRegex(), "") + } + // 만료 시간 필터 등록 + binding.edittextProductRegistrationExpiration.apply { + addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { + // 안써! + } + + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { + s.toString().replace(IS_NOT_NUMBER.toRegex(), "").toIntOrNull()?.let { + if (it > MAX_EXPIRATION_TIME) { + binding.edittextProductRegistrationExpiration.apply { + setText(MAX_EXPIRATION_TIME.toString()) + setSelection(text.length) + } + } else if (it.toString().length != text.length) { + setText(it.toString()) + setSelection(text.length) + } + it + } + } + + override fun afterTextChanged(s: Editable?) { + viewModel.checkRegistrationCondition() + } + }) + filters = arrayOf(expirationFilter, InputFilter.LengthFilter(MAX_EXPIRATION_LENGTH)) + } // 사진 가져오기 binding.imageviewSelectPicture.setOnClickListener { toPictureSelectFragment() } // 게시글 작성 요청 - binding.button2.setOnClickListener { - // viewModel.productRegistrationRequest() - Toast.makeText(requireContext(), "지금은 안돼", Toast.LENGTH_SHORT).show() + binding.includeProductRegistrationToolbar.buttonToolbarRegistration.setOnClickListener { + if (viewModel.condition.value && checkPriceCondition()) { + val list = mutableListOf() + viewModel.urlList.value.forEach { uri -> + getMultipart(Uri.parse(uri), requireActivity().contentResolver)?.let { it1 -> list.add(it1) } + } + viewModel.productRegistrationRequest(list) + } + } + // 키보드 이벤트 + keyboardVisibilityUtils = KeyboardVisibilityUtils( + requireActivity().window, + onHideKeyboard = { + viewModel.setContentLengthVisibility(false) + } + ) + // 본문 에딧텍스트 터치, 포커싱 + binding.edittextProductRegistrationContent.apply { + setOnClickListener { + viewModel.setContentLengthVisibility(true) + } + setOnFocusChangeListener { _, hasFocus -> + viewModel.setContentLengthVisibility(hasFocus) + } + } + // 툴바 뒤로가기 버튼 + binding.includeProductRegistrationToolbar.buttonToolbarBack.setOnClickListener { + requireActivity().onBackPressed() } } + private fun initEditTextFilter(editText: EditText, length: Int) { + val priceFilter = InputFilter { source, _, _, _, _, _ -> + source.replace("[^(0-9|,)]".toRegex(), "") + } + editText.filters = arrayOf(priceFilter, InputFilter.LengthFilter(length)) + editText.addTextChangedListener(PriceTextWatcher(editText) { viewModel.checkRegistrationCondition() }) + } + private fun initResultLauncher() { permissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> @@ -117,15 +213,67 @@ class ProductRegistrationFragment : Fragment() { } private fun initCollector() { - lifecycleScope.launchWhenStarted { - viewModel.productRegistrationResponse.collect { - if (!it.isSuccessful) { - Logger.t("myImage").e(it.errorBody().toString()) + // 등록 요청 api + lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.productRegistrationResponse.collectLatest { + if (it.isSuccessful) { + findNavController().navigate(R.id.action_productRegistrationFragment_to_productListFragment) + } else { + Logger.t("myImage").e(it.errorBody().toString()) + } + } + } + } + // 본문 + lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.content.collectLatest { + binding.textviewProductRegistrationContentLength.apply { + text = "${it.length} / $MAX_CONTENT_LENGTH" + val color = if (it.length == MAX_CONTENT_LENGTH) Color.RED else Color.GRAY + setTextColor(color) + } + } + } + } + // 홈 화면 이동 시 글자 수 textView의 visible 처리 + lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.contentLengthVisible.collect { + if (binding.edittextProductRegistrationContent.isFocused != viewModel.contentLengthVisible.value) { + viewModel.setContentLengthVisibility(true) + } + } + } + } + // 등록 버튼 + lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.condition.collectLatest { + val color = if (it) Color.BLACK else Color.GRAY + binding.includeProductRegistrationToolbar.buttonToolbarRegistration.setTextColor(color) } } } } + // 희망가 <= 최소 입찰가 인지 검사 + private fun checkPriceCondition(): Boolean { + runCatching { + Pair( + viewModel.openingBid.value.replace(IS_NOT_NUMBER.toRegex(), "").toLong(), + viewModel.hopePrice.value.replace(IS_NOT_NUMBER.toRegex(), "").toLong() + ) + }.onSuccess { + if (it.first >= it.second) { + Toast.makeText(requireContext(), "최소 입찰가는 희망 가격보다 작아야 합니다.", Toast.LENGTH_SHORT).show() + return false + } + } + return true + } + private fun getMultipart(uri: Uri, contentResolver: ContentResolver): MultipartBody.Part? { return contentResolver.query(uri, null, null, null, null)?.let { if (it.moveToNext()) { @@ -153,5 +301,15 @@ class ProductRegistrationFragment : Fragment() { super.onDestroyView() _binding = null backPressedCallback.remove() + keyboardVisibilityUtils.deleteKeyboardListeners() + } + + companion object { + const val MAX_PRICE_LENGTH = 17 + const val MAX_TICK_LENGTH = 12 + const val MAX_CONTENT_LENGTH = 1000 + const val MAX_EXPIRATION_TIME = 72 + const val MAX_EXPIRATION_LENGTH = 3 + const val IS_NOT_NUMBER = "[^0-9]" } } diff --git a/app/src/main/kotlin/com/fakedevelopers/bidderbidder/ui/product_registration/ProductRegistrationViewModel.kt b/app/src/main/kotlin/com/fakedevelopers/bidderbidder/ui/product_registration/ProductRegistrationViewModel.kt index 84e84412..78b37389 100644 --- a/app/src/main/kotlin/com/fakedevelopers/bidderbidder/ui/product_registration/ProductRegistrationViewModel.kt +++ b/app/src/main/kotlin/com/fakedevelopers/bidderbidder/ui/product_registration/ProductRegistrationViewModel.kt @@ -2,6 +2,7 @@ package com.fakedevelopers.bidderbidder.ui.product_registration import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.fakedevelopers.bidderbidder.api.data.Constants.Companion.dateFormatter import com.fakedevelopers.bidderbidder.api.repository.ProductRegistrationRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableSharedFlow @@ -10,8 +11,12 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody import okhttp3.RequestBody import okhttp3.RequestBody.Companion.toRequestBody +import org.threeten.bp.Instant +import org.threeten.bp.LocalDateTime +import org.threeten.bp.ZoneId import retrofit2.Response import java.util.Collections import javax.inject.Inject @@ -27,17 +32,34 @@ class ProductRegistrationViewModel @Inject constructor( ) { fromPosition, toPosition -> swapSelectedImage(fromPosition, toPosition) } - - // private val imageList = mutableListOf() private val _urlList = MutableStateFlow>(mutableListOf()) private val _productRegistrationResponse = MutableSharedFlow>() + private val _condition = MutableStateFlow(false) + private val _contentLengthVisible = MutableStateFlow(false) val urlList: StateFlow> get() = _urlList + val contentLengthVisible: StateFlow get() = _contentLengthVisible val productRegistrationResponse: SharedFlow> get() = _productRegistrationResponse + val title = MutableStateFlow("") + val content = MutableStateFlow("") + val hopePrice = MutableStateFlow("") + val openingBid = MutableStateFlow("") + val tick = MutableStateFlow("") + val expiration = MutableStateFlow("") + // 카테고리 목록을 받아오는 api 필요 + val category = mutableListOf( + "카테고리를", + "받아오는", + "api가", + "필요함", + "카테고리 선택" + ) + // 등록 조건 완료 + val condition: StateFlow get() = _condition private fun deleteSelectedImage(uri: String) { _urlList.value.remove(uri) - adapter.submitList(_urlList.value.toList()) + adapter.submitList(_urlList.value.toMutableList()) // 사진이 삭제 된다면 다음 사진에게 대표직을 물려줌 if (_urlList.value.isNotEmpty()) { adapter.notifyItemChanged(1) @@ -54,28 +76,81 @@ class ProductRegistrationViewModel @Inject constructor( Collections.swap(_urlList.value, i, i - 1) } } - adapter.notifyItemMoved(fromPosition, toPosition) + adapter.submitList(_urlList.value.toMutableList()) } private fun findSelectedImageIndex(uri: String) = _urlList.value.indexOf(uri) - fun productRegistrationRequest() { + // 게시글 등록 조건 검사 + fun checkRegistrationCondition() { + viewModelScope.launch { + // 희망가, 호가, 만료 시간은 0이 되면 안됨 + if (hopePrice.value == "0") { + hopePrice.emit("") + } + if (tick.value == "0") { + tick.emit("") + } + if (expiration.value == "0") { + expiration.emit("") + } + _condition.emit( + title.value.isNotEmpty() && + (hopePrice.value.isEmpty() || hopePrice.value.replace(",", "").toLongOrNull() != null) && + openingBid.value.replace(",", "").toLongOrNull() != null && + tick.value.replace(",", "").toIntOrNull() != null && + expiration.value.toIntOrNull() != null && + content.value.isNotEmpty() + ) + } + } + + fun productRegistrationRequest(imageList: List) { viewModelScope.launch { + val date = LocalDateTime.ofInstant( + Instant.ofEpochMilli(System.currentTimeMillis() + expiration.value.toInt() * 3600000), + ZoneId.of("Asia/Seoul") + ) val map = hashMapOf() - map["board_content"] = "콘텐트 내용".toPlainRequestBody() - map["board_title"] = "제목".toPlainRequestBody() - map["category"] = "1".toPlainRequestBody() - map["end_date"] = "2030-01-01 07:07".toPlainRequestBody() - map["hope_price"] = "100".toPlainRequestBody() - map["opening_bid"] = "50".toPlainRequestBody() - map["tick"] = "1".toPlainRequestBody() - // _productRegistrationResponse.emit(repository.postProductRegistration(imageList, map)) + map["productContent"] = content.value.toPlainRequestBody() + map["productTitle"] = title.value.toPlainRequestBody() + map["category"] = "0".toPlainRequestBody() + map["expirationDate"] = dateFormatter.format(date).toPlainRequestBody() + map["hopePrice"] = hopePrice.value.replace(",", "").toPlainRequestBody() + map["openingBid"] = openingBid.value.replace(",", "").toPlainRequestBody() + map["representPicture"] = "0".toPlainRequestBody() + map["tick"] = tick.value.replace(",", "").toPlainRequestBody() + _productRegistrationResponse.emit(repository.postProductRegistration(imageList, map)) } } - fun setImageList(url: List) { - _urlList.value.addAll(url) - adapter.submitList(_urlList.value.toList()) + fun initState(state: ProductRegistrationDto) { + viewModelScope.launch { + _urlList.emit(state.urlList.toMutableList()) + adapter.submitList(_urlList.value.toMutableList()) + title.emit(state.title) + hopePrice.emit(state.hopePrice) + openingBid.emit(state.openingBid) + tick.emit(state.tick) + expiration.emit(state.expiration) + content.emit(state.content) + } + } + + fun getProductRegistrationDto() = ProductRegistrationDto( + urlList.value, + title.value, + hopePrice.value, + openingBid.value, + tick.value, + expiration.value, + content.value + ) + + fun setContentLengthVisibility(state: Boolean) { + viewModelScope.launch { + _contentLengthVisible.emit(state) + } } private fun String?.toPlainRequestBody() = requireNotNull(this).toRequestBody("text/plain".toMediaTypeOrNull()) diff --git a/app/src/main/kotlin/com/fakedevelopers/bidderbidder/ui/product_registration/SelectedPictureListAdapter.kt b/app/src/main/kotlin/com/fakedevelopers/bidderbidder/ui/product_registration/SelectedPictureListAdapter.kt index 98df538a..95637a96 100644 --- a/app/src/main/kotlin/com/fakedevelopers/bidderbidder/ui/product_registration/SelectedPictureListAdapter.kt +++ b/app/src/main/kotlin/com/fakedevelopers/bidderbidder/ui/product_registration/SelectedPictureListAdapter.kt @@ -14,8 +14,8 @@ import com.fakedevelopers.bidderbidder.databinding.RecyclerProductRegistrationBi class SelectedPictureListAdapter( private val deleteSelectedImage: (String) -> Unit, private val findSelectedImageIndex: (String) -> Int, - private val swapComplete: () -> Unit = {}, - private val swapSelectedImage: (Int, Int) -> Unit = { _, _ -> } + private val swapComplete: (() -> Unit)? = null, + private val swapSelectedImage: ((Int, Int) -> Unit)? = null ) : ListAdapter(diffUtil) { inner class ViewHolder( @@ -58,11 +58,11 @@ class SelectedPictureListAdapter( } fun onItemDragMove(fromPosition: Int, toPosition: Int) { - swapSelectedImage(fromPosition, toPosition) + swapSelectedImage?.invoke(fromPosition, toPosition) } fun changeMoveEvent() { - swapComplete() + swapComplete?.invoke() } companion object { diff --git a/app/src/main/kotlin/com/fakedevelopers/bidderbidder/ui/product_registration/album_list/AlbumListAdapter.kt b/app/src/main/kotlin/com/fakedevelopers/bidderbidder/ui/product_registration/album_list/AlbumListAdapter.kt index 8eef7b1e..84e95716 100644 --- a/app/src/main/kotlin/com/fakedevelopers/bidderbidder/ui/product_registration/album_list/AlbumListAdapter.kt +++ b/app/src/main/kotlin/com/fakedevelopers/bidderbidder/ui/product_registration/album_list/AlbumListAdapter.kt @@ -1,6 +1,7 @@ package com.fakedevelopers.bidderbidder.ui.product_registration.album_list import android.content.Context +import android.graphics.drawable.Drawable import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -8,12 +9,17 @@ import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.Glide +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.engine.GlideException +import com.bumptech.glide.request.RequestListener +import com.bumptech.glide.request.target.Target import com.fakedevelopers.bidderbidder.R import com.fakedevelopers.bidderbidder.databinding.RecyclerPictureSelectBinding class AlbumListAdapter( private val findSelectedImageIndex: (String) -> Int, private val setScrollFlag: () -> Unit, + private val sendErrorToast: () -> Unit, private val setSelectedImageList: (String, Boolean) -> Unit ) : ListAdapter(diffUtil) { @@ -21,12 +27,35 @@ class AlbumListAdapter( private val binding: RecyclerPictureSelectBinding, private val context: Context ) : RecyclerView.ViewHolder(binding.root) { + private var isErrorImage = false fun bind(item: String) { binding.imageviewPictureSelect.let { image -> Glide.with(context) .load(item) .placeholder(R.drawable.the_cat) .error(R.drawable.error_cat) + .listener(object : RequestListener { + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target?, + isFirstResource: Boolean + ): Boolean { + isErrorImage = true + return false + } + + override fun onResourceReady( + resource: Drawable?, + model: Any?, + target: Target?, + dataSource: DataSource?, + isFirstResource: Boolean + ): Boolean { + isErrorImage = false + return false + } + }) .into(image) // 선택된 사진 리스트에 현재 item이 포함되어 있다면 표시해줍니다. @@ -40,14 +69,18 @@ class AlbumListAdapter( } image.setOnClickListener { - val visibility = - if (binding.backgroundPictrueSelect.visibility == View.VISIBLE) - View.INVISIBLE - else - View.VISIBLE - binding.backgroundPictrueSelect.visibility = visibility - binding.textviewPictureSelectCount.visibility = visibility - setSelectedImageList(item, visibility == View.VISIBLE) + if (!isErrorImage) { + val visibility = + if (binding.backgroundPictrueSelect.visibility == View.VISIBLE) + View.INVISIBLE + else + View.VISIBLE + binding.backgroundPictrueSelect.visibility = visibility + binding.textviewPictureSelectCount.visibility = visibility + setSelectedImageList(item, visibility == View.VISIBLE) + } else { + sendErrorToast() + } } } } diff --git a/app/src/main/kotlin/com/fakedevelopers/bidderbidder/ui/product_registration/album_list/AlbumListFragment.kt b/app/src/main/kotlin/com/fakedevelopers/bidderbidder/ui/product_registration/album_list/AlbumListFragment.kt index 6da3be1e..bb039ac0 100644 --- a/app/src/main/kotlin/com/fakedevelopers/bidderbidder/ui/product_registration/album_list/AlbumListFragment.kt +++ b/app/src/main/kotlin/com/fakedevelopers/bidderbidder/ui/product_registration/album_list/AlbumListFragment.kt @@ -8,6 +8,7 @@ import android.view.View import android.view.ViewGroup import android.widget.AdapterView import android.widget.ArrayAdapter +import android.widget.Toast import androidx.activity.OnBackPressedCallback import androidx.databinding.DataBindingUtil import androidx.fragment.app.Fragment @@ -23,6 +24,7 @@ import androidx.recyclerview.widget.RecyclerView import com.fakedevelopers.bidderbidder.R import com.fakedevelopers.bidderbidder.databinding.FragmentAlbumListBinding import com.fakedevelopers.bidderbidder.ui.product_registration.DragAndDropCallback +import com.fakedevelopers.bidderbidder.ui.product_registration.ProductRegistrationDto import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @@ -32,16 +34,12 @@ class AlbumListFragment : Fragment() { private val viewModel: AlbumListViewModel by viewModels() private val binding get() = _binding!! + private val args: AlbumListFragmentArgs by navArgs() private val backPressedCallback by lazy { object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { - findNavController().navigate( - AlbumListFragmentDirections - .actionPictureSelectFragmentToProductRegistrationFragment( - viewModel.selectedImageList.value.toTypedArray() - ) - ) + toProductRegistration(args.productRegistrationDto) } } } @@ -64,25 +62,33 @@ class AlbumListFragment : Fragment() { super.onViewCreated(view, savedInstanceState) initCollector() getPictures() - val args: AlbumListFragmentArgs by navArgs() - if (!args.selectedImageList.isNullOrEmpty()) { - viewModel.initSelectedImageList(args.selectedImageList!!.toList()) + if (args.productRegistrationDto.urlList.isNotEmpty()) { + viewModel.initSelectedImageList(args.productRegistrationDto.urlList) binding.buttonAlbumListComplete.visibility = View.VISIBLE } binding.buttonAlbumListComplete.setOnClickListener { - // 선택한 이미지 uri를 들고 돌아갑니다 - findNavController().navigate( - AlbumListFragmentDirections - .actionPictureSelectFragmentToProductRegistrationFragment( - viewModel.selectedImageList.value.toTypedArray() - ) - ) + toProductRegistration(args.productRegistrationDto) } - requireActivity().onBackPressedDispatcher.addCallback(backPressedCallback) ItemTouchHelper(DragAndDropCallback(viewModel.selectedPictureAdapter)) .attachToRecyclerView(binding.recyclerSelectedPicture) } + override fun onStart() { + super.onStart() + requireActivity().onBackPressedDispatcher.addCallback(backPressedCallback) + } + + private fun toProductRegistration(dto: ProductRegistrationDto) { + dto.urlList = viewModel.selectedImageList.value.toList() + // 선택한 이미지 uri를 들고 돌아갑니다 + findNavController().navigate( + AlbumListFragmentDirections + .actionPictureSelectFragmentToProductRegistrationFragment( + dto + ) + ) + } + private fun getPictures() { val uri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI requireActivity().contentResolver.query( @@ -164,6 +170,17 @@ class AlbumListFragment : Fragment() { } } } + lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.selectErrorImage.collectLatest { + Toast.makeText( + requireContext(), + getText(R.string.album_selected_error_image), + Toast.LENGTH_SHORT + ).show() + } + } + } } companion object { diff --git a/app/src/main/kotlin/com/fakedevelopers/bidderbidder/ui/product_registration/album_list/AlbumListViewModel.kt b/app/src/main/kotlin/com/fakedevelopers/bidderbidder/ui/product_registration/album_list/AlbumListViewModel.kt index 19054f55..30882297 100644 --- a/app/src/main/kotlin/com/fakedevelopers/bidderbidder/ui/product_registration/album_list/AlbumListViewModel.kt +++ b/app/src/main/kotlin/com/fakedevelopers/bidderbidder/ui/product_registration/album_list/AlbumListViewModel.kt @@ -16,20 +16,23 @@ class AlbumListViewModel : ViewModel() { private val currentAlbum = MutableStateFlow("") private val _selectedImageList = MutableStateFlow>(mutableListOf()) private val _onListChange = MutableSharedFlow() + private val _selectErrorImage = MutableSharedFlow() private lateinit var allImages: Map> val selectedImageList: StateFlow> get() = _selectedImageList val onListChange: SharedFlow get() = _onListChange + val selectErrorImage: SharedFlow get() = _selectErrorImage val albumListAdapter = AlbumListAdapter( findSelectedImageIndex = { findSelectedImageIndex(it) }, - setScrollFlag = { setScrollFlag() } + setScrollFlag = { setScrollFlag() }, + sendErrorToast = { viewModelScope.launch { _selectErrorImage.emit(true) } } ) { uri, state -> setSelectedState(uri, state) } val selectedPictureAdapter = SelectedPictureListAdapter( deleteSelectedImage = { setSelectedState(it) }, findSelectedImageIndex = { findSelectedImageIndex(it) }, - swapComplete = { setAlbumList() } + swapComplete = { swapComplete() } ) { fromPosition, toPosition -> swapSelectedImage(fromPosition, toPosition) } @@ -66,8 +69,12 @@ class AlbumListViewModel : ViewModel() { scrollToTopFlag = !scrollToTopFlag } + private fun swapComplete() { + setAlbumList() + } + private fun setSelectedImageList() { - selectedPictureAdapter.submitList(_selectedImageList.value.toList()) + selectedPictureAdapter.submitList(_selectedImageList.value.toMutableList()) setAlbumList() viewModelScope.launch { _onListChange.emit(true) @@ -84,7 +91,7 @@ class AlbumListViewModel : ViewModel() { Collections.swap(_selectedImageList.value, i, i - 1) } } - selectedPictureAdapter.notifyItemMoved(fromPosition, toPosition) + selectedPictureAdapter.submitList(_selectedImageList.value.toMutableList()) } private fun findSelectedImageIndex(uri: String) = _selectedImageList.value.indexOf(uri) diff --git a/app/src/main/kotlin/com/fakedevelopers/bidderbidder/ui/register/RegisterFragment.kt b/app/src/main/kotlin/com/fakedevelopers/bidderbidder/ui/register/RegisterFragment.kt index b313d0c1..01d80502 100644 --- a/app/src/main/kotlin/com/fakedevelopers/bidderbidder/ui/register/RegisterFragment.kt +++ b/app/src/main/kotlin/com/fakedevelopers/bidderbidder/ui/register/RegisterFragment.kt @@ -69,6 +69,11 @@ class RegisterFragment : Fragment() { initCollector() } + override fun onStart() { + super.onStart() + requireActivity().onBackPressedDispatcher.addCallback(backPressedCallback) + } + private fun initListener() { val now = Calendar.getInstance(Locale.getDefault()) val mYear = now.get(Calendar.YEAR) @@ -93,8 +98,6 @@ class RegisterFragment : Fragment() { findNavController().navigate(R.id.action_registerFragment_to_mainFragment) } } - - requireActivity().onBackPressedDispatcher.addCallback(backPressedCallback) } private fun initCollector() { diff --git a/app/src/main/kotlin/com/fakedevelopers/bidderbidder/ui/util/KeyboardVisibilityUtils.kt b/app/src/main/kotlin/com/fakedevelopers/bidderbidder/ui/util/KeyboardVisibilityUtils.kt new file mode 100644 index 00000000..9ca7c89a --- /dev/null +++ b/app/src/main/kotlin/com/fakedevelopers/bidderbidder/ui/util/KeyboardVisibilityUtils.kt @@ -0,0 +1,42 @@ +package com.fakedevelopers.bidderbidder.ui.util + +import android.graphics.Rect +import android.view.ViewTreeObserver +import android.view.Window + +class KeyboardVisibilityUtils( + private val window: Window, + private val onShowKeyboard: (() -> Unit)? = null, + private val onHideKeyboard: (() -> Unit)? = null +) { + private val windowVisibleDisplayFrame = Rect() + private var lastVisibleDecorViewHeight = 0 + + private val onGlobalLayoutListener = ViewTreeObserver.OnGlobalLayoutListener { + window.decorView.getWindowVisibleDisplayFrame(windowVisibleDisplayFrame) + val visibleDecorViewHeight = windowVisibleDisplayFrame.height() + + if (lastVisibleDecorViewHeight != 0) { + if (lastVisibleDecorViewHeight > visibleDecorViewHeight + MIN_KEYBOARD_HEIGHT_PX) { + // 키보드 열림 + onShowKeyboard?.invoke() + } else if (lastVisibleDecorViewHeight + MIN_KEYBOARD_HEIGHT_PX < visibleDecorViewHeight) { + // 키보드 닫힘 + onHideKeyboard?.invoke() + } + } + lastVisibleDecorViewHeight = visibleDecorViewHeight + } + + init { + window.decorView.viewTreeObserver.addOnGlobalLayoutListener(onGlobalLayoutListener) + } + + fun deleteKeyboardListeners() { + window.decorView.viewTreeObserver.removeOnGlobalLayoutListener(onGlobalLayoutListener) + } + + companion object { + const val MIN_KEYBOARD_HEIGHT_PX = 100 + } +} diff --git a/app/src/main/res/drawable/shape_product_registration_divider.xml b/app/src/main/res/drawable/shape_product_registration_divider.xml new file mode 100644 index 00000000..a9cc7223 --- /dev/null +++ b/app/src/main/res/drawable/shape_product_registration_divider.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/layout/fragment_album_list.xml b/app/src/main/res/layout/fragment_album_list.xml index 07f380e4..9db30688 100644 --- a/app/src/main/res/layout/fragment_album_list.xml +++ b/app/src/main/res/layout/fragment_album_list.xml @@ -53,7 +53,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" - android:text="완료" + android:text="@string/album_selected_button_complete" android:visibility="invisible" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" diff --git a/app/src/main/res/layout/fragment_product_list.xml b/app/src/main/res/layout/fragment_product_list.xml index d8eb956d..02df5e0d 100644 --- a/app/src/main/res/layout/fragment_product_list.xml +++ b/app/src/main/res/layout/fragment_product_list.xml @@ -23,7 +23,7 @@ app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" - app:menu="@menu/toolbar_menu"/> + app:menu="@menu/toolbar_product_list_menu"/> + + - - + app:layout_constraintTop_toBottomOf="@+id/include_product_registration_toolbar"> - - + android:layout_margin="8dp" + android:divider="@drawable/shape_product_registration_divider" + android:orientation="vertical" + android:showDividers="middle"> + + + + + + + + + + -