diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 535f19044..22aa4834f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -128,6 +128,7 @@ dependencies { implementation(projects.core.security) implementation(projects.core.webview) + implementation(projects.domain.attendance) implementation(projects.domain.soptamp) implementation(projects.domain.mypage) implementation(projects.domain.poke) @@ -136,6 +137,7 @@ dependencies { implementation(projects.domain.home) implementation(projects.domain.auth) + implementation(projects.data.attendance) implementation(projects.data.fortune) implementation(projects.data.soptamp) implementation(projects.data.mypage) @@ -144,6 +146,7 @@ dependencies { implementation(projects.data.home) implementation(projects.data.auth) + implementation(projects.feature.attendance) implementation(projects.feature.soptamp) implementation(projects.data.schedule) implementation(projects.data.soptlog) diff --git a/app/src/main/java/org/sopt/official/feature/attendance/AttendanceActivity.kt b/app/src/main/java/org/sopt/official/feature/attendance/AttendanceActivity.kt deleted file mode 100644 index d63cda2f7..000000000 --- a/app/src/main/java/org/sopt/official/feature/attendance/AttendanceActivity.kt +++ /dev/null @@ -1,357 +0,0 @@ -/* - * MIT License - * Copyright 2023-2024 SOPT - Shout Our Passion Together - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ -package org.sopt.official.feature.attendance - -import android.annotation.SuppressLint -import android.content.Context -import android.content.Intent -import android.graphics.Rect -import android.graphics.Typeface -import android.os.Bundle -import android.text.Spannable -import android.text.Spanned -import android.text.style.StyleSpan -import android.view.View -import android.view.ViewGroup -import android.view.animation.AnimationUtils -import android.widget.TextView -import android.widget.Toast -import androidx.activity.viewModels -import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.isInvisible -import androidx.core.view.isVisible -import androidx.lifecycle.flowWithLifecycle -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.RecyclerView -import com.airbnb.deeplinkdispatch.DeepLink -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import org.sopt.official.R -import org.sopt.official.common.util.colorOf -import org.sopt.official.common.util.dp -import org.sopt.official.common.util.stringOf -import org.sopt.official.common.view.toast -import org.sopt.official.databinding.ActivityAttendanceBinding -import org.sopt.official.domain.entity.attendance.AttendanceLog -import org.sopt.official.domain.entity.attendance.AttendanceStatus -import org.sopt.official.domain.entity.attendance.AttendanceSummary -import org.sopt.official.domain.entity.attendance.AttendanceUserInfo -import org.sopt.official.domain.entity.attendance.EventType -import org.sopt.official.domain.entity.attendance.SoptEvent -import org.sopt.official.feature.attendance.adapter.AttendanceAdapter -import org.sopt.official.feature.attendance.model.AttendanceState -import org.sopt.official.type.SoptColors - -@AndroidEntryPoint -@DeepLink("sopt://attendance") -class AttendanceActivity : AppCompatActivity() { - private lateinit var binding: ActivityAttendanceBinding - private val viewModel by viewModels() - private lateinit var attendanceAdapter: AttendanceAdapter - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - binding = ActivityAttendanceBinding.inflate(layoutInflater) - setContentView(binding.root) - - initView() - initUiInteraction() - initListener() - observeData() - observeProgressState() - } - - private fun observeProgressState() { - viewModel.isFirstToSecondLineActive.observe(this) { - binding.lineFirstToSecondActive.isInvisible = !it - } - viewModel.isSecondToThirdLineActive.observe(this) { - binding.lineSecondToThirdActive.isInvisible = !it - } - viewModel.isFirstProgressBarAttendance.observe(this) { - binding.ivAttendanceProgress1Check.setImageResource( - if (it) R.drawable.ic_attendance_check_gray else R.drawable.ic_attendance_close_gray - ) - } - viewModel.isFirstProgressBarActive.observe(this) { - binding.ivAttendanceProgress1Check.isInvisible = !it - binding.tvAttendanceProgress1.setTextColor( - if (it) colorOf(SoptColors.mds_gray_10) else colorOf(SoptColors.mds_gray_500) - ) - } - viewModel.isSecondProgressBarAttendance.observe(this) { - binding.ivAttendanceProgress2Check.setImageResource( - if (it) R.drawable.ic_attendance_check_gray else R.drawable.ic_attendance_close_gray - ) - } - viewModel.isSecondProgressBarActive.observe(this) { - binding.ivAttendanceProgress2Check.isInvisible = !it - binding.tvAttendanceProgress2.setTextColor( - if (it) colorOf(SoptColors.mds_gray_10) else colorOf(SoptColors.mds_gray_500) - ) - } - viewModel.isThirdProgressBarVisible.observe(this) { - binding.ivAttendanceProgress3Tardy.isInvisible = !it - binding.ivAttendanceProgress3Attendance.isInvisible = it - binding.tvAttendanceProgress3.text = stringOf( - if (it) R.string.attendance_progress_third_tardy else R.string.attendance_progress_third_complete - ) - } - viewModel.isThirdProgressBarActive.observe(this) { - binding.ivAttendanceProgress3Empty.isInvisible = it - binding.tvAttendanceProgress3Attendance.text = stringOf( - if (it) R.string.attendance_progress_third_absent else R.string.attendance_progress_before - ) - binding.tvAttendanceProgress3Attendance.setTextColor( - if (it) colorOf(SoptColors.mds_gray_10) else colorOf(SoptColors.mds_gray_500) - ) - } - viewModel.isThirdProgressBarBeforeAttendance.observe(this) { - binding.ivAttendanceProgress3Attendance.setImageResource( - if (it) R.drawable.ic_attendacne_check_white else R.drawable.ic_attendance_close_white - ) - } - viewModel.isThirdProgressBarActiveAndBeforeAttendance.observe(this) { - binding.tvAttendanceProgress3.isInvisible = !it - binding.tvAttendanceProgress3Attendance.isInvisible = it - } - viewModel.isAttendanceButtonEnabled.observe(this) { - binding.btnAttendance.isEnabled = it - } - viewModel.attendanceButtonText.observe(this) { - binding.btnAttendance.text = it - } - viewModel.isAttendanceButtonVisibility.observe(this) { - binding.btnAttendance.isVisible = it - } - } - - private fun initView() { - initToolbar() - initRecyclerView() - initListener() - } - - private fun initUiInteraction() { - binding.icRefresh.setOnClickListener { - it.startAnimation(AnimationUtils.loadAnimation(this, R.anim.anim_rotation)) - viewModel.fetchData() - } - } - - private fun observeData() { - observeSoptEvent() - observeAttendanceHistory() - } - - private fun initToolbar() { - binding.toolbar.run { - setSupportActionBar(this) - setNavigationOnClickListener { this@AttendanceActivity.finish() } - } - supportActionBar?.setDisplayShowTitleEnabled(false) - } - - private fun initRecyclerView() { - attendanceAdapter = AttendanceAdapter() - binding.recyclerViewAttendanceHistory.run { - adapter = attendanceAdapter - addItemDecoration(object : RecyclerView.ItemDecoration() { - override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { - super.getItemOffsets(outRect, view, parent, state) - when (parent.getChildAdapterPosition(view)) { - 0 -> { - outRect.set(24.dp, 32.dp, 24.dp, 12.dp) - } - - attendanceAdapter.itemCount - 1 -> { - outRect.set(24.dp, 12.dp, 24.dp, 32.dp) - } - - else -> { - outRect.set(24.dp, 12.dp, 24.dp, 12.dp) - } - } - } - }) - } - } - - private fun initListener() { - binding.btnAttendance.setOnClickListener { - AttendanceCodeDialog().setTitle(binding.btnAttendance.text.toString()).show(supportFragmentManager, "attendanceCodeDialog") - } - } - - private fun observeSoptEvent() { - viewModel.soptEvent.flowWithLifecycle(lifecycle).onEach { - when (it) { - is AttendanceState.Success -> updateSoptEventComponent(it.data) - is AttendanceState.Failure -> toast("문제가 발생했습니다") - else -> {} - } - }.launchIn(lifecycleScope) - } - - private fun observeAttendanceHistory() { - viewModel.attendanceHistory.flowWithLifecycle(lifecycle).onEach { - when (it) { - is AttendanceState.Success -> { - updateAttendanceUserInfo(it.data.userInfo) - updateAttendanceSummary(it.data.attendanceSummary) - updateAttendanceLog(it.data.attendanceLog) - } - - is AttendanceState.Failure -> { - Toast.makeText(this@AttendanceActivity, "문제가 발생했습니다", Toast.LENGTH_SHORT).show() - } - - else -> {} - } - }.launchIn(lifecycleScope) - } - - private fun updateSoptEventComponent(soptEvent: SoptEvent) { - when (soptEvent.eventType) { - EventType.NO_SESSION -> updateSoptEventComponentWithNoSession() - EventType.HAS_ATTENDANCE -> updateSoptEventComponentWithHasAttendance(soptEvent) - EventType.NO_ATTENDANCE -> updateSoptEventComponentWithNoAttendance(soptEvent) - } - } - - private fun updateSoptEventComponentWithNoSession() { - binding.run { - layoutInfoEventDate.isVisible = false - layoutInfoEventLocation.isVisible = false - textInfoEventPoint.isVisible = false - val textInfoEventNameLayoutParams = textInfoEventName.layoutParams as ViewGroup.MarginLayoutParams - textInfoEventNameLayoutParams.setMargins(0, 0, 0, 0) - textInfoEventName.layoutParams = textInfoEventNameLayoutParams - removeAllSpan(textInfoEventName) - textInfoEventName.text = "오늘은 일정이 없는 날이에요" - layoutAttendanceProgress.isVisible = false - } - } - - @SuppressLint("SetTextI18n") - private fun updateSoptEventComponentWithHasAttendance(soptEvent: SoptEvent) { - binding.run { - layoutInfoEventDate.isVisible = true - textInfoEventDate.text = soptEvent.date - layoutInfoEventLocation.isVisible = true - textInfoEventLocation.text = soptEvent.location - textInfoEventPoint.isVisible = (soptEvent.message != "") - textInfoEventPoint.text = soptEvent.message - val textInfoEventNameLayoutParams = textInfoEventName.layoutParams as ViewGroup.MarginLayoutParams - textInfoEventNameLayoutParams.setMargins(0, 16.dp, 0, 0) - textInfoEventName.layoutParams = textInfoEventNameLayoutParams - removeAllSpan(textInfoEventName) - textInfoEventName.text = "오늘은 ${soptEvent.eventName} 날이에요" - (textInfoEventName.text as Spannable).run { - setSpan(StyleSpan(Typeface.BOLD), 4, 4 + (soptEvent.eventName.length), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) - } - layoutAttendanceProgress.isVisible = true - viewModel.setProgressBar(soptEvent) - when (soptEvent.attendances.size) { - 1 -> { - tvAttendanceProgress1.text = if (soptEvent.attendances[0].status == AttendanceStatus.ATTENDANCE) { - soptEvent.attendances[0].attendedAt - } else { - "-" - } - tvAttendanceProgress2.text = "2차 출석" - } - - 2 -> { - tvAttendanceProgress1.text = if (soptEvent.attendances[0].status == AttendanceStatus.ATTENDANCE) { - soptEvent.attendances[0].attendedAt - } else { - "-" - } - tvAttendanceProgress2.text = if (soptEvent.attendances[1].status == AttendanceStatus.ATTENDANCE) { - soptEvent.attendances[1].attendedAt - } else { - "-" - } - } - - else -> { - tvAttendanceProgress1.text = "1차 출석" - tvAttendanceProgress2.text = "2차 출석" - } - } - } - } - - @SuppressLint("SetTextI18n") - private fun updateSoptEventComponentWithNoAttendance(soptEvent: SoptEvent) { - binding.run { - layoutInfoEventDate.isVisible = true - textInfoEventDate.text = soptEvent.date - layoutInfoEventLocation.isVisible = true - textInfoEventLocation.text = soptEvent.location - textInfoEventPoint.isVisible = (soptEvent.message != "") - textInfoEventPoint.text = soptEvent.message - val textInfoEventNameLayoutParams = textInfoEventName.layoutParams as ViewGroup.MarginLayoutParams - textInfoEventNameLayoutParams.setMargins(0, 16.dp, 0, 0) - textInfoEventName.layoutParams = textInfoEventNameLayoutParams - removeAllSpan(textInfoEventName) - textInfoEventName.text = "오늘은 ${soptEvent.eventName} 날이에요" - (textInfoEventName.text as Spannable).run { - setSpan(StyleSpan(Typeface.BOLD), 4, 4 + (soptEvent.eventName.length), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) - } - layoutAttendanceProgress.isVisible = false - } - } - - private fun removeAllSpan(textView: TextView) { - val originalText = textView.text - (textView.text as Spannable).run { - getSpans(0, this.length, Any::class.java).forEach { this.removeSpan(it) } - } - textView.text = originalText - } - - private fun updateAttendanceUserInfo(userInfo: AttendanceUserInfo) { - attendanceAdapter.updateUserInfo(userInfo) - } - - private fun updateAttendanceSummary(summary: AttendanceSummary) { - attendanceAdapter.updateSummary(summary) - } - - private fun updateAttendanceLog(log: List) { - val list = log.toMutableList().apply { - repeat(3) { add(0, null) } - } - attendanceAdapter.submitList(list) - binding.recyclerViewAttendanceHistory.scrollToPosition(0) - } - - companion object { - fun newInstance(context: Context) = Intent(context, AttendanceActivity::class.java) - } -} diff --git a/app/src/main/java/org/sopt/official/feature/attendance/AttendanceCodeDialog.kt b/app/src/main/java/org/sopt/official/feature/attendance/AttendanceCodeDialog.kt deleted file mode 100644 index 0cb4014ed..000000000 --- a/app/src/main/java/org/sopt/official/feature/attendance/AttendanceCodeDialog.kt +++ /dev/null @@ -1,269 +0,0 @@ -/* - * MIT License - * Copyright 2023-2024 SOPT - Shout Our Passion Together - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ -package org.sopt.official.feature.attendance - -import android.content.Context -import android.graphics.Color -import android.graphics.drawable.ColorDrawable -import android.graphics.drawable.InsetDrawable -import android.os.Bundle -import android.view.KeyEvent -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.inputmethod.InputMethodManager -import android.widget.EditText -import androidx.core.content.ContextCompat.getSystemService -import androidx.core.view.children -import androidx.core.widget.addTextChangedListener -import androidx.core.widget.doAfterTextChanged -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.flowWithLifecycle -import androidx.lifecycle.lifecycleScope -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch -import org.sopt.official.common.util.viewBinding -import org.sopt.official.databinding.DialogAttendanceCodeBinding -import org.sopt.official.feature.attendance.model.DialogState - -class AttendanceCodeDialog : DialogFragment() { - private val binding by viewBinding(DialogAttendanceCodeBinding::bind) - private val viewModel: AttendanceViewModel by activityViewModels() - private lateinit var dialogTitle: String - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - isCancelable = false - } - - override fun onResume() { - super.onResume() - val back = ColorDrawable(Color.TRANSPARENT) - val inset = InsetDrawable(back, 24, 0, 24, 0) - dialog?.window?.run { - setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) - setDimAmount(0.85f) - setBackgroundDrawable(inset) - } - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?,): View { - return DialogAttendanceCodeBinding.inflate(inflater, container, false).root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?,) { - super.onViewCreated(view, savedInstanceState) - initTitle() - initListener() - observeState() - } - - fun setTitle(title: String): AttendanceCodeDialog { - dialogTitle = title - return this - } - - private fun initTitle() { - viewModel.initDialogTitle(dialogTitle) - viewModel.title - .flowWithLifecycle(viewLifecycleOwner.lifecycle) - .onEach { binding.tvAttendanceCodeDialogTitle.text = "${it.substring(0, 5)}하기" } - .launchIn(viewLifecycleOwner.lifecycleScope) - } - - private fun initListener() { - initEdittextListener() - initButtonListener() - } - - private fun initEdittextListener() { - with(binding) { - // 마지막 입력란부터 시작하도록 확인 - focusedLastInput(etAttendanceCode2) - focusedLastInput(etAttendanceCode3) - focusedLastInput(etAttendanceCode4) - focusedLastInput(etAttendanceCode5) - - // edittext 사이 공간 클릭 시에도 입력란으로 이동 - linearLayout.setOnClickListener { - checkedLastInputEditText().requestFocus() - val imm = getSystemService(requireContext(), InputMethodManager::class.java) - imm?.showSoftInput(checkedLastInputEditText(), 0) - } - - // back 버튼 클릭 - etAttendanceCode1.setOnBackKeyListener(etAttendanceCode1) - etAttendanceCode2.setOnBackKeyListener(etAttendanceCode1) - etAttendanceCode3.setOnBackKeyListener(etAttendanceCode2) - etAttendanceCode4.setOnBackKeyListener(etAttendanceCode3) - etAttendanceCode5.setOnBackKeyListener(etAttendanceCode4) - - // edittext 자동 이동 - etAttendanceCode1.addTextChangedListener( - beforeTextChanged = { _, _, _, _ -> - binding.tvAttendanceCodeDialogError.visibility = View.GONE - }, - afterTextChanged = { - if (it?.length == 1) { - etAttendanceCode2.requestFocus() - etAttendanceCode1.isEnabled = false - } - }, - ) - etAttendanceCode2.requestFocusAfterTextChanged(etAttendanceCode3) - etAttendanceCode3.requestFocusAfterTextChanged(etAttendanceCode4) - etAttendanceCode4.requestFocusAfterTextChanged(etAttendanceCode5) - - // 출석하기 버튼 활성화 - etAttendanceCode5.doAfterTextChanged { - btnAttendanceCodeDialog.isEnabled = etAttendanceCode5.text.isNotEmpty() - } - } - } - - private fun focusedLastInput(et: EditText) { - et.setOnFocusChangeListener { _, _ -> - if (et != checkedLastInputEditText()) { - checkedLastInputEditText().requestFocus() - et.clearFocus() - } - val imm = getSystemService(requireContext(), InputMethodManager::class.java) - imm?.showSoftInput(et, 0) - } - } - - private fun EditText.setOnBackKeyListener(to: EditText) { - setOnKeyListener { view, i, keyEvent -> - if (keyEvent.action != KeyEvent.ACTION_DOWN) { - return@setOnKeyListener true - } - if (text.toString().isEmpty() && i == KeyEvent.KEYCODE_DEL) { - to.isEnabled = true - clearFocus() - if (this == binding.etAttendanceCode1) { - hideKeyboard() - } else { - to.text = null - to.requestFocus() - } - } - - if (i == KeyEvent.KEYCODE_ENTER) { - val imm = context?.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - imm.hideSoftInputFromWindow(view.windowToken, 0) - } - return@setOnKeyListener false - } - } - - private fun checkedLastInputEditText(): EditText { - with(binding) { - if (etAttendanceCode1.text.isEmpty()) { - return etAttendanceCode1 - } else if (etAttendanceCode2.text.isEmpty()) { - return etAttendanceCode2 - } else if (etAttendanceCode3.text.isEmpty()) { - return etAttendanceCode3 - } else if (etAttendanceCode4.text.isEmpty()) { - return etAttendanceCode4 - } else { - return etAttendanceCode5 - } - } - } - - private fun initButtonListener() { - with(binding) { - ivAttendanceCodeClose.setOnClickListener { - dismiss() - } - btnAttendanceCodeDialog.setOnClickListener { - viewModel.checkAttendanceCode( - "${etAttendanceCode1.text}${etAttendanceCode2.text}${etAttendanceCode3.text}" + - "${etAttendanceCode4.text}${etAttendanceCode5.text}", - ) - } - } - } - - override fun dismiss() { - viewModel.initDialogState() - super.dismiss() - } - - private fun observeState() { - viewLifecycleOwner.lifecycleScope.launch { - viewModel.dialogState.collectLatest { - when (it) { - is DialogState.Close -> { - dismiss() - viewModel.run { - fetchSoptEvent() - initDialogState() - dialogErrorMessage = "" - } - } - - is DialogState.Failure -> { - // 설정한 - with(binding) { - // 숫자 초기화 - binding.linearLayout.children - .forEach { child -> - if (child is EditText) { - initCodeEditText(child) - } - } - etAttendanceCode5.clearFocus() - linearLayout.requestFocus() - - // 에러 메시지 나타나도록 - tvAttendanceCodeDialogError.visibility = View.VISIBLE - tvAttendanceCodeDialogError.text = viewModel.dialogErrorMessage - - // 키보드 내리기 - hideKeyboard() - } - } - - else -> {} - } - } - } - } - - private fun hideKeyboard() { - val imm = context?.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - imm.hideSoftInputFromWindow(view?.windowToken, 0) - } - - private fun initCodeEditText(et: EditText) { - et.isEnabled = true - et.text = null - } -} diff --git a/app/src/main/java/org/sopt/official/feature/attendance/AttendanceViewModel.kt b/app/src/main/java/org/sopt/official/feature/attendance/AttendanceViewModel.kt deleted file mode 100644 index d629f9bc1..000000000 --- a/app/src/main/java/org/sopt/official/feature/attendance/AttendanceViewModel.kt +++ /dev/null @@ -1,364 +0,0 @@ -/* - * MIT License - * Copyright 2023-2024 SOPT - Shout Our Passion Together - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ -package org.sopt.official.feature.attendance - -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.map -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import org.sopt.official.domain.entity.attendance.AttendanceHistory -import org.sopt.official.domain.entity.attendance.AttendanceRound -import org.sopt.official.domain.entity.attendance.SoptEvent -import org.sopt.official.domain.repository.attendance.AttendanceRepository -import org.sopt.official.feature.attendance.model.AttendanceState -import org.sopt.official.feature.attendance.model.DialogState -import timber.log.Timber - -data class ProgressBarState( - val isFirstProgressBarActive: Boolean = false, - val isFirstProgressBarAttendance: Boolean = false, - val isFirstToSecondLineActive: Boolean = false, - val isSecondProgressBarActive: Boolean = false, - val isSecondProgressBarAttendance: Boolean = false, - val isSecondToThirdLineActive: Boolean = false, - val isThirdProgressBarActive: Boolean = false, - val isThirdProgressBarAttendance: Boolean = false, - val isThirdProgressBarTardy: Boolean = false, - val isThirdProgressBarBeforeAttendance: Boolean = false, - val isThirdProgressBarAbsent: Boolean = false, -) - -data class AttendanceButtonState( - val isAttendanceButtonEnabled: Boolean = false, - val attendanceButtonText: String = "", - val isAttendanceButtonVisibility: Boolean = false -) - -@HiltViewModel -class AttendanceViewModel @Inject constructor( - private val attendanceRepository: AttendanceRepository -) : ViewModel() { - private var eventId: Int = 0 - private val _title: MutableStateFlow = MutableStateFlow("") - val title = _title.asStateFlow() - private val _soptEvent = MutableStateFlow>(AttendanceState.Init) - val soptEvent: StateFlow> get() = _soptEvent - private val _attendanceHistory = MutableStateFlow>(AttendanceState.Init) - val attendanceHistory: StateFlow> get() = _attendanceHistory - private val _attendanceRound = MutableStateFlow>(AttendanceState.Init) - val attendanceRound: StateFlow> get() = _attendanceRound - private val _dialogState = MutableStateFlow(DialogState.Show) - val dialogState: StateFlow get() = _dialogState - - private val progressBarState = MutableLiveData(ProgressBarState()) - val isFirstProgressBarActive: LiveData = progressBarState.map { it.isFirstProgressBarActive } - var isFirstProgressBarAttendance: LiveData = progressBarState.map { it.isFirstProgressBarAttendance } - val isFirstToSecondLineActive: LiveData = progressBarState.map { it.isFirstToSecondLineActive } - val isSecondProgressBarActive: LiveData = progressBarState.map { it.isSecondProgressBarActive } - var isSecondProgressBarAttendance: LiveData = progressBarState.map { it.isSecondProgressBarAttendance } - val isSecondToThirdLineActive: LiveData = progressBarState.map { it.isSecondToThirdLineActive } - val isThirdProgressBarActive: LiveData = progressBarState.map { it.isThirdProgressBarActive } - val isThirdProgressBarAttendance: LiveData = progressBarState.map { it.isThirdProgressBarAttendance } - private val isThirdProgressBarTardy: LiveData = progressBarState.map { it.isThirdProgressBarTardy } - val isThirdProgressBarBeforeAttendance: LiveData = progressBarState.map { it.isThirdProgressBarBeforeAttendance } - val isThirdProgressBarVisible = progressBarState.map { it.isThirdProgressBarActive && it.isThirdProgressBarTardy } - val isThirdProgressBarActiveAndBeforeAttendance = - progressBarState.map { it.isThirdProgressBarActive && it.isThirdProgressBarBeforeAttendance } - - private val attendanceButtonState = MutableLiveData(AttendanceButtonState()) - val isAttendanceButtonEnabled: LiveData = attendanceButtonState.map { it.isAttendanceButtonEnabled } - val attendanceButtonText: LiveData = attendanceButtonState.map { it.attendanceButtonText } - val isAttendanceButtonVisibility: LiveData = attendanceButtonState.map { it.isAttendanceButtonVisibility } - - private var subLectureId: Long = 0L - var dialogErrorMessage: String = "" - private var attendancesSize = 0 - - init { - fetchData() - } - - fun fetchData() { - fetchSoptEvent() - fetchAttendanceHistory() - } - - fun initDialogTitle(title: String) { - _title.value = title - } - - fun fetchSoptEvent() { - viewModelScope.launch { - _soptEvent.value = AttendanceState.Loading - attendanceRepository.fetchSoptEvent() - .onSuccess { - _soptEvent.value = AttendanceState.Success(it) - eventId = it.id - attendancesSize = it.attendances.size - fetchAttendanceRound() - }.onFailure { - Timber.e(it) - _soptEvent.value = AttendanceState.Failure(it) - } - } - } - - fun setProgressBar(soptEvent: SoptEvent) { - when (soptEvent.attendances.size) { - // 출석 전 - 0 -> { - setFirstToSecondLine(false) - setThirdProgressBarBeforeAttendance(true) - setThirdProgressBar(false) - } - // 1차 출석 시작 ~ 2차 출석 시작 전 - 1 -> { - setThirdProgressBarBeforeAttendance(true) - setThirdProgressBar(false) - val firstProgressText = soptEvent.attendances[0].attendedAt - if (firstProgressText != FIRST_ATTENDANCE_TEXT) { - // 1차 출석이 출석 - setFirstProgressBar(true) - setFirstToSecondLine(true) - setFirstProgressBarAttendance(true) - } else { - // 1차 출석이 결석 - setFirstProgressBar(true) - setFirstToSecondLine(true) - setFirstProgressBarAttendance(false) - } - } - // 2차 출석 시작 ~ - 2 -> { - val firstProgressText = soptEvent.attendances[0].attendedAt - val secondProgressText = soptEvent.attendances[1].attendedAt - - if (firstProgressText != FIRST_ATTENDANCE_TEXT) { - // 1차 출석이 출석 - setFirstProgressBar(true) - setFirstToSecondLine(true) - setFirstProgressBarAttendance(true) - } else { - // 1차 출석이 결석 - setFirstProgressBar(true) - setFirstToSecondLine(true) - setFirstProgressBarAttendance(false) - } - - if (secondProgressText != SECOND_ATTENDANCE_TEXT) { - // 2차 출석이 출석 - setSecondProgressBar(true) - setSecondToThirdLine(true) - setSecondProgressBarAttendance(true) - } else { - // 2차 출석이 결석 - setSecondProgressBar(true) - setSecondToThirdLine(true) - setSecondProgressBarAttendance(false) - } - - val firstStatus = soptEvent.attendances[0].status.name - val secondStatus = soptEvent.attendances[1].status.name - if (firstStatus == "ATTENDANCE" && secondStatus == "ATTENDANCE") { - // 마지막 progress가 출석 - setThirdProgressBar(true) - setThirdProgressBarAttendance(true) - setThirdProgressBarBeforeAttendance(true) - setThirdProgressBarTardy(false) - } else if (firstStatus == "ATTENDANCE" && secondStatus == "ABSENT") { - // 마지막 progress가 지각 - setThirdProgressBarBeforeAttendance(true) - setThirdProgressBar(true) - setThirdProgressBarTardy(true) - setThirdProgressBarAttendance(true) - } else if (firstStatus == "ABSENT" && secondStatus == "ATTENDANCE") { - // 마지막 progress가 지각 - setThirdProgressBarBeforeAttendance(true) - setThirdProgressBar(true) - setThirdProgressBarTardy(true) - setThirdProgressBarAttendance(true) - } else { - // 마지막 progress가 결석 - setThirdProgressBarBeforeAttendance(false) - setThirdProgressBar(true) - setThirdProgressBarTardy(false) - } - } - } - } - - private fun setProgressBarState(block: ProgressBarState.() -> ProgressBarState) { - progressBarState.value = progressBarState.value?.block() - } - - private fun setAttendanceButtonState(block: AttendanceButtonState.() -> AttendanceButtonState) { - attendanceButtonState.value = attendanceButtonState.value?.block() - } - - private fun setFirstProgressBar(isActive: Boolean) { - setProgressBarState { copy(isFirstProgressBarActive = isActive) } - } - - private fun setFirstProgressBarAttendance(isActive: Boolean) { - setProgressBarState { copy(isFirstProgressBarAttendance = isActive) } - } - - private fun setFirstToSecondLine(isActive: Boolean) { - setProgressBarState { copy(isFirstToSecondLineActive = isActive) } - } - - private fun setSecondProgressBar(isActive: Boolean) { - setProgressBarState { copy(isSecondProgressBarActive = isActive) } - } - - private fun setSecondProgressBarAttendance(isActive: Boolean) { - setProgressBarState { copy(isSecondProgressBarAttendance = isActive) } - } - - private fun setSecondToThirdLine(isActive: Boolean) { - setProgressBarState { copy(isSecondToThirdLineActive = isActive) } - } - - private fun setThirdProgressBar(isActive: Boolean) { - setProgressBarState { copy(isThirdProgressBarActive = isActive) } - } - - private fun setThirdProgressBarAttendance(isAttendance: Boolean) { - setProgressBarState { copy(isThirdProgressBarAttendance = isAttendance) } - } - - private fun setThirdProgressBarTardy(isTardy: Boolean) { - setProgressBarState { copy(isThirdProgressBarTardy = isTardy) } - } - - private fun setThirdProgressBarBeforeAttendance(isBeforeAttendance: Boolean) { - setProgressBarState { copy(isThirdProgressBarBeforeAttendance = isBeforeAttendance) } - } - - private fun fetchAttendanceHistory() { - viewModelScope.launch { - _attendanceHistory.value = AttendanceState.Loading - attendanceRepository.fetchAttendanceHistory() - .onSuccess { - _attendanceHistory.value = AttendanceState.Success(it) - }.onFailure { - Timber.e(it) - _attendanceHistory.value = AttendanceState.Failure(it) - } - } - } - - private suspend fun fetchAttendanceRound() { - attendanceRepository.fetchAttendanceRound(eventId.toLong()) - .onSuccess { - _attendanceRound.value = AttendanceState.Success(it) - subLectureId = it.id - when (it.id) { - // N차 출석 정보 조회 에러 - -2L -> { - setAttendanceButtonVisibility(false) - } - - // 오늘 세션이 없는 경우 - -1L -> { - setAttendanceButtonVisibility(false) - } - - // 1차 출석 전 || 2차 출석 전 || 2차 출석 종료 후 - 0L -> { - setAttendanceButtonText(it.roundText) - setAttendanceButtonVisibility(true) - setAttendanceButtonEnabled(false) - } - - else -> { - if (it.roundText.isNotEmpty() && attendancesSize == it.roundText[0].code - '0'.code) { - // 사용자가 출석을 완료한 경우 - setAttendanceButtonText(it.roundText.substring(0, 5) + " 종료") - setAttendanceButtonVisibility(true) - setAttendanceButtonEnabled(false) - } else { - // 사용자가 출석을 진행해야 하는 경우 - setAttendanceButtonText(it.roundText) - setAttendanceButtonVisibility(true) - setAttendanceButtonEnabled(true) - } - } - } - }.onFailure { - Timber.e(it) - } - } - - private fun setAttendanceButtonVisibility(isVisibility: Boolean) { - setAttendanceButtonState { copy(isAttendanceButtonVisibility = isVisibility) } - } - - private fun setAttendanceButtonEnabled(isEnabled: Boolean) { - setAttendanceButtonState { copy(isAttendanceButtonEnabled = isEnabled) } - } - - private fun setAttendanceButtonText(text: String) { - setAttendanceButtonState { copy(attendanceButtonText = text) } - } - - fun checkAttendanceCode(code: String) { - _dialogState.value = DialogState.Show - viewModelScope.launch { - attendanceRepository.confirmAttendanceCode(subLectureId, code) - .onSuccess { - when (it.subLectureId) { - -2L -> showDialog("코드가 일치하지 않아요!") - -1L -> showDialog("출석 시간 전입니다.") - 0L -> showDialog("출석이 이미 종료되었습니다.") - else -> _dialogState.value = DialogState.Close - } - }.onFailure { - Timber.e(it) - } - } - } - - fun initDialogState() { - _dialogState.value = DialogState.Show - } - - private fun showDialog(message: String) { - dialogErrorMessage = message - _dialogState.value = DialogState.Failure - } - - companion object { - private const val FIRST_ATTENDANCE_TEXT = "1차 출석" - private const val SECOND_ATTENDANCE_TEXT = "2차 출석" - } -} diff --git a/app/src/main/java/org/sopt/official/feature/attendance/adapter/AttendanceAdapter.kt b/app/src/main/java/org/sopt/official/feature/attendance/adapter/AttendanceAdapter.kt deleted file mode 100644 index 4070f2e53..000000000 --- a/app/src/main/java/org/sopt/official/feature/attendance/adapter/AttendanceAdapter.kt +++ /dev/null @@ -1,98 +0,0 @@ -/* - * MIT License - * Copyright 2023 SOPT - Shout Our Passion Together - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ -package org.sopt.official.feature.attendance.adapter - -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import org.sopt.official.domain.entity.attendance.AttendanceLog -import org.sopt.official.domain.entity.attendance.AttendanceSummary -import org.sopt.official.domain.entity.attendance.AttendanceUserInfo - -class AttendanceAdapter : ListAdapter(diffCallback) { - private var userInfo: AttendanceUserInfo? = null - private var summary: AttendanceSummary? = null - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return when (viewType) { - USER_INFO -> UserInfoViewHolder.create(parent) - SUMMARY -> SummaryViewHolder.create(parent) - LOG_HEADER -> LogHeaderViewHolder.create(parent) - LOG -> LogViewHolder.create(parent) - else -> throw IllegalArgumentException("Illegal viewType argument: $viewType") - } - } - - override fun getItemCount(): Int { - return currentList.size - } - - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is UserInfoViewHolder -> userInfo?.let { holder.onBind(it) } - is SummaryViewHolder -> summary?.let { holder.onBind(it) } - is LogHeaderViewHolder -> {} - is LogViewHolder -> holder.onBind(currentList[position]) - else -> throw IllegalArgumentException("Illegal holder argument: ${holder::class.java.simpleName}") - } - } - - override fun getItemViewType(position: Int): Int { - return when (position) { - 0 -> USER_INFO - 1 -> SUMMARY - 2 -> LOG_HEADER - else -> LOG - } - } - - fun updateUserInfo(userInfo: AttendanceUserInfo) { - this.userInfo = userInfo - notifyItemChanged(USER_INFO) - } - - fun updateSummary(summary: AttendanceSummary) { - this.summary = summary - notifyItemChanged(SUMMARY) - } - - companion object { - private const val USER_INFO = 0 - private const val SUMMARY = 1 - private const val LOG_HEADER = 2 - private const val LOG = 3 - - private val diffCallback = object : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: AttendanceLog, newItem: AttendanceLog): Boolean { - return oldItem.eventName == newItem.eventName - } - - override fun areContentsTheSame(oldItem: AttendanceLog, newItem: AttendanceLog): Boolean { - return oldItem == newItem - } - } - } -} diff --git a/app/src/main/java/org/sopt/official/feature/attendance/adapter/LogViewHolder.kt b/app/src/main/java/org/sopt/official/feature/attendance/adapter/LogViewHolder.kt deleted file mode 100644 index 29c960c02..000000000 --- a/app/src/main/java/org/sopt/official/feature/attendance/adapter/LogViewHolder.kt +++ /dev/null @@ -1,85 +0,0 @@ -/* - * MIT License - * Copyright 2023 SOPT - Shout Our Passion Together - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ -package org.sopt.official.feature.attendance.adapter - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.core.content.ContextCompat -import androidx.recyclerview.widget.RecyclerView -import org.sopt.official.databinding.ItemAttendanceHistoryLogBinding -import org.sopt.official.domain.entity.attendance.AttendanceLog -import org.sopt.official.domain.entity.attendance.AttendanceStatus -import org.sopt.official.type.SoptColors - -class LogViewHolder(private val binding: ItemAttendanceHistoryLogBinding) : RecyclerView.ViewHolder(binding.root) { - fun onBind(log: AttendanceLog) { - initView(log) - } - - private fun initView(log: AttendanceLog) { - binding.run { - textAttendanceEventName.text = log.eventName - textAttendanceDate.text = log.date - - textAttendanceState.text = log.attendanceState - when (log.attendanceState) { - AttendanceStatus.ATTENDANCE.statusKorean -> { - textAttendanceState.backgroundTintList = ContextCompat.getColorStateList(root.context, SoptColors.mds_green_900) - textAttendanceState.setTextColor(root.context.getColor(SoptColors.mds_information)) - } - - AttendanceStatus.TARDY.statusKorean -> { - textAttendanceState.backgroundTintList = ContextCompat.getColorStateList(root.context, SoptColors.mds_yellow_900) - textAttendanceState.setTextColor(root.context.getColor(SoptColors.mds_attention)) - } - - AttendanceStatus.ABSENT.statusKorean -> { - textAttendanceState.backgroundTintList = ContextCompat.getColorStateList(root.context, SoptColors.mds_red_800) - textAttendanceState.setTextColor(root.context.getColor(SoptColors.mds_red_300)) - } - - AttendanceStatus.PARTICIPATE.statusKorean -> { - textAttendanceState.backgroundTintList = ContextCompat.getColorStateList(root.context, SoptColors.mds_gray_700) - textAttendanceState.setTextColor(root.context.getColor(SoptColors.mds_gray_200)) - } - - else -> { - throw IllegalArgumentException("Illegal attendanceState argument: ${log.attendanceState}") - } - } - } - } - - companion object { - fun create(parent: ViewGroup): LogViewHolder { - val binding = ItemAttendanceHistoryLogBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - return LogViewHolder(binding) - } - } -} diff --git a/app/src/main/java/org/sopt/official/feature/attendance/adapter/SummaryViewHolder.kt b/app/src/main/java/org/sopt/official/feature/attendance/adapter/SummaryViewHolder.kt deleted file mode 100644 index 69902bbe5..000000000 --- a/app/src/main/java/org/sopt/official/feature/attendance/adapter/SummaryViewHolder.kt +++ /dev/null @@ -1,59 +0,0 @@ -/* - * MIT License - * Copyright 2023 SOPT - Shout Our Passion Together - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ -package org.sopt.official.feature.attendance.adapter - -import android.annotation.SuppressLint -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import org.sopt.official.databinding.ItemAttendanceHistorySummaryBinding -import org.sopt.official.domain.entity.attendance.AttendanceSummary - -class SummaryViewHolder(private val binding: ItemAttendanceHistorySummaryBinding) : RecyclerView.ViewHolder(binding.root) { - fun onBind(summary: AttendanceSummary) { - initView(summary) - } - - @SuppressLint("SetTextI18n") - private fun initView(summary: AttendanceSummary) { - binding.run { - textAttendanceCountNormal.text = "${summary.normal}회" - textAttendanceCountLate.text = "${summary.late}회" - textAttendanceCountAbnormal.text = "${summary.abnormal}회" - textAttendanceCountParticipate.text = "${summary.participate}회" - } - } - - companion object { - fun create(parent: ViewGroup): SummaryViewHolder { - val binding = ItemAttendanceHistorySummaryBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - return SummaryViewHolder(binding) - } - } -} diff --git a/app/src/main/java/org/sopt/official/feature/attendance/adapter/UserInfoViewHolder.kt b/app/src/main/java/org/sopt/official/feature/attendance/adapter/UserInfoViewHolder.kt deleted file mode 100644 index 1e86af4a2..000000000 --- a/app/src/main/java/org/sopt/official/feature/attendance/adapter/UserInfoViewHolder.kt +++ /dev/null @@ -1,86 +0,0 @@ -/* - * MIT License - * Copyright 2023 SOPT - Shout Our Passion Together - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ -package org.sopt.official.feature.attendance.adapter - -import android.annotation.SuppressLint -import android.content.Intent -import android.graphics.Typeface -import android.net.Uri -import android.text.Spannable -import android.text.Spanned -import android.text.style.ForegroundColorSpan -import android.text.style.StyleSpan -import android.view.LayoutInflater -import android.view.ViewGroup -import android.widget.Toast -import androidx.core.content.ContextCompat -import androidx.recyclerview.widget.RecyclerView -import org.sopt.official.databinding.ItemAttendanceHistoryUserInfoBinding -import org.sopt.official.domain.entity.attendance.AttendanceUserInfo -import org.sopt.official.type.SoptColors - -class UserInfoViewHolder(private val binding: ItemAttendanceHistoryUserInfoBinding) : RecyclerView.ViewHolder(binding.root) { - fun onBind(userInfo: AttendanceUserInfo) { - initView(userInfo) - } - - @SuppressLint("SetTextI18n") - private fun initView(userInfo: AttendanceUserInfo) { - binding.run { - textUserInfo.text = "${userInfo.generation}기 ${userInfo.partName}파트 ${userInfo.userName}" - textUserAttendancePoint.text = "현재 출석점수는 ${userInfo.attendancePoint}점 입니다!" - (textUserAttendancePoint.text as Spannable).run { - setSpan( - (ForegroundColorSpan(ContextCompat.getColor(textUserAttendancePoint.context, SoptColors.mds_secondary))), - 9, - 9 + "${userInfo.attendancePoint}".length + 1, - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE - ) - setSpan(StyleSpan(Typeface.BOLD), 9, 9 + "${userInfo.attendancePoint}".length + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) - } - } - } - - companion object { - private const val ATTENDANCE_RULE_URL = "https://sopt.org/rules" - - fun create(parent: ViewGroup): UserInfoViewHolder { - val binding = ItemAttendanceHistoryUserInfoBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - - binding.imageAttendancePointInfo.setOnClickListener { - Toast.makeText(parent.context, "제2장 제10조(출석)를 확인해주세요", Toast.LENGTH_SHORT).show() - Intent(Intent.ACTION_VIEW, Uri.parse(ATTENDANCE_RULE_URL)).run { - parent.context.startActivity(this) - } - } - - return UserInfoViewHolder(binding) - } - } -} diff --git a/data/attendance/build.gradle.kts b/data/attendance/build.gradle.kts new file mode 100644 index 000000000..bb0abf717 --- /dev/null +++ b/data/attendance/build.gradle.kts @@ -0,0 +1,40 @@ +/* + * MIT License + * Copyright 2023-2025 SOPT - Shout Our Passion Together + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +plugins { + sopt("feature") + alias(libs.plugins.kotlinx.serialization) +} + +android { + namespace = "org.sopt.official.data.attendance" +} + +dependencies { + implementation(projects.domain.attendance) + implementation(projects.core.network) + implementation(projects.core.common) + implementation(platform(libs.okhttp.bom)) + implementation(libs.bundles.okhttp) +} \ No newline at end of file diff --git a/data/attendance/src/main/AndroidManifest.xml b/data/attendance/src/main/AndroidManifest.xml new file mode 100644 index 000000000..7e2cc9152 --- /dev/null +++ b/data/attendance/src/main/AndroidManifest.xml @@ -0,0 +1,25 @@ + + + diff --git a/app/src/main/java/org/sopt/official/di/attendance/AttendanceBindsModule.kt b/data/attendance/src/main/java/org/sopt/official/data/attendance/di/AttendanceDataModule.kt similarity index 84% rename from app/src/main/java/org/sopt/official/di/attendance/AttendanceBindsModule.kt rename to data/attendance/src/main/java/org/sopt/official/data/attendance/di/AttendanceDataModule.kt index bd754a504..2698d775a 100644 --- a/app/src/main/java/org/sopt/official/di/attendance/AttendanceBindsModule.kt +++ b/data/attendance/src/main/java/org/sopt/official/data/attendance/di/AttendanceDataModule.kt @@ -1,6 +1,6 @@ /* * MIT License - * Copyright 2023-2024 SOPT - Shout Our Passion Together + * Copyright 2023-2025 SOPT - Shout Our Passion Together * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal @@ -22,7 +22,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -package org.sopt.official.di.attendance +package org.sopt.official.data.attendance.di import dagger.Binds import dagger.Module @@ -31,14 +31,14 @@ import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import javax.inject.Singleton import org.sopt.official.common.di.OperationRetrofit -import org.sopt.official.data.repository.attendance.AttendanceRepositoryImpl -import org.sopt.official.data.service.attendance.AttendanceService -import org.sopt.official.domain.repository.attendance.AttendanceRepository +import org.sopt.official.data.attendance.repository.AttendanceRepositoryImpl +import org.sopt.official.data.attendance.service.AttendanceService +import org.sopt.official.domain.attendance.repository.AttendanceRepository import retrofit2.Retrofit @Module @InstallIn(SingletonComponent::class) -abstract class AttendanceBindsModule { +abstract class AttendanceDataModule { @Binds @Singleton abstract fun bindAttendanceRepository(attendanceRepositoryImpl: AttendanceRepositoryImpl): AttendanceRepository diff --git a/app/src/main/java/org/sopt/official/data/model/attendance/AttendanceCodeResponse.kt b/data/attendance/src/main/java/org/sopt/official/data/attendance/model/AttendanceCodeResponse.kt similarity index 93% rename from app/src/main/java/org/sopt/official/data/model/attendance/AttendanceCodeResponse.kt rename to data/attendance/src/main/java/org/sopt/official/data/attendance/model/AttendanceCodeResponse.kt index e280e93a0..7a9e0b22f 100644 --- a/app/src/main/java/org/sopt/official/data/model/attendance/AttendanceCodeResponse.kt +++ b/data/attendance/src/main/java/org/sopt/official/data/attendance/model/AttendanceCodeResponse.kt @@ -1,6 +1,6 @@ /* * MIT License - * Copyright 2023 SOPT - Shout Our Passion Together + * Copyright 2023-2025 SOPT - Shout Our Passion Together * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal @@ -22,7 +22,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -package org.sopt.official.data.model.attendance +package org.sopt.official.data.attendance.model import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/app/src/main/java/org/sopt/official/data/model/attendance/AttendanceHistoryResponse.kt b/data/attendance/src/main/java/org/sopt/official/data/attendance/model/AttendanceHistoryResponse.kt similarity index 85% rename from app/src/main/java/org/sopt/official/data/model/attendance/AttendanceHistoryResponse.kt rename to data/attendance/src/main/java/org/sopt/official/data/attendance/model/AttendanceHistoryResponse.kt index e2ec3eb45..321a3be60 100644 --- a/app/src/main/java/org/sopt/official/data/model/attendance/AttendanceHistoryResponse.kt +++ b/data/attendance/src/main/java/org/sopt/official/data/attendance/model/AttendanceHistoryResponse.kt @@ -1,6 +1,6 @@ /* * MIT License - * Copyright 2023 SOPT - Shout Our Passion Together + * Copyright 2023-2025 SOPT - Shout Our Passion Together * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal @@ -22,11 +22,17 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -package org.sopt.official.data.model.attendance +package org.sopt.official.data.attendance.model import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import org.sopt.official.domain.entity.attendance.* +import org.sopt.official.domain.attendance.entity.AttendanceHistory +import org.sopt.official.domain.attendance.entity.AttendanceLog +import org.sopt.official.domain.attendance.entity.AttendanceStatus +import org.sopt.official.domain.attendance.entity.AttendanceSummary +import org.sopt.official.domain.attendance.entity.AttendanceUserInfo +import org.sopt.official.domain.attendance.entity.EventAttribute +import org.sopt.official.domain.attendance.entity.Part @Serializable data class AttendanceHistoryResponse( diff --git a/app/src/main/java/org/sopt/official/data/model/attendance/AttendanceRoundResponse.kt b/data/attendance/src/main/java/org/sopt/official/data/attendance/model/AttendanceRoundResponse.kt similarity index 89% rename from app/src/main/java/org/sopt/official/data/model/attendance/AttendanceRoundResponse.kt rename to data/attendance/src/main/java/org/sopt/official/data/attendance/model/AttendanceRoundResponse.kt index 4ea0d879d..eae669b92 100644 --- a/app/src/main/java/org/sopt/official/data/model/attendance/AttendanceRoundResponse.kt +++ b/data/attendance/src/main/java/org/sopt/official/data/attendance/model/AttendanceRoundResponse.kt @@ -1,6 +1,6 @@ /* * MIT License - * Copyright 2023 SOPT - Shout Our Passion Together + * Copyright 2023-2025 SOPT - Shout Our Passion Together * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal @@ -22,11 +22,11 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -package org.sopt.official.data.model.attendance +package org.sopt.official.data.attendance.model import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import org.sopt.official.domain.entity.attendance.AttendanceRound +import org.sopt.official.domain.attendance.entity.AttendanceRound @Serializable data class AttendanceRoundResponse( diff --git a/app/src/main/java/org/sopt/official/data/model/attendance/BaseAttendanceResponse.kt b/data/attendance/src/main/java/org/sopt/official/data/attendance/model/BaseAttendanceResponse.kt similarity index 92% rename from app/src/main/java/org/sopt/official/data/model/attendance/BaseAttendanceResponse.kt rename to data/attendance/src/main/java/org/sopt/official/data/attendance/model/BaseAttendanceResponse.kt index 4ec9c3748..aa023aa11 100644 --- a/app/src/main/java/org/sopt/official/data/model/attendance/BaseAttendanceResponse.kt +++ b/data/attendance/src/main/java/org/sopt/official/data/attendance/model/BaseAttendanceResponse.kt @@ -1,6 +1,6 @@ /* * MIT License - * Copyright 2023 SOPT - Shout Our Passion Together + * Copyright 2023-2025 SOPT - Shout Our Passion Together * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal @@ -22,7 +22,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -package org.sopt.official.data.model.attendance +package org.sopt.official.data.attendance.model import kotlinx.serialization.Serializable diff --git a/app/src/main/java/org/sopt/official/data/model/attendance/RequestAttendanceCode.kt b/data/attendance/src/main/java/org/sopt/official/data/attendance/model/RequestAttendanceCode.kt similarity index 92% rename from app/src/main/java/org/sopt/official/data/model/attendance/RequestAttendanceCode.kt rename to data/attendance/src/main/java/org/sopt/official/data/attendance/model/RequestAttendanceCode.kt index 5cbbda0fc..78507bdeb 100644 --- a/app/src/main/java/org/sopt/official/data/model/attendance/RequestAttendanceCode.kt +++ b/data/attendance/src/main/java/org/sopt/official/data/attendance/model/RequestAttendanceCode.kt @@ -1,6 +1,6 @@ /* * MIT License - * Copyright 2023 SOPT - Shout Our Passion Together + * Copyright 2023-2025 SOPT - Shout Our Passion Together * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal @@ -22,7 +22,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -package org.sopt.official.data.model.attendance +package org.sopt.official.data.attendance.model import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/app/src/main/java/org/sopt/official/data/model/attendance/SoptEventResponse.kt b/data/attendance/src/main/java/org/sopt/official/data/attendance/model/SoptEventResponse.kt similarity index 93% rename from app/src/main/java/org/sopt/official/data/model/attendance/SoptEventResponse.kt rename to data/attendance/src/main/java/org/sopt/official/data/attendance/model/SoptEventResponse.kt index 4de3139bf..98bbe0499 100644 --- a/app/src/main/java/org/sopt/official/data/model/attendance/SoptEventResponse.kt +++ b/data/attendance/src/main/java/org/sopt/official/data/attendance/model/SoptEventResponse.kt @@ -1,6 +1,6 @@ /* * MIT License - * Copyright 2023-2024 SOPT - Shout Our Passion Together + * Copyright 2023-2025 SOPT - Shout Our Passion Together * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal @@ -22,14 +22,14 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -package org.sopt.official.data.model.attendance +package org.sopt.official.data.attendance.model import kotlinx.datetime.LocalDateTime import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import org.sopt.official.domain.entity.attendance.AttendanceStatus -import org.sopt.official.domain.entity.attendance.EventType -import org.sopt.official.domain.entity.attendance.SoptEvent +import org.sopt.official.domain.attendance.entity.AttendanceStatus +import org.sopt.official.domain.attendance.entity.EventType +import org.sopt.official.domain.attendance.entity.SoptEvent @Serializable data class SoptEventResponse( diff --git a/app/src/main/java/org/sopt/official/data/repository/attendance/AttendanceRepositoryImpl.kt b/data/attendance/src/main/java/org/sopt/official/data/attendance/repository/AttendanceRepositoryImpl.kt similarity index 68% rename from app/src/main/java/org/sopt/official/data/repository/attendance/AttendanceRepositoryImpl.kt rename to data/attendance/src/main/java/org/sopt/official/data/attendance/repository/AttendanceRepositoryImpl.kt index 52fe8dde7..a248422d0 100644 --- a/app/src/main/java/org/sopt/official/data/repository/attendance/AttendanceRepositoryImpl.kt +++ b/data/attendance/src/main/java/org/sopt/official/data/attendance/repository/AttendanceRepositoryImpl.kt @@ -1,6 +1,6 @@ /* * MIT License - * Copyright 2023-2024 SOPT - Shout Our Passion Together + * Copyright 2023-2025 SOPT - Shout Our Passion Together * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal @@ -22,28 +22,33 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -package org.sopt.official.data.repository.attendance +package org.sopt.official.data.attendance.repository import javax.inject.Inject import kotlinx.serialization.json.Json import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive -import org.sopt.official.data.model.attendance.AttendanceCodeResponse -import org.sopt.official.data.model.attendance.RequestAttendanceCode -import org.sopt.official.data.service.attendance.AttendanceService -import org.sopt.official.domain.entity.attendance.AttendanceButtonType -import org.sopt.official.domain.entity.attendance.AttendanceErrorCode -import org.sopt.official.domain.entity.attendance.AttendanceHistory -import org.sopt.official.domain.entity.attendance.AttendanceRound -import org.sopt.official.domain.entity.attendance.SoptEvent -import org.sopt.official.domain.repository.attendance.AttendanceRepository +import org.sopt.official.data.attendance.model.AttendanceCodeResponse +import org.sopt.official.data.attendance.model.RequestAttendanceCode +import org.sopt.official.data.attendance.service.AttendanceService +import org.sopt.official.domain.attendance.entity.AttendanceButtonType +import org.sopt.official.domain.attendance.entity.AttendanceCodeResponse as DomainAttendanceCodeResponse +import org.sopt.official.domain.attendance.entity.AttendanceErrorCode +import org.sopt.official.domain.attendance.entity.AttendanceHistory +import org.sopt.official.domain.attendance.entity.AttendanceRound +import org.sopt.official.domain.attendance.entity.SoptEvent +import org.sopt.official.domain.attendance.repository.AttendanceRepository import retrofit2.HttpException class AttendanceRepositoryImpl @Inject constructor( private val attendanceService: AttendanceService, private val json: Json ) : AttendanceRepository { + + companion object { + private const val UNKNOWN_ERROR_CODE = -2L + } override suspend fun fetchSoptEvent(): Result = runCatching { attendanceService.getSoptEvent().data!!.toEntity() } override suspend fun fetchAttendanceHistory(): Result = runCatching { attendanceService.getAttendanceHistory().data!!.toEntity() } @@ -67,8 +72,9 @@ class AttendanceRepositoryImpl @Inject constructor( } } - override suspend fun confirmAttendanceCode(subLectureId: Long, code: String): Result = runCatching { - attendanceService.confirmAttendanceCode(RequestAttendanceCode(subLectureId, code)).data ?: AttendanceCodeResponse(-1) + override suspend fun confirmAttendanceCode(subLectureId: Long, code: String): Result = runCatching { + val response = attendanceService.confirmAttendanceCode(RequestAttendanceCode(subLectureId, code)).data!! + DomainAttendanceCodeResponse(response.subLectureId) }.recoverCatching { cause -> when (cause) { is HttpException -> { @@ -76,13 +82,16 @@ class AttendanceRepositoryImpl @Inject constructor( if (errorBodyString != null) { val errorBody = json.parseToJsonElement(errorBodyString).jsonObject val message = errorBody["message"]?.jsonPrimitive?.contentOrNull - AttendanceErrorCode.of(message ?: "") ?: AttendanceCodeResponse.ERROR + val errorCode = AttendanceErrorCode.of(message ?: "") + DomainAttendanceCodeResponse( + errorCode?.errorCode ?: UNKNOWN_ERROR_CODE + ) } else { - AttendanceCodeResponse.ERROR + DomainAttendanceCodeResponse(UNKNOWN_ERROR_CODE) } } - else -> AttendanceCodeResponse.ERROR + else -> DomainAttendanceCodeResponse(UNKNOWN_ERROR_CODE) } } } diff --git a/app/src/main/java/org/sopt/official/data/service/attendance/AttendanceService.kt b/data/attendance/src/main/java/org/sopt/official/data/attendance/service/AttendanceService.kt similarity index 92% rename from app/src/main/java/org/sopt/official/data/service/attendance/AttendanceService.kt rename to data/attendance/src/main/java/org/sopt/official/data/attendance/service/AttendanceService.kt index 1db3b1e5a..c6e485fd9 100644 --- a/app/src/main/java/org/sopt/official/data/service/attendance/AttendanceService.kt +++ b/data/attendance/src/main/java/org/sopt/official/data/attendance/service/AttendanceService.kt @@ -1,6 +1,6 @@ /* * MIT License - * Copyright 2023-2024 SOPT - Shout Our Passion Together + * Copyright 2023-2025 SOPT - Shout Our Passion Together * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal @@ -22,9 +22,9 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -package org.sopt.official.data.service.attendance +package org.sopt.official.data.attendance.service -import org.sopt.official.data.model.attendance.* +import org.sopt.official.data.attendance.model.* import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.POST diff --git a/app/src/main/java/org/sopt/official/data/service/attendance/MockAttendanceService.kt b/data/attendance/src/main/java/org/sopt/official/data/attendance/service/MockAttendanceService.kt similarity index 98% rename from app/src/main/java/org/sopt/official/data/service/attendance/MockAttendanceService.kt rename to data/attendance/src/main/java/org/sopt/official/data/attendance/service/MockAttendanceService.kt index 1e52ba758..f7de1a364 100644 --- a/app/src/main/java/org/sopt/official/data/service/attendance/MockAttendanceService.kt +++ b/data/attendance/src/main/java/org/sopt/official/data/attendance/service/MockAttendanceService.kt @@ -1,6 +1,6 @@ /* * MIT License - * Copyright 2023 SOPT - Shout Our Passion Together + * Copyright 2023-2025 SOPT - Shout Our Passion Together * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal @@ -22,10 +22,10 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -package org.sopt.official.data.service.attendance +package org.sopt.official.data.attendance.service import kotlinx.serialization.json.Json -import org.sopt.official.data.model.attendance.* +import org.sopt.official.data.attendance.model.* class MockAttendanceService : AttendanceService { override suspend fun getSoptEvent(): BaseAttendanceResponse { diff --git a/domain/attendance/build.gradle.kts b/domain/attendance/build.gradle.kts new file mode 100644 index 000000000..91c329e07 --- /dev/null +++ b/domain/attendance/build.gradle.kts @@ -0,0 +1,35 @@ +/* + * MIT License + * Copyright 2023-2025 SOPT - Shout Our Passion Together + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +plugins { + sopt("kotlin") +} + +kotlin{ + jvmToolchain(17) +} + +dependencies{ + implementation(libs.javax.inject) +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/official/domain/entity/attendance/AttendanceButtonType.kt b/domain/attendance/src/main/java/org/sopt/official/domain/attendance/entity/AttendanceButtonType.kt similarity index 95% rename from app/src/main/java/org/sopt/official/domain/entity/attendance/AttendanceButtonType.kt rename to domain/attendance/src/main/java/org/sopt/official/domain/attendance/entity/AttendanceButtonType.kt index 8ab550fae..19474285f 100644 --- a/app/src/main/java/org/sopt/official/domain/entity/attendance/AttendanceButtonType.kt +++ b/domain/attendance/src/main/java/org/sopt/official/domain/attendance/entity/AttendanceButtonType.kt @@ -1,6 +1,6 @@ /* * MIT License - * Copyright 2023-2024 SOPT - Shout Our Passion Together + * Copyright 2023-2025 SOPT - Shout Our Passion Together * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal @@ -22,7 +22,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -package org.sopt.official.domain.entity.attendance +package org.sopt.official.domain.attendance.entity enum class AttendanceButtonType( val attendanceRound: AttendanceRound, diff --git a/app/src/main/java/org/sopt/official/feature/attendance/CustomTextWatcher.kt b/domain/attendance/src/main/java/org/sopt/official/domain/attendance/entity/AttendanceCodeResponse.kt similarity index 73% rename from app/src/main/java/org/sopt/official/feature/attendance/CustomTextWatcher.kt rename to domain/attendance/src/main/java/org/sopt/official/domain/attendance/entity/AttendanceCodeResponse.kt index 10e4d8b3e..7010ed16a 100644 --- a/app/src/main/java/org/sopt/official/feature/attendance/CustomTextWatcher.kt +++ b/domain/attendance/src/main/java/org/sopt/official/domain/attendance/entity/AttendanceCodeResponse.kt @@ -1,6 +1,6 @@ /* * MIT License - * Copyright 2023 SOPT - Shout Our Passion Together + * Copyright 2023-2025 SOPT - Shout Our Passion Together * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal @@ -22,17 +22,12 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -package org.sopt.official.feature.attendance +package org.sopt.official.domain.attendance.entity -import android.widget.EditText -import androidx.core.widget.doAfterTextChanged - -fun EditText.requestFocusAfterTextChanged(to: EditText, otherLogic: () -> Unit = {}) { - doAfterTextChanged { - if (it?.length == 1) { - to.requestFocus() - this.isEnabled = false - } - otherLogic() +data class AttendanceCodeResponse( + val subLectureId: Long +) { + companion object { + val ERROR = AttendanceCodeResponse(-2) } } diff --git a/app/src/main/java/org/sopt/official/domain/entity/attendance/AttendanceErrorCode.kt b/domain/attendance/src/main/java/org/sopt/official/domain/attendance/entity/AttendanceErrorCode.kt similarity index 67% rename from app/src/main/java/org/sopt/official/domain/entity/attendance/AttendanceErrorCode.kt rename to domain/attendance/src/main/java/org/sopt/official/domain/attendance/entity/AttendanceErrorCode.kt index 54f1ff4e7..178162829 100644 --- a/app/src/main/java/org/sopt/official/domain/entity/attendance/AttendanceErrorCode.kt +++ b/domain/attendance/src/main/java/org/sopt/official/domain/attendance/entity/AttendanceErrorCode.kt @@ -1,6 +1,6 @@ /* * MIT License - * Copyright 2023 SOPT - Shout Our Passion Together + * Copyright 2023-2025 SOPT - Shout Our Passion Together * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal @@ -22,22 +22,22 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -package org.sopt.official.domain.entity.attendance - -import org.sopt.official.data.model.attendance.AttendanceCodeResponse +package org.sopt.official.domain.attendance.entity enum class AttendanceErrorCode( - val attendanceErrorCode: AttendanceCodeResponse, - val messages: List + val errorCode: Long, + val message: String, + val serverMessages: List ) { - WRONG_CODE(AttendanceCodeResponse(-2), listOf("[LectureException] : 코드가 일치하지 않아요!")), - BEFORE_ATTENDANCE(AttendanceCodeResponse(-1), listOf("[LectureException] : 1차 출석 시작 전입니다", "[LectureException] : 2차 출석 시작 전입니다")), + WRONG_CODE(-2L, "코드가 일치하지 않아요!", listOf("[LectureException] : 코드가 일치하지 않아요!")), + BEFORE_ATTENDANCE(-1L, "출석 시작 전입니다", listOf("[LectureException] : 1차 출석 시작 전입니다", "[LectureException] : 2차 출석 시작 전입니다")), AFTER_ATTENDANCE( - AttendanceCodeResponse(0), + 0L, + "출석이 이미 종료되었습니다", listOf("[LectureException] : 1차 출석이 이미 종료되었습니다.", "[LectureException] : 2차 출석이 이미 종료되었습니다.") ); companion object { - fun of(message: String) = entries.find { it.messages.contains(message) }?.attendanceErrorCode + fun of(message: String) = entries.find { it.serverMessages.contains(message) } } } diff --git a/app/src/main/java/org/sopt/official/domain/entity/attendance/AttendanceHistory.kt b/domain/attendance/src/main/java/org/sopt/official/domain/attendance/entity/AttendanceHistory.kt similarity index 92% rename from app/src/main/java/org/sopt/official/domain/entity/attendance/AttendanceHistory.kt rename to domain/attendance/src/main/java/org/sopt/official/domain/attendance/entity/AttendanceHistory.kt index df2507d41..f37a1e348 100644 --- a/app/src/main/java/org/sopt/official/domain/entity/attendance/AttendanceHistory.kt +++ b/domain/attendance/src/main/java/org/sopt/official/domain/attendance/entity/AttendanceHistory.kt @@ -1,6 +1,6 @@ /* * MIT License - * Copyright 2023 SOPT - Shout Our Passion Together + * Copyright 2023-2025 SOPT - Shout Our Passion Together * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal @@ -22,7 +22,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -package org.sopt.official.domain.entity.attendance +package org.sopt.official.domain.attendance.entity data class AttendanceHistory( val userInfo: AttendanceUserInfo, diff --git a/app/src/main/java/org/sopt/official/domain/entity/attendance/AttendanceLog.kt b/domain/attendance/src/main/java/org/sopt/official/domain/attendance/entity/AttendanceLog.kt similarity index 92% rename from app/src/main/java/org/sopt/official/domain/entity/attendance/AttendanceLog.kt rename to domain/attendance/src/main/java/org/sopt/official/domain/attendance/entity/AttendanceLog.kt index 7b6366a8a..12742e328 100644 --- a/app/src/main/java/org/sopt/official/domain/entity/attendance/AttendanceLog.kt +++ b/domain/attendance/src/main/java/org/sopt/official/domain/attendance/entity/AttendanceLog.kt @@ -1,6 +1,6 @@ /* * MIT License - * Copyright 2023 SOPT - Shout Our Passion Together + * Copyright 2023-2025 SOPT - Shout Our Passion Together * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal @@ -22,7 +22,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -package org.sopt.official.domain.entity.attendance +package org.sopt.official.domain.attendance.entity data class AttendanceLog( val attribute: EventAttribute, diff --git a/app/src/main/java/org/sopt/official/domain/entity/attendance/AttendanceRound.kt b/domain/attendance/src/main/java/org/sopt/official/domain/attendance/entity/AttendanceRound.kt similarity index 92% rename from app/src/main/java/org/sopt/official/domain/entity/attendance/AttendanceRound.kt rename to domain/attendance/src/main/java/org/sopt/official/domain/attendance/entity/AttendanceRound.kt index 5df8f3f6d..d11330774 100644 --- a/app/src/main/java/org/sopt/official/domain/entity/attendance/AttendanceRound.kt +++ b/domain/attendance/src/main/java/org/sopt/official/domain/attendance/entity/AttendanceRound.kt @@ -1,6 +1,6 @@ /* * MIT License - * Copyright 2023 SOPT - Shout Our Passion Together + * Copyright 2023-2025 SOPT - Shout Our Passion Together * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal @@ -22,7 +22,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -package org.sopt.official.domain.entity.attendance +package org.sopt.official.domain.attendance.entity data class AttendanceRound( val id: Long, diff --git a/app/src/main/java/org/sopt/official/domain/entity/attendance/AttendanceStatus.kt b/domain/attendance/src/main/java/org/sopt/official/domain/attendance/entity/AttendanceStatus.kt similarity index 92% rename from app/src/main/java/org/sopt/official/domain/entity/attendance/AttendanceStatus.kt rename to domain/attendance/src/main/java/org/sopt/official/domain/attendance/entity/AttendanceStatus.kt index 025a8813e..80cd41e09 100644 --- a/app/src/main/java/org/sopt/official/domain/entity/attendance/AttendanceStatus.kt +++ b/domain/attendance/src/main/java/org/sopt/official/domain/attendance/entity/AttendanceStatus.kt @@ -1,6 +1,6 @@ /* * MIT License - * Copyright 2023-2024 SOPT - Shout Our Passion Together + * Copyright 2023-2025 SOPT - Shout Our Passion Together * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal @@ -22,7 +22,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -package org.sopt.official.domain.entity.attendance +package org.sopt.official.domain.attendance.entity enum class AttendanceStatus(val statusKorean: String) { ATTENDANCE("출석"), diff --git a/app/src/main/java/org/sopt/official/domain/entity/attendance/AttendanceSummary.kt b/domain/attendance/src/main/java/org/sopt/official/domain/attendance/entity/AttendanceSummary.kt similarity index 92% rename from app/src/main/java/org/sopt/official/domain/entity/attendance/AttendanceSummary.kt rename to domain/attendance/src/main/java/org/sopt/official/domain/attendance/entity/AttendanceSummary.kt index 6bc583690..81f84bbcc 100644 --- a/app/src/main/java/org/sopt/official/domain/entity/attendance/AttendanceSummary.kt +++ b/domain/attendance/src/main/java/org/sopt/official/domain/attendance/entity/AttendanceSummary.kt @@ -1,6 +1,6 @@ /* * MIT License - * Copyright 2023 SOPT - Shout Our Passion Together + * Copyright 2023-2025 SOPT - Shout Our Passion Together * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal @@ -22,7 +22,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -package org.sopt.official.domain.entity.attendance +package org.sopt.official.domain.attendance.entity data class AttendanceSummary( val normal: Int, diff --git a/app/src/main/java/org/sopt/official/domain/entity/attendance/AttendanceUserInfo.kt b/domain/attendance/src/main/java/org/sopt/official/domain/attendance/entity/AttendanceUserInfo.kt similarity index 92% rename from app/src/main/java/org/sopt/official/domain/entity/attendance/AttendanceUserInfo.kt rename to domain/attendance/src/main/java/org/sopt/official/domain/attendance/entity/AttendanceUserInfo.kt index dcb9453ba..487ac5951 100644 --- a/app/src/main/java/org/sopt/official/domain/entity/attendance/AttendanceUserInfo.kt +++ b/domain/attendance/src/main/java/org/sopt/official/domain/attendance/entity/AttendanceUserInfo.kt @@ -1,6 +1,6 @@ /* * MIT License - * Copyright 2023 SOPT - Shout Our Passion Together + * Copyright 2023-2025 SOPT - Shout Our Passion Together * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal @@ -22,7 +22,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -package org.sopt.official.domain.entity.attendance +package org.sopt.official.domain.attendance.entity data class AttendanceUserInfo( val generation: Int, diff --git a/app/src/main/java/org/sopt/official/domain/entity/attendance/EventAttribute.kt b/domain/attendance/src/main/java/org/sopt/official/domain/attendance/entity/EventAttribute.kt similarity index 91% rename from app/src/main/java/org/sopt/official/domain/entity/attendance/EventAttribute.kt rename to domain/attendance/src/main/java/org/sopt/official/domain/attendance/entity/EventAttribute.kt index 96aeaf96f..7e2e5cbd1 100644 --- a/app/src/main/java/org/sopt/official/domain/entity/attendance/EventAttribute.kt +++ b/domain/attendance/src/main/java/org/sopt/official/domain/attendance/entity/EventAttribute.kt @@ -1,6 +1,6 @@ /* * MIT License - * Copyright 2023-2024 SOPT - Shout Our Passion Together + * Copyright 2023-2025 SOPT - Shout Our Passion Together * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal @@ -22,7 +22,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -package org.sopt.official.domain.entity.attendance +package org.sopt.official.domain.attendance.entity enum class EventAttribute { SEMINAR, diff --git a/app/src/main/java/org/sopt/official/domain/entity/attendance/EventType.kt b/domain/attendance/src/main/java/org/sopt/official/domain/attendance/entity/EventType.kt similarity index 91% rename from app/src/main/java/org/sopt/official/domain/entity/attendance/EventType.kt rename to domain/attendance/src/main/java/org/sopt/official/domain/attendance/entity/EventType.kt index 35084ffa0..0d1470b8d 100644 --- a/app/src/main/java/org/sopt/official/domain/entity/attendance/EventType.kt +++ b/domain/attendance/src/main/java/org/sopt/official/domain/attendance/entity/EventType.kt @@ -1,6 +1,6 @@ /* * MIT License - * Copyright 2023-2024 SOPT - Shout Our Passion Together + * Copyright 2023-2025 SOPT - Shout Our Passion Together * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal @@ -22,7 +22,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -package org.sopt.official.domain.entity.attendance +package org.sopt.official.domain.attendance.entity enum class EventType { NO_SESSION, diff --git a/app/src/main/java/org/sopt/official/domain/entity/attendance/Part.kt b/domain/attendance/src/main/java/org/sopt/official/domain/attendance/entity/Part.kt similarity index 92% rename from app/src/main/java/org/sopt/official/domain/entity/attendance/Part.kt rename to domain/attendance/src/main/java/org/sopt/official/domain/attendance/entity/Part.kt index 0b828529c..1d33c13af 100644 --- a/app/src/main/java/org/sopt/official/domain/entity/attendance/Part.kt +++ b/domain/attendance/src/main/java/org/sopt/official/domain/attendance/entity/Part.kt @@ -1,6 +1,6 @@ /* * MIT License - * Copyright 2023-2024 SOPT - Shout Our Passion Together + * Copyright 2023-2025 SOPT - Shout Our Passion Together * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal @@ -22,7 +22,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -package org.sopt.official.domain.entity.attendance +package org.sopt.official.domain.attendance.entity enum class Part(val partName: String) { PLAN("기획"), diff --git a/app/src/main/java/org/sopt/official/domain/entity/attendance/SoptEvent.kt b/domain/attendance/src/main/java/org/sopt/official/domain/attendance/entity/SoptEvent.kt similarity index 93% rename from app/src/main/java/org/sopt/official/domain/entity/attendance/SoptEvent.kt rename to domain/attendance/src/main/java/org/sopt/official/domain/attendance/entity/SoptEvent.kt index 755fe5dad..a1fbebf8f 100644 --- a/app/src/main/java/org/sopt/official/domain/entity/attendance/SoptEvent.kt +++ b/domain/attendance/src/main/java/org/sopt/official/domain/attendance/entity/SoptEvent.kt @@ -1,6 +1,6 @@ /* * MIT License - * Copyright 2023 SOPT - Shout Our Passion Together + * Copyright 2023-2025 SOPT - Shout Our Passion Together * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal @@ -22,7 +22,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -package org.sopt.official.domain.entity.attendance +package org.sopt.official.domain.attendance.entity data class SoptEvent( val id: Int, diff --git a/app/src/main/java/org/sopt/official/domain/repository/attendance/AttendanceRepository.kt b/domain/attendance/src/main/java/org/sopt/official/domain/attendance/repository/AttendanceRepository.kt similarity index 80% rename from app/src/main/java/org/sopt/official/domain/repository/attendance/AttendanceRepository.kt rename to domain/attendance/src/main/java/org/sopt/official/domain/attendance/repository/AttendanceRepository.kt index 5569262d0..41894bf6f 100644 --- a/app/src/main/java/org/sopt/official/domain/repository/attendance/AttendanceRepository.kt +++ b/domain/attendance/src/main/java/org/sopt/official/domain/attendance/repository/AttendanceRepository.kt @@ -1,6 +1,6 @@ /* * MIT License - * Copyright 2023-2024 SOPT - Shout Our Passion Together + * Copyright 2023-2025 SOPT - Shout Our Passion Together * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal @@ -22,12 +22,12 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -package org.sopt.official.domain.repository.attendance +package org.sopt.official.domain.attendance.repository -import org.sopt.official.data.model.attendance.AttendanceCodeResponse -import org.sopt.official.domain.entity.attendance.AttendanceHistory -import org.sopt.official.domain.entity.attendance.AttendanceRound -import org.sopt.official.domain.entity.attendance.SoptEvent +import org.sopt.official.domain.attendance.entity.AttendanceCodeResponse +import org.sopt.official.domain.attendance.entity.AttendanceHistory +import org.sopt.official.domain.attendance.entity.AttendanceRound +import org.sopt.official.domain.attendance.entity.SoptEvent interface AttendanceRepository { suspend fun fetchSoptEvent(): Result diff --git a/feature/attendance/build.gradle.kts b/feature/attendance/build.gradle.kts new file mode 100644 index 000000000..4ea581bd9 --- /dev/null +++ b/feature/attendance/build.gradle.kts @@ -0,0 +1,39 @@ +/* + * MIT License + * Copyright 2023-2025 SOPT - Shout Our Passion Together + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +plugins { + sopt("feature") + sopt("compose") + sopt("deeplink") +} + +android { + namespace = "org.sopt.official.feature.attendance" +} + +dependencies { + implementation(projects.domain.attendance) + implementation(projects.core.common) + implementation(projects.core.designsystem) +} \ No newline at end of file diff --git a/feature/attendance/src/main/AndroidManifest.xml b/feature/attendance/src/main/AndroidManifest.xml new file mode 100644 index 000000000..cfb86d6dc --- /dev/null +++ b/feature/attendance/src/main/AndroidManifest.xml @@ -0,0 +1,33 @@ + + + + + + + diff --git a/feature/attendance/src/main/java/org/sopt/official/feature/attendance/AttendanceActivity.kt b/feature/attendance/src/main/java/org/sopt/official/feature/attendance/AttendanceActivity.kt new file mode 100644 index 000000000..ecb8166d5 --- /dev/null +++ b/feature/attendance/src/main/java/org/sopt/official/feature/attendance/AttendanceActivity.kt @@ -0,0 +1,57 @@ +/* + * MIT License + * Copyright 2024-2025 SOPT - Shout Our Passion Together + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.sopt.official.feature.attendance + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import com.airbnb.deeplinkdispatch.DeepLink +import dagger.hilt.android.AndroidEntryPoint +import org.sopt.official.designsystem.SoptTheme + +@AndroidEntryPoint +@DeepLink("sopt://attendance") +class AttendanceActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + setContent { + SoptTheme { + AttendanceScreen( + onBackClick = { finish() } + ) + } + } + } + + companion object { + fun newInstance(context: Context) = Intent(context, AttendanceActivity::class.java) + } +} diff --git a/app/src/main/java/org/sopt/official/feature/attendance/adapter/LogHeaderViewHolder.kt b/feature/attendance/src/main/java/org/sopt/official/feature/attendance/AttendanceNavigation.kt similarity index 60% rename from app/src/main/java/org/sopt/official/feature/attendance/adapter/LogHeaderViewHolder.kt rename to feature/attendance/src/main/java/org/sopt/official/feature/attendance/AttendanceNavigation.kt index bab63df7d..238cf0dd5 100644 --- a/app/src/main/java/org/sopt/official/feature/attendance/adapter/LogHeaderViewHolder.kt +++ b/feature/attendance/src/main/java/org/sopt/official/feature/attendance/AttendanceNavigation.kt @@ -1,6 +1,6 @@ /* * MIT License - * Copyright 2023 SOPT - Shout Our Passion Together + * Copyright 2024-2025 SOPT - Shout Our Passion Together * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal @@ -22,24 +22,24 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -package org.sopt.official.feature.attendance.adapter +package org.sopt.official.feature.attendance -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import org.sopt.official.databinding.ItemAttendanceHistoryLogHeaderBinding +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable -class LogHeaderViewHolder( - binding: ItemAttendanceHistoryLogHeaderBinding -) : RecyclerView.ViewHolder(binding.root) { - companion object { - fun create(parent: ViewGroup): LogHeaderViewHolder { - val binding = ItemAttendanceHistoryLogHeaderBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - return LogHeaderViewHolder(binding) - } +const val AttendanceRoute = "attendance" + +fun NavController.navigateToAttendance() { + navigate(AttendanceRoute) +} + +fun NavGraphBuilder.attendanceScreen( + onBackClick: () -> Unit +) { + composable(route = AttendanceRoute) { + AttendanceScreen( + onBackClick = onBackClick + ) } } diff --git a/feature/attendance/src/main/java/org/sopt/official/feature/attendance/AttendanceScreen.kt b/feature/attendance/src/main/java/org/sopt/official/feature/attendance/AttendanceScreen.kt new file mode 100644 index 000000000..5830a66dd --- /dev/null +++ b/feature/attendance/src/main/java/org/sopt/official/feature/attendance/AttendanceScreen.kt @@ -0,0 +1,209 @@ +/* + * MIT License + * Copyright 2025 SOPT - Shout Our Passion Together + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.sopt.official.feature.attendance + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.sopt.official.designsystem.Gray10 +import org.sopt.official.designsystem.Gray300 +import org.sopt.official.domain.attendance.entity.SoptEvent +import org.sopt.official.feature.attendance.component.AttendanceButton +import org.sopt.official.feature.attendance.component.AttendanceCodeDialog +import org.sopt.official.feature.attendance.component.AttendanceEventInfoCard +import org.sopt.official.feature.attendance.component.AttendanceHistoryList +import org.sopt.official.feature.attendance.component.AttendanceTopBar +import org.sopt.official.feature.attendance.model.AttendanceConstants +import org.sopt.official.feature.attendance.model.AttendanceDialogState + +@Composable +fun AttendanceScreen( + onBackClick: () -> Unit, + viewModel: AttendanceViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val dialogState by viewModel.dialogState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.loadAttendanceData() + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + ) { + Column( + modifier = Modifier.fillMaxSize() + ) { + AttendanceTopBar( + onBackClick = onBackClick, + onRefreshClick = { viewModel.loadAttendanceData() } + ) + + when { + uiState.isDataReady -> { + AttendanceContent( + soptEvent = uiState.soptEvent!!, + attendanceHistory = uiState.attendanceHistory!!, + buttonState = uiState.buttonState, + progressState = uiState.progressState, + onAttendanceClick = { + viewModel.showAttendanceDialog() + }, + modifier = Modifier.weight(1f) + ) + } + uiState.isLoading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + color = Gray10 + ) + } + } + uiState.hasError -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = uiState.error ?: "출석 정보를 불러올 수 없습니다.", + color = Gray300, + fontSize = 14.sp + ) + } + } + } + } + + when (val currentDialogState = dialogState) { + is AttendanceDialogState.CodeInput -> { + AttendanceCodeDialog( + title = currentDialogState.title, + onDismiss = { viewModel.closeDialog() }, + onCodeSubmit = { code -> viewModel.checkAttendanceCode(code) }, + errorMessage = null + ) + } + is AttendanceDialogState.Error -> { + AttendanceCodeDialog( + title = currentDialogState.title, + onDismiss = { viewModel.closeDialog() }, + onCodeSubmit = { code -> viewModel.checkAttendanceCode(code) }, + errorMessage = currentDialogState.message + ) + } + is AttendanceDialogState.Hidden -> { + // No dialog + } + } + } +} + +@Composable +private fun AttendanceContent( + soptEvent: SoptEvent, + attendanceHistory: org.sopt.official.domain.attendance.entity.AttendanceHistory, + buttonState: org.sopt.official.feature.attendance.model.AttendanceButtonState, + progressState: org.sopt.official.feature.attendance.model.ProgressBarUIState, + onAttendanceClick: () -> Unit, + modifier: Modifier = Modifier +) { + Box(modifier = modifier) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + start = 20.dp, + end = 20.dp, + top = 16.dp, + bottom = AttendanceConstants.BOTTOM_PADDING_DP.dp + ), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + item { + AttendanceEventInfoCard( + soptEvent = soptEvent, + progressState = progressState + ) + } + + item { + AttendanceHistoryList( + attendanceHistory = attendanceHistory + ) + } + } + + Box( + modifier = Modifier + .fillMaxWidth() + .height(AttendanceConstants.GRADIENT_HEIGHT_DP.dp) + .align(Alignment.BottomCenter) + .background( + brush = Brush.verticalGradient( + colors = listOf( + Color.Transparent, + Color.Black + ) + ) + ) + ) + + if (buttonState.isVisible) { + AttendanceButton( + text = buttonState.text, + isEnabled = buttonState.isEnabled, + onClick = onAttendanceClick, + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .padding(horizontal = 20.dp, vertical = 16.dp) + ) + } + } +} diff --git a/feature/attendance/src/main/java/org/sopt/official/feature/attendance/AttendanceScreenPreviews.kt b/feature/attendance/src/main/java/org/sopt/official/feature/attendance/AttendanceScreenPreviews.kt new file mode 100644 index 000000000..39ba40542 --- /dev/null +++ b/feature/attendance/src/main/java/org/sopt/official/feature/attendance/AttendanceScreenPreviews.kt @@ -0,0 +1,311 @@ +/* + * MIT License + * Copyright 2025 SOPT - Shout Our Passion Together + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.sopt.official.feature.attendance + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.sopt.official.designsystem.Gray10 +import org.sopt.official.designsystem.Gray300 +import org.sopt.official.designsystem.SoptTheme +import org.sopt.official.domain.attendance.entity.AttendanceHistory +import org.sopt.official.domain.attendance.entity.AttendanceLog +import org.sopt.official.domain.attendance.entity.AttendanceStatus +import org.sopt.official.domain.attendance.entity.AttendanceSummary +import org.sopt.official.domain.attendance.entity.AttendanceUserInfo +import org.sopt.official.domain.attendance.entity.EventType +import org.sopt.official.domain.attendance.entity.EventAttribute +import org.sopt.official.domain.attendance.entity.SoptEvent +import org.sopt.official.feature.attendance.component.AttendanceButton +import org.sopt.official.feature.attendance.component.AttendanceEventInfoCard +import org.sopt.official.feature.attendance.component.AttendanceHistoryList +import org.sopt.official.feature.attendance.component.AttendanceTopBar +import org.sopt.official.feature.attendance.model.AttendanceButtonState +import org.sopt.official.feature.attendance.model.AttendanceConstants +import org.sopt.official.feature.attendance.model.ProgressBarUIState + +// Sample data for previews +private val sampleSoptEvent = SoptEvent( + id = 1, + eventType = EventType.HAS_ATTENDANCE, + date = "2024-01-15", + location = "건국대학교 새천년관", + eventName = "1차 세미나", + message = "Android 기초 세미나입니다.", + isAttendancePointAwardedEvent = true, + attendances = listOf( + SoptEvent.Attendance( + status = AttendanceStatus.ATTENDANCE, + attendedAt = "19:30" + ) + ) +) + +private val sampleAttendanceHistory = AttendanceHistory( + userInfo = AttendanceUserInfo( + generation = 34, + partName = "Android", + userName = "김솝트", + attendancePoint = 15 + ), + attendanceSummary = AttendanceSummary( + normal = 5, + late = 2, + abnormal = 1, + participate = 1 + ), + attendanceLog = listOf( + AttendanceLog( + attribute = EventAttribute.SEMINAR, + attendanceState = "출석", + eventName = "1차 세미나", + date = "2024-01-15" + ), + AttendanceLog( + attribute = EventAttribute.SEMINAR, + attendanceState = "결석", + eventName = "2차 세미나", + date = "2024-01-22" + ) + ) +) + +@Preview(name = "AttendanceScreen - Success State", showBackground = true) +@Composable +private fun AttendanceScreenSuccessPreview() { + SoptTheme { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + ) { + Column( + modifier = Modifier.fillMaxSize() + ) { + AttendanceTopBar( + onBackClick = { }, + onRefreshClick = { } + ) + + AttendanceContentPreview( + soptEvent = sampleSoptEvent, + attendanceHistory = sampleAttendanceHistory, + buttonState = AttendanceButtonState.visible("1차 출석", true), + progressState = ProgressBarUIState( + isFirstProgressBarActive = true, + isFirstProgressBarAttendance = true, + isFirstToSecondLineActive = true + ), + onAttendanceClick = { }, + modifier = Modifier.weight(1f) + ) + } + } + } +} + +@Preview(name = "AttendanceScreen - Loading State", showBackground = true) +@Composable +private fun AttendanceScreenLoadingPreview() { + SoptTheme { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + ) { + Column( + modifier = Modifier.fillMaxSize() + ) { + AttendanceTopBar( + onBackClick = { }, + onRefreshClick = { } + ) + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + color = Gray10 + ) + } + } + } + } +} + +@Preview(name = "AttendanceScreen - Error State", showBackground = true) +@Composable +private fun AttendanceScreenErrorPreview() { + SoptTheme { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + ) { + Column( + modifier = Modifier.fillMaxSize() + ) { + AttendanceTopBar( + onBackClick = { }, + onRefreshClick = { } + ) + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "출석 정보를 불러올 수 없습니다.", + color = Gray300, + fontSize = 14.sp + ) + } + } + } + } +} + +@Preview(name = "AttendanceContent - Button Visible", showBackground = true) +@Composable +private fun AttendanceContentPreview() { + SoptTheme { + AttendanceContentPreview( + soptEvent = sampleSoptEvent, + attendanceHistory = sampleAttendanceHistory, + buttonState = AttendanceButtonState.visible("1차 출석", true), + progressState = ProgressBarUIState( + isFirstProgressBarActive = true, + isFirstProgressBarAttendance = true, + isFirstToSecondLineActive = true + ), + onAttendanceClick = { } + ) + } +} + +@Preview(name = "AttendanceContent - Button Hidden", showBackground = true) +@Composable +private fun AttendanceContentNoButtonPreview() { + SoptTheme { + AttendanceContentPreview( + soptEvent = sampleSoptEvent, + attendanceHistory = sampleAttendanceHistory, + buttonState = AttendanceButtonState.Hidden, + progressState = ProgressBarUIState( + isFirstProgressBarActive = true, + isFirstProgressBarAttendance = true, + isFirstToSecondLineActive = true, + isSecondProgressBarActive = true, + isSecondProgressBarAttendance = true, + isSecondToThirdLineActive = true, + isThirdProgressBarActive = true, + isThirdProgressBarAttendance = true, + isThirdProgressBarBeforeAttendance = true + ), + onAttendanceClick = { } + ) + } +} + +@Composable +private fun AttendanceContentPreview( + soptEvent: SoptEvent, + attendanceHistory: AttendanceHistory, + buttonState: AttendanceButtonState, + progressState: ProgressBarUIState, + onAttendanceClick: () -> Unit, + modifier: Modifier = Modifier +) { + Box(modifier = modifier.fillMaxSize().background(Color.Black)) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + start = 20.dp, + end = 20.dp, + top = 16.dp, + bottom = AttendanceConstants.BOTTOM_PADDING_DP.dp + ), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + item { + AttendanceEventInfoCard( + soptEvent = soptEvent, + progressState = progressState + ) + } + + item { + AttendanceHistoryList( + attendanceHistory = attendanceHistory + ) + } + } + + Box( + modifier = Modifier + .fillMaxWidth() + .height(AttendanceConstants.GRADIENT_HEIGHT_DP.dp) + .align(Alignment.BottomCenter) + .background( + brush = Brush.verticalGradient( + colors = listOf( + Color.Transparent, + Color.Black + ) + ) + ) + ) + + if (buttonState.isVisible) { + AttendanceButton( + text = buttonState.text, + isEnabled = buttonState.isEnabled, + onClick = onAttendanceClick, + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .padding(horizontal = 20.dp, vertical = 16.dp) + ) + } + } +} diff --git a/feature/attendance/src/main/java/org/sopt/official/feature/attendance/AttendanceViewModel.kt b/feature/attendance/src/main/java/org/sopt/official/feature/attendance/AttendanceViewModel.kt new file mode 100644 index 000000000..350789973 --- /dev/null +++ b/feature/attendance/src/main/java/org/sopt/official/feature/attendance/AttendanceViewModel.kt @@ -0,0 +1,174 @@ +/* + * MIT License + * Copyright 2025 SOPT - Shout Our Passion Together + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.sopt.official.feature.attendance + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.sopt.official.common.coroutines.suspendRunCatching +import org.sopt.official.domain.attendance.entity.AttendanceHistory +import org.sopt.official.domain.attendance.entity.AttendanceRound +import org.sopt.official.domain.attendance.entity.SoptEvent +import org.sopt.official.domain.attendance.repository.AttendanceRepository +import org.sopt.official.feature.attendance.model.AttendanceConstants +import org.sopt.official.feature.attendance.model.AttendanceDialogState +import org.sopt.official.feature.attendance.model.AttendanceUiState +import org.sopt.official.feature.attendance.usecase.LoadAttendanceDataUseCase +import javax.inject.Inject + +@HiltViewModel +class AttendanceViewModel @Inject constructor( + private val attendanceRepository: AttendanceRepository, + private val loadAttendanceDataUseCase: LoadAttendanceDataUseCase +) : ViewModel() { + + // 단일 UI 상태 + private val _uiState = MutableStateFlow(AttendanceUiState.Initial) + val uiState: StateFlow = _uiState.asStateFlow() + + // 다이얼로그 상태 (UI 상태와 별도 관리) + private val _dialogState = MutableStateFlow(AttendanceDialogState.Hidden) + val dialogState: StateFlow = _dialogState.asStateFlow() + + // 현재 로드된 데이터 (내부 상태) + private var currentSoptEvent: SoptEvent? = null + private var currentAttendanceRound: AttendanceRound? = null + + init { + loadAttendanceData() + } + + /** + * 출석 데이터 로드 + */ + fun loadAttendanceData() { + viewModelScope.launch { + _uiState.value = AttendanceUiState.Initial + + loadAttendanceDataUseCase() + .onSuccess { uiState -> + _uiState.value = uiState + // 현재 상태 업데이트 (다이얼로그용) + currentSoptEvent = uiState.soptEvent + currentAttendanceRound = loadCurrentAttendanceRound(uiState.soptEvent?.id?.toLong()) + } + .onFailure { error -> + _uiState.value = AttendanceUiState.error( + message = error.message ?: "알 수 없는 오류가 발생했습니다" + ) + } + } + } + + /** + * 현재 출석 라운드 정보 로드 (다이얼로그용) + */ + private suspend fun loadCurrentAttendanceRound(eventId: Long?): AttendanceRound? { + if (eventId == null) return null + return suspendRunCatching { + attendanceRepository.fetchAttendanceRound(eventId) + }.mapCatching { result -> + result.getOrThrow() + }.getOrNull() + } + + /** + * 출석 코드 입력 다이얼로그 표시 + */ + fun showAttendanceDialog() { + val title = currentSoptEvent?.eventName ?: "출석 확인" + _dialogState.value = AttendanceDialogState.CodeInput(title) + } + + /** + * 다이얼로그 닫기 + */ + fun closeDialog() { + _dialogState.value = AttendanceDialogState.Hidden + } + + /** + * 출석 코드 확인 + */ + fun checkAttendanceCode(code: String) { + val subLectureId = currentAttendanceRound?.id + if (subLectureId == null || subLectureId <= 0) { + showErrorDialog("출석 정보를 불러올 수 없습니다") + return + } + + viewModelScope.launch { + suspendRunCatching { + attendanceRepository.confirmAttendanceCode(subLectureId, code) + }.mapCatching { result -> + result.getOrThrow() + }.fold( + onSuccess = { response -> + handleAttendanceCodeResult(response.subLectureId) + }, + onFailure = { + showErrorDialog(AttendanceConstants.ERROR_INVALID_CODE) + } + ) + } + } + + /** + * 출석 코드 확인 결과 처리 + */ + private fun handleAttendanceCodeResult(resultCode: Long) { + when (resultCode) { + AttendanceConstants.ERROR_CODE -> { + showErrorDialog(AttendanceConstants.ERROR_WRONG_CODE) + } + + AttendanceConstants.NO_SESSION_CODE -> { + showErrorDialog(AttendanceConstants.ERROR_BEFORE_TIME) + } + + AttendanceConstants.TIME_RESTRICTION_CODE -> { + showErrorDialog(AttendanceConstants.ERROR_AFTER_TIME) + } + + else -> { + // 성공 - 다이얼로그 닫고 데이터 새로고침 + closeDialog() + loadAttendanceData() + } + } + } + + /** + * 에러 다이얼로그 표시 + */ + private fun showErrorDialog(message: String) { + val title = currentSoptEvent?.eventName ?: "출석 확인" + _dialogState.value = AttendanceDialogState.Error(title, message) + } +} diff --git a/feature/attendance/src/main/java/org/sopt/official/feature/attendance/component/AttendanceButton.kt b/feature/attendance/src/main/java/org/sopt/official/feature/attendance/component/AttendanceButton.kt new file mode 100644 index 000000000..0be5e0a9b --- /dev/null +++ b/feature/attendance/src/main/java/org/sopt/official/feature/attendance/component/AttendanceButton.kt @@ -0,0 +1,70 @@ +/* + * MIT License + * Copyright 2024-2025 SOPT - Shout Our Passion Together + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.sopt.official.feature.attendance.component + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.sopt.official.designsystem.Gray400 +import org.sopt.official.designsystem.Gray700 +import org.sopt.official.designsystem.Orange500 + +@Composable +fun AttendanceButton( + text: String, + isEnabled: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val (backgroundColor, textColor) = if (isEnabled) { + Pair(Orange500, Color.Black) + } else { + Pair(Gray700, Gray400) + } + + Button( + onClick = onClick, + enabled = isEnabled, + modifier = modifier.height(56.dp), + shape = RoundedCornerShape(10.dp), + colors = ButtonDefaults.buttonColors( + containerColor = backgroundColor, + disabledContainerColor = backgroundColor + ) + ) { + Text( + text = text, + color = textColor, + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) + } +} diff --git a/feature/attendance/src/main/java/org/sopt/official/feature/attendance/component/AttendanceCodeDialog.kt b/feature/attendance/src/main/java/org/sopt/official/feature/attendance/component/AttendanceCodeDialog.kt new file mode 100644 index 000000000..cc5aecdf1 --- /dev/null +++ b/feature/attendance/src/main/java/org/sopt/official/feature/attendance/component/AttendanceCodeDialog.kt @@ -0,0 +1,427 @@ +/* + * MIT License + * Copyright 2025 SOPT - Shout Our Passion Together + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.sopt.official.feature.attendance.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import org.sopt.official.designsystem.Gray10 +import org.sopt.official.designsystem.Gray300 +import org.sopt.official.designsystem.Gray400 +import org.sopt.official.designsystem.Gray50 +import org.sopt.official.designsystem.Gray700 +import org.sopt.official.designsystem.Gray800 +import org.sopt.official.designsystem.Orange500 +import org.sopt.official.feature.attendance.R +import org.sopt.official.feature.attendance.model.AttendanceConstants + +/** + * 출석 코드 입력 다이얼로그 + * + * @param title 다이얼로그 제목 + * @param onDismiss 다이얼로그 닫기 콜백 + * @param onCodeSubmit 코드 제출 콜백 + * @param errorMessage 에러 메시지 (null이면 표시하지 않음) + */ +@Composable +fun AttendanceCodeDialog( + title: String, + onDismiss: () -> Unit, + onCodeSubmit: (String) -> Unit, + errorMessage: String?, + modifier: Modifier = Modifier +) { + val codeInputState = rememberCodeInputState() + + // 에러 발생 시 입력 필드 초기화 + LaunchedEffect(errorMessage) { + if (errorMessage != null) { + codeInputState.clearAndFocusFirst() + } + } + + // 다이얼로그 열릴 때 첫 번째 필드에 포커스 + LaunchedEffect(Unit) { + codeInputState.focusFirst() + } + + Dialog(onDismissRequest = onDismiss) { + AttendanceCodeDialogContent( + title = title, + codeInputState = codeInputState, + errorMessage = errorMessage, + onDismiss = onDismiss, + onCodeSubmit = { onCodeSubmit(codeInputState.getCompleteCode()) }, + modifier = modifier + ) + } +} + +/** + * 다이얼로그 컨텐츠 + */ +@Composable +private fun AttendanceCodeDialogContent( + title: String, + codeInputState: CodeInputState, + errorMessage: String?, + onDismiss: () -> Unit, + onCodeSubmit: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)), + colors = CardDefaults.cardColors(containerColor = Gray800) + ) { + Column( + modifier = Modifier + .padding(16.dp) + .clickable { + // 빈 필드 클릭 시 해당 필드로 포커스 이동 + codeInputState.focusFirstEmpty() + }, + horizontalAlignment = Alignment.CenterHorizontally + ) { + DialogHeader(onDismiss = onDismiss) + + DialogTitle(title = title) + + Spacer(modifier = Modifier.height(16.dp)) + + DialogSubtitle() + + Spacer(modifier = Modifier.height(22.dp)) + + CodeInputFields(codeInputState = codeInputState) + + ErrorMessage(errorMessage = errorMessage) + + Spacer(modifier = Modifier.height(32.dp)) + + SubmitButton( + isEnabled = codeInputState.isComplete, + onClick = onCodeSubmit + ) + } + } +} + +/** + * 다이얼로그 헤더 (닫기 버튼) + */ +@Composable +private fun DialogHeader( + onDismiss: () -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + IconButton(onClick = onDismiss) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_close), + contentDescription = "닫기", + tint = Gray300 + ) + } + } +} + +/** + * 다이얼로그 제목 + */ +@Composable +private fun DialogTitle(title: String) { + Text( + text = title, + color = Gray10, + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) +} + +/** + * 다이얼로그 부제목 + */ +@Composable +private fun DialogSubtitle() { + Text( + text = "출석 코드 5자리를 입력해 주세요", + color = Gray300, + fontSize = 12.sp + ) +} + +/** + * 코드 입력 필드들 + */ +@Composable +private fun CodeInputFields( + codeInputState: CodeInputState +) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.width(AttendanceConstants.DIALOG_WIDTH_DP.dp) + ) { + codeInputState.codeValues.forEachIndexed { index, value -> + CodeInputField( + value = value, + isFocused = codeInputState.focusedIndex == index, + onValueChange = { newValue -> + codeInputState.updateValue(index, newValue) + }, + onFocus = { + codeInputState.onFieldFocused(index) + }, + onBackspace = { + codeInputState.handleBackspace(index) + }, + focusRequester = codeInputState.focusRequesters[index], + modifier = Modifier.weight(1f) + ) + } + } +} + +/** + * 에러 메시지 + */ +@Composable +private fun ErrorMessage( + errorMessage: String? +) { + if (errorMessage != null) { + Spacer(modifier = Modifier.height(18.dp)) + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.error, + fontSize = 14.sp + ) + } +} + +/** + * 제출 버튼 + */ +@Composable +private fun SubmitButton( + isEnabled: Boolean, + onClick: () -> Unit +) { + Button( + onClick = onClick, + enabled = isEnabled, + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + shape = RoundedCornerShape(6.dp), + colors = ButtonDefaults.buttonColors( + containerColor = if (isEnabled) Orange500 else Gray700, + disabledContainerColor = Gray700 + ) + ) { + Text( + text = "출석하기", + color = if (isEnabled) Color.Black else Gray400, + fontSize = 14.sp, + fontWeight = FontWeight.Bold + ) + } +} + +/** + * 개별 코드 입력 필드 + */ +@Composable +private fun CodeInputField( + value: String, + isFocused: Boolean, + onValueChange: (String) -> Unit, + onFocus: () -> Unit, + onBackspace: () -> Unit, + focusRequester: FocusRequester, + modifier: Modifier = Modifier +) { + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = modifier + .height(60.dp) + .clip(RoundedCornerShape(8.dp)) + .background(Gray700) + .border( + width = if (isFocused) 2.dp else 1.dp, + color = if (isFocused) Orange500 else Gray400, + shape = RoundedCornerShape(8.dp) + ) + .focusRequester(focusRequester) + .onFocusChanged { focusState -> + if (focusState.isFocused) { + onFocus() + } + } + .onKeyEvent { keyEvent -> + if (keyEvent.key == Key.Backspace) { + onBackspace() + true + } else { + false + } + }, + textStyle = TextStyle( + color = Gray50, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center + ), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Next + ), + singleLine = true, + decorationBox = { innerTextField -> + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + innerTextField() + } + } + ) +} + +/** + * 코드 입력 상태 관리 클래스 + */ +@Stable +private class CodeInputState( + private val keyboardController: androidx.compose.ui.platform.SoftwareKeyboardController? +) { + var codeValues by mutableStateOf(List(AttendanceConstants.CODE_INPUT_FIELDS) { "" }) + private set + + val focusRequesters = List(AttendanceConstants.CODE_INPUT_FIELDS) { FocusRequester() } + + var focusedIndex by mutableStateOf(-1) + private set + + val isComplete: Boolean + get() = codeValues.all { it.isNotEmpty() } + + fun updateValue(index: Int, newValue: String) { + if (newValue.length <= 1 && newValue.all { it.isDigit() }) { + codeValues = codeValues.toMutableList().apply { + this[index] = newValue + } + + // 값 입력 시 다음 필드로 자동 이동 + if (newValue.isNotEmpty() && index < AttendanceConstants.CODE_INPUT_FIELDS - 1) { + focusRequesters[index + 1].requestFocus() + } + } + } + + fun handleBackspace(index: Int) { + val value = codeValues[index] + when { + // 현재 필드가 비어있고 첫 번째가 아닌 경우 -> 이전 필드로 이동 + value.isEmpty() && index > 0 -> { + codeValues = codeValues.toMutableList().apply { + this[index - 1] = "" + } + focusRequesters[index - 1].requestFocus() + } + // 첫 번째 필드가 비어있는 경우 -> 키보드 숨김 + index == 0 && value.isEmpty() -> { + keyboardController?.hide() + } + } + } + + fun onFieldFocused(index: Int) { + focusedIndex = index + // 이미 값이 있는 필드 클릭 시 첫 번째 빈 필드로 이동 + if (codeValues[index].isNotEmpty()) { + focusFirstEmpty() + } + } + + fun focusFirst() { + focusRequesters[0].requestFocus() + } + + fun focusFirstEmpty() { + val targetIndex = codeValues.indexOfFirst { it.isEmpty() } + .takeIf { it != -1 } ?: (AttendanceConstants.CODE_INPUT_FIELDS - 1) + focusRequesters[targetIndex].requestFocus() + } + + fun clearAndFocusFirst() { + codeValues = List(AttendanceConstants.CODE_INPUT_FIELDS) { "" } + focusFirst() + } + + fun getCompleteCode(): String = codeValues.joinToString("") +} + +/** + * 코드 입력 상태를 기억하는 Composable + */ +@Composable +private fun rememberCodeInputState(): CodeInputState { + val keyboardController = LocalSoftwareKeyboardController.current + return remember { CodeInputState(keyboardController) } +} diff --git a/feature/attendance/src/main/java/org/sopt/official/feature/attendance/component/AttendanceEventInfoCard.kt b/feature/attendance/src/main/java/org/sopt/official/feature/attendance/component/AttendanceEventInfoCard.kt new file mode 100644 index 000000000..79fb5dcb1 --- /dev/null +++ b/feature/attendance/src/main/java/org/sopt/official/feature/attendance/component/AttendanceEventInfoCard.kt @@ -0,0 +1,154 @@ +/* + * MIT License + * Copyright 2025 SOPT - Shout Our Passion Together + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.sopt.official.feature.attendance.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.sopt.official.designsystem.Gray10 +import org.sopt.official.designsystem.Gray100 +import org.sopt.official.designsystem.Gray300 +import org.sopt.official.designsystem.Gray800 +import org.sopt.official.designsystem.Orange500 +import org.sopt.official.domain.attendance.entity.SoptEvent +import org.sopt.official.feature.attendance.model.ProgressBarUIState +import org.sopt.official.feature.attendance.R + +@Composable +fun AttendanceEventInfoCard( + soptEvent: SoptEvent, + progressState: ProgressBarUIState, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)), + colors = CardDefaults.cardColors(containerColor = Gray800) + ) { + Column( + modifier = Modifier.padding(24.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Event Date + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_attendance_event_date), + contentDescription = null, + tint = Gray300, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = soptEvent.date, + color = Gray300, + fontSize = 14.sp + ) + } + + // Event Location + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_attendance_event_location), + contentDescription = null, + tint = Gray300, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = soptEvent.location, + color = Gray300, + fontSize = 14.sp + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Event Name + val annotatedEventName = buildAnnotatedString { + val message = soptEvent.message + val eventName = soptEvent.eventName + + if (message.isNotEmpty() && eventName.contains(message)) { + val startIndex = eventName.indexOf(message) + val endIndex = startIndex + message.length + + append(eventName.substring(0, startIndex)) + withStyle( + style = SpanStyle( + color = Orange500, + fontWeight = FontWeight.Bold + ) + ) { + append(message) + } + append(eventName.substring(endIndex)) + } else { + append(eventName) + } + } + + Text( + text = annotatedEventName, + color = Gray10, + fontSize = 18.sp, + fontWeight = FontWeight.Normal + ) + + if (!soptEvent.isAttendancePointAwardedEvent) { + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = "이번 행사는 출석 점수가 부여되지 않습니다.", + color = Gray100, + fontSize = 14.sp + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Attendance Progress + AttendanceProgressIndicator( + progressState = progressState + ) + } + } +} diff --git a/feature/attendance/src/main/java/org/sopt/official/feature/attendance/component/AttendanceHistoryList.kt b/feature/attendance/src/main/java/org/sopt/official/feature/attendance/component/AttendanceHistoryList.kt new file mode 100644 index 000000000..0c30fe55f --- /dev/null +++ b/feature/attendance/src/main/java/org/sopt/official/feature/attendance/component/AttendanceHistoryList.kt @@ -0,0 +1,272 @@ +/* + * MIT License + * Copyright 2025 SOPT - Shout Our Passion Together + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.sopt.official.feature.attendance.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.sopt.official.designsystem.Gray10 +import org.sopt.official.designsystem.Gray300 +import org.sopt.official.designsystem.Gray400 +import org.sopt.official.designsystem.Gray600 +import org.sopt.official.designsystem.Gray800 +import org.sopt.official.designsystem.Green400 +import org.sopt.official.designsystem.Orange400 +import org.sopt.official.designsystem.Red400 +import org.sopt.official.domain.attendance.entity.AttendanceHistory +import org.sopt.official.domain.attendance.entity.AttendanceLog +import org.sopt.official.domain.attendance.entity.AttendanceStatus +import org.sopt.official.domain.attendance.entity.AttendanceSummary + +@Composable +fun AttendanceHistoryList( + attendanceHistory: AttendanceHistory, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)), + colors = CardDefaults.cardColors(containerColor = Gray800) + ) { + LazyColumn( + modifier = Modifier.padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // User Info + item { + AttendanceUserInfoItem( + userInfo = attendanceHistory.userInfo + ) + } + + // Summary + item { + AttendanceSummaryItem( + summary = attendanceHistory.attendanceSummary + ) + } + + // Attendance Logs Header + item { + AttendanceLogHeaderItem() + } + + // Attendance Logs + items(attendanceHistory.attendanceLog) { log -> + AttendanceLogItem( + log = log + ) + } + } + } +} + +@Composable +private fun AttendanceUserInfoItem( + userInfo: org.sopt.official.domain.attendance.entity.AttendanceUserInfo +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "${userInfo.userName} ${userInfo.partName}", + color = Gray10, + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + + Text( + text = "${userInfo.generation}기", + color = Gray300, + fontSize = 14.sp + ) + } +} + +@Composable +private fun AttendanceSummaryItem( + summary: AttendanceSummary +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "출석", + color = Gray300, + fontSize = 14.sp + ) + Text( + text = "${summary.normal}회", + color = Gray10, + fontSize = 14.sp + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "지각", + color = Gray300, + fontSize = 14.sp + ) + Text( + text = "${summary.late}회", + color = Gray10, + fontSize = 14.sp + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "결석", + color = Gray300, + fontSize = 14.sp + ) + Text( + text = "${summary.abnormal}회", + color = Gray10, + fontSize = 14.sp + ) + } + } +} + +@Composable +private fun AttendanceLogHeaderItem() { + Divider( + color = Gray600, + thickness = 1.dp, + modifier = Modifier.padding(vertical = 8.dp) + ) + + Text( + text = "출석 기록", + color = Gray10, + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) +} + +@Composable +private fun AttendanceLogItem( + log: AttendanceLog +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = log.eventName, + color = Gray10, + fontSize = 14.sp, + fontWeight = FontWeight.Medium + ) + Text( + text = log.date, + color = Gray400, + fontSize = 12.sp + ) + } + + AttendanceStatusBadge( + statusText = log.attendanceState + ) + } +} + +@Composable +private fun AttendanceStatusBadge( + statusText: String +) { + val (backgroundColor, textColor, text) = when (statusText) { + "출석" -> Triple( + Green400, + Color.Black, + "출석" + ) + "지각" -> Triple( + Orange400, + Color.Black, + "지각" + ) + "결석" -> Triple( + Gray600, + Gray300, + "결석" + ) + "참석" -> Triple( + Red400, + Color.Black, + "참석" + ) + else -> Triple( + Gray600, + Gray300, + statusText + ) + } + + Box( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(backgroundColor) + .padding(horizontal = 8.dp, vertical = 4.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = text, + color = textColor, + fontSize = 12.sp, + fontWeight = FontWeight.Medium + ) + } +} diff --git a/feature/attendance/src/main/java/org/sopt/official/feature/attendance/component/AttendanceProgressIndicator.kt b/feature/attendance/src/main/java/org/sopt/official/feature/attendance/component/AttendanceProgressIndicator.kt new file mode 100644 index 000000000..02fdcf7f9 --- /dev/null +++ b/feature/attendance/src/main/java/org/sopt/official/feature/attendance/component/AttendanceProgressIndicator.kt @@ -0,0 +1,277 @@ +/* + * MIT License + * Copyright 2025 SOPT - Shout Our Passion Together + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.sopt.official.feature.attendance.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.sopt.official.designsystem.Gray10 +import org.sopt.official.designsystem.Gray300 +import org.sopt.official.designsystem.Gray400 +import org.sopt.official.designsystem.Gray500 +import org.sopt.official.designsystem.Gray700 +import org.sopt.official.feature.attendance.model.ProgressBarUIState +import org.sopt.official.feature.attendance.R + +@Composable +fun AttendanceProgressIndicator( + progressState: ProgressBarUIState, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top + ) { + // First Progress with label + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(start = 12.dp) + ) { + ProgressCircle( + isActive = progressState.isFirstProgressBarActive, + isAttendance = progressState.isFirstProgressBarAttendance, + type = ProgressCircleType.FIRST + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = stringResource(R.string.attendance_progress_first), + color = if (progressState.isFirstProgressBarActive) Gray10 else Gray500, + fontSize = 12.sp, + fontWeight = FontWeight.Normal + ) + } + + // First to Second Line - positioned at circle center height + Box( + modifier = Modifier + .weight(1f) + .padding(top = 12.dp) // Half of circle height to align with center + .height(1.dp) + .background( + if (progressState.isFirstToSecondLineActive) Gray10 else Gray400 + ) + ) + + // Second Progress with label + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + ProgressCircle( + isActive = progressState.isSecondProgressBarActive, + isAttendance = progressState.isSecondProgressBarAttendance, + type = ProgressCircleType.SECOND + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = stringResource(R.string.attendance_progress_second), + color = if (progressState.isSecondProgressBarActive) Gray10 else Gray500, + fontSize = 12.sp, + fontWeight = FontWeight.Normal + ) + } + + // Second to Third Line - positioned at circle center height + Box( + modifier = Modifier + .weight(1f) + .padding(top = 12.dp) // Half of circle height to align with center + .height(1.dp) + .background( + if (progressState.isSecondToThirdLineActive) Gray10 else Gray400 + ) + ) + + // Third Progress with label + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(end = 12.dp) + ) { + ThirdProgressCircle( + isActive = progressState.isThirdProgressBarActive, + isAttendance = progressState.isThirdProgressBarAttendance, + isTardy = progressState.isThirdProgressBarTardy, + isBeforeAttendance = progressState.isThirdProgressBarBeforeAttendance + ) + + Spacer(modifier = Modifier.height(12.dp)) + + val thirdProgressText = when { + progressState.isThirdProgressBarActiveAndBeforeAttendance -> { + if (progressState.isThirdProgressBarTardy) + stringResource(R.string.attendance_progress_third_tardy) + else + stringResource(R.string.attendance_progress_third_complete) + } + progressState.isThirdProgressBarActive -> { + stringResource(R.string.attendance_progress_third_absent) + } + else -> { + stringResource(R.string.attendance_progress_before) + } + } + + Text( + text = thirdProgressText, + color = if (progressState.isThirdProgressBarActive) Gray10 else Gray500, + fontSize = 12.sp, + fontWeight = FontWeight.Normal + ) + } + } +} + +@Composable +private fun ProgressCircle( + isActive: Boolean, + isAttendance: Boolean, + type: ProgressCircleType, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier.size(24.dp), + contentAlignment = Alignment.Center + ) { + if (isActive) { + // Active circle with icon + Box( + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .background(Gray10) + .border(1.dp, Gray300, CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = ImageVector.vectorResource( + if (isAttendance) + R.drawable.ic_attendance_check_gray + else + R.drawable.ic_attendance_close_gray + ), + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier.size(16.dp) + ) + } + } else { + // Empty circle + Box( + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .border(1.dp, Gray400, CircleShape) + ) + } + } +} + +@Composable +private fun ThirdProgressCircle( + isActive: Boolean, + isAttendance: Boolean, + isTardy: Boolean, + isBeforeAttendance: Boolean, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier.size(24.dp), + contentAlignment = Alignment.Center + ) { + when { + isActive && isTardy -> { + // Tardy state + Box( + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .background(Gray700) + .border(1.dp, Gray10, CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_point), + contentDescription = null, + tint = Gray10, + modifier = Modifier.size(12.dp) + ) + } + } + isActive && isAttendance -> { + // Attendance state + Box( + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .background(Gray700) + .border(1.dp, Gray10, CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = ImageVector.vectorResource( + if (isBeforeAttendance) + R.drawable.ic_attendacne_check_white + else + R.drawable.ic_attendance_close_white + ), + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier.size(16.dp) + ) + } + } + else -> { + // Empty circle + Box( + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .border(1.dp, Gray400, CircleShape) + ) + } + } + } +} + +private enum class ProgressCircleType { + FIRST, SECOND, THIRD +} diff --git a/feature/attendance/src/main/java/org/sopt/official/feature/attendance/component/AttendanceScreenPreviews.kt b/feature/attendance/src/main/java/org/sopt/official/feature/attendance/component/AttendanceScreenPreviews.kt new file mode 100644 index 000000000..1d86f27b2 --- /dev/null +++ b/feature/attendance/src/main/java/org/sopt/official/feature/attendance/component/AttendanceScreenPreviews.kt @@ -0,0 +1,291 @@ +/* + * MIT License + * Copyright 2025 SOPT - Shout Our Passion Together + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.sopt.official.feature.attendance.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.sopt.official.designsystem.SoptTheme +import org.sopt.official.domain.attendance.entity.AttendanceHistory +import org.sopt.official.domain.attendance.entity.AttendanceLog +import org.sopt.official.domain.attendance.entity.AttendanceStatus +import org.sopt.official.domain.attendance.entity.AttendanceSummary +import org.sopt.official.domain.attendance.entity.AttendanceUserInfo +import org.sopt.official.domain.attendance.entity.EventType +import org.sopt.official.domain.attendance.entity.EventAttribute +import org.sopt.official.domain.attendance.entity.SoptEvent +import org.sopt.official.feature.attendance.model.AttendanceButtonState +import org.sopt.official.feature.attendance.model.AttendanceDialogState +import org.sopt.official.feature.attendance.model.ProgressBarUIState + +// Sample data for previews +private val sampleSoptEvent = SoptEvent( + id = 1, + eventType = EventType.HAS_ATTENDANCE, + date = "2024-01-15", + location = "건국대학교 새천년관", + eventName = "1차 세미나", + message = "Android 기초 세미나입니다.", + isAttendancePointAwardedEvent = true, + attendances = listOf( + SoptEvent.Attendance( + status = AttendanceStatus.ATTENDANCE, + attendedAt = "19:30" + ) + ) +) + +private val sampleAttendanceHistory = AttendanceHistory( + userInfo = AttendanceUserInfo( + generation = 34, + partName = "Android", + userName = "김솝트", + attendancePoint = 15 + ), + attendanceSummary = AttendanceSummary( + normal = 5, + late = 2, + abnormal = 1, + participate = 1 + ), + attendanceLog = listOf( + AttendanceLog( + attribute = EventAttribute.SEMINAR, + attendanceState = "출석", + eventName = "1차 세미나", + date = "2024-01-15" + ), + AttendanceLog( + attribute = EventAttribute.SEMINAR, + attendanceState = "결석", + eventName = "2차 세미나", + date = "2024-01-22" + ) + ) +) + +@Preview(name = "AttendanceEventInfoCard - Normal", showBackground = true) +@Composable +private fun AttendanceEventInfoCardPreview() { + SoptTheme { + Box( + modifier = Modifier + .fillMaxWidth() + .background(Color.Black) + .padding(20.dp) + ) { + AttendanceEventInfoCard( + soptEvent = sampleSoptEvent, + progressState = ProgressBarUIState( + isFirstProgressBarActive = true, + isFirstProgressBarAttendance = true, + isFirstToSecondLineActive = true, + isSecondProgressBarActive = false + ) + ) + } + } +} + +@Preview(name = "AttendanceEventInfoCard - Completed", showBackground = true) +@Composable +private fun AttendanceEventInfoCardCompletedPreview() { + SoptTheme { + Box( + modifier = Modifier + .fillMaxWidth() + .background(Color.Black) + .padding(20.dp) + ) { + AttendanceEventInfoCard( + soptEvent = sampleSoptEvent.copy( + attendances = listOf( + SoptEvent.Attendance(AttendanceStatus.ATTENDANCE, "19:30"), + SoptEvent.Attendance(AttendanceStatus.ATTENDANCE, "21:00") + ) + ), + progressState = ProgressBarUIState( + isFirstProgressBarActive = true, + isFirstProgressBarAttendance = true, + isFirstToSecondLineActive = true, + isSecondProgressBarActive = true, + isSecondProgressBarAttendance = true, + isSecondToThirdLineActive = true, + isThirdProgressBarActive = true, + isThirdProgressBarAttendance = true, + isThirdProgressBarBeforeAttendance = true + ) + ) + } + } +} + +@Preview(name = "AttendanceButton - Enabled", showBackground = true) +@Composable +private fun AttendanceButtonEnabledPreview() { + SoptTheme { + Box( + modifier = Modifier + .fillMaxWidth() + .background(Color.Black) + .padding(20.dp) + ) { + AttendanceButton( + text = "1차 출석", + isEnabled = true, + onClick = { } + ) + } + } +} + +@Preview(name = "AttendanceButton - Disabled", showBackground = true) +@Composable +private fun AttendanceButtonDisabledPreview() { + SoptTheme { + Box( + modifier = Modifier + .fillMaxWidth() + .background(Color.Black) + .padding(20.dp) + ) { + AttendanceButton( + text = "1차 출석 종료", + isEnabled = false, + onClick = { } + ) + } + } +} + +@Preview(name = "AttendanceCodeDialog - Input", showBackground = true) +@Composable +private fun AttendanceCodeDialogPreview() { + SoptTheme { + AttendanceCodeDialog( + title = "1차 세미나", + onDismiss = { }, + onCodeSubmit = { }, + errorMessage = null + ) + } +} + +@Preview(name = "AttendanceCodeDialog - Error", showBackground = true) +@Composable +private fun AttendanceCodeDialogErrorPreview() { + SoptTheme { + AttendanceCodeDialog( + title = "1차 세미나", + onDismiss = { }, + onCodeSubmit = { }, + errorMessage = "코드가 일치하지 않아요!" + ) + } +} + +@Preview(name = "AttendanceHistoryList", showBackground = true) +@Composable +private fun AttendanceHistoryListPreview() { + SoptTheme { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + .padding(20.dp) + ) { + AttendanceHistoryList( + attendanceHistory = sampleAttendanceHistory + ) + } + } +} + +@Preview(name = "AttendanceTopBar", showBackground = true) +@Composable +private fun AttendanceTopBarPreview() { + SoptTheme { + AttendanceTopBar( + onBackClick = { }, + onRefreshClick = { } + ) + } +} + +@Preview(name = "AttendanceRoundProgressBar - First Active", showBackground = true) +@Composable +private fun AttendanceRoundProgressBarFirstActivePreview() { + SoptTheme { + Box( + modifier = Modifier + .fillMaxWidth() + .background(Color.Black) + .padding(20.dp) + ) { + AttendanceProgressIndicator( + progressState = ProgressBarUIState( + isFirstProgressBarActive = true, + isFirstProgressBarAttendance = true, + isFirstToSecondLineActive = true + ) + ) + } + } +} + +@Preview(name = "AttendanceRoundProgressBar - All Complete", showBackground = true) +@Composable +private fun AttendanceRoundProgressBarCompletePreview() { + SoptTheme { + Box( + modifier = Modifier + .fillMaxWidth() + .background(Color.Black) + .padding(20.dp) + ) { + AttendanceProgressIndicator( + progressState = ProgressBarUIState( + isFirstProgressBarActive = true, + isFirstProgressBarAttendance = true, + isFirstToSecondLineActive = true, + isSecondProgressBarActive = true, + isSecondProgressBarAttendance = true, + isSecondToThirdLineActive = true, + isThirdProgressBarActive = true, + isThirdProgressBarAttendance = true, + isThirdProgressBarBeforeAttendance = true + ) + ) + } + } +} diff --git a/feature/attendance/src/main/java/org/sopt/official/feature/attendance/component/AttendanceTopBar.kt b/feature/attendance/src/main/java/org/sopt/official/feature/attendance/component/AttendanceTopBar.kt new file mode 100644 index 000000000..e10030bf3 --- /dev/null +++ b/feature/attendance/src/main/java/org/sopt/official/feature/attendance/component/AttendanceTopBar.kt @@ -0,0 +1,97 @@ +/* + * MIT License + * Copyright 2025 SOPT - Shout Our Passion Together + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.sopt.official.feature.attendance.component + +import androidx.compose.animation.core.* +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.sopt.official.designsystem.Gray10 +import org.sopt.official.feature.attendance.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AttendanceTopBar( + onBackClick: () -> Unit, + onRefreshClick: () -> Unit, + modifier: Modifier = Modifier +) { + var isRefreshing by remember { mutableStateOf(false) } + + // Rotation animation + val rotationAnimation by animateFloatAsState( + targetValue = if (isRefreshing) 360f else 0f, + animationSpec = tween(durationMillis = 500, easing = LinearEasing), + finishedListener = { isRefreshing = false } + ) + TopAppBar( + title = { + Text( + text = "출석", + color = Gray10, + fontSize = 16.sp, + fontWeight = FontWeight.Normal + ) + }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_attendance_arrow_left_white), + contentDescription = "뒤로가기", + tint = Gray10 + ) + } + }, + actions = { + IconButton( + onClick = { + isRefreshing = true + onRefreshClick() + } + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_refresh), + contentDescription = "새로고침", + tint = Gray10, + modifier = Modifier.rotate(rotationAnimation) + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = androidx.compose.ui.graphics.Color.Black + ), + modifier = modifier + ) +} diff --git a/feature/attendance/src/main/java/org/sopt/official/feature/attendance/model/AttendanceConstants.kt b/feature/attendance/src/main/java/org/sopt/official/feature/attendance/model/AttendanceConstants.kt new file mode 100644 index 000000000..4517d49ee --- /dev/null +++ b/feature/attendance/src/main/java/org/sopt/official/feature/attendance/model/AttendanceConstants.kt @@ -0,0 +1,48 @@ +/* + * MIT License + * Copyright 2023-2025 SOPT - Shout Our Passion Together + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.sopt.official.feature.attendance.model + +object AttendanceConstants { + // Response codes + const val ERROR_CODE = -2L + const val NO_SESSION_CODE = -1L + const val TIME_RESTRICTION_CODE = 0L + + // Error messages + const val ERROR_WRONG_CODE = "코드가 일치하지 않아요!" + const val ERROR_BEFORE_TIME = "출석 시간 전입니다." + const val ERROR_AFTER_TIME = "출석이 이미 종료되었습니다." + const val ERROR_INVALID_CODE = "출석 코드가 올바르지 않습니다" + + // Attendance texts + const val FIRST_ATTENDANCE_TEXT = "1차 출석" + const val SECOND_ATTENDANCE_TEXT = "2차 출석" + + // UI dimensions + const val DIALOG_WIDTH_DP = 258 + const val CODE_INPUT_FIELDS = 5 + const val GRADIENT_HEIGHT_DP = 156 + const val BOTTOM_PADDING_DP = 100 +} diff --git a/feature/attendance/src/main/java/org/sopt/official/feature/attendance/model/AttendanceProgressState.kt b/feature/attendance/src/main/java/org/sopt/official/feature/attendance/model/AttendanceProgressState.kt new file mode 100644 index 000000000..473f7acf9 --- /dev/null +++ b/feature/attendance/src/main/java/org/sopt/official/feature/attendance/model/AttendanceProgressState.kt @@ -0,0 +1,154 @@ +/* + * MIT License + * Copyright 2025 SOPT - Shout Our Passion Together + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.sopt.official.feature.attendance.model + +import org.sopt.official.domain.attendance.entity.SoptEvent + +sealed class AttendanceProgressState { + object BeforeAttendance : AttendanceProgressState() + + data class FirstAttendanceCompleted( + val isAttended: Boolean + ) : AttendanceProgressState() + + data class SecondAttendanceCompleted( + val firstAttended: Boolean, + val secondAttended: Boolean + ) : AttendanceProgressState() + + companion object { + fun fromSoptEvent(soptEvent: SoptEvent): AttendanceProgressState { + return when (soptEvent.attendances.size) { + 0 -> BeforeAttendance + 1 -> { + val firstStatus = soptEvent.attendances[0].attendedAt != AttendanceConstants.FIRST_ATTENDANCE_TEXT + FirstAttendanceCompleted(isAttended = firstStatus) + } + 2 -> { + val firstStatus = soptEvent.attendances[0].attendedAt != AttendanceConstants.FIRST_ATTENDANCE_TEXT + val secondStatus = soptEvent.attendances[1].attendedAt != AttendanceConstants.SECOND_ATTENDANCE_TEXT + SecondAttendanceCompleted(firstAttended = firstStatus, secondAttended = secondStatus) + } + else -> BeforeAttendance + } + } + } +} + +data class ProgressBarUIState( + val isFirstProgressBarActive: Boolean = false, + val isFirstProgressBarAttendance: Boolean = false, + val isFirstToSecondLineActive: Boolean = false, + val isSecondProgressBarActive: Boolean = false, + val isSecondProgressBarAttendance: Boolean = false, + val isSecondToThirdLineActive: Boolean = false, + val isThirdProgressBarActive: Boolean = false, + val isThirdProgressBarAttendance: Boolean = false, + val isThirdProgressBarTardy: Boolean = false, + val isThirdProgressBarBeforeAttendance: Boolean = false, +) { + val isThirdProgressBarVisible: Boolean + get() = isThirdProgressBarActive && isThirdProgressBarTardy + + val isThirdProgressBarActiveAndBeforeAttendance: Boolean + get() = isThirdProgressBarActive && isThirdProgressBarBeforeAttendance + + companion object { + fun fromProgressState(progressState: AttendanceProgressState): ProgressBarUIState { + return when (progressState) { + is AttendanceProgressState.BeforeAttendance -> { + ProgressBarUIState( + isThirdProgressBarBeforeAttendance = true + ) + } + is AttendanceProgressState.FirstAttendanceCompleted -> { + ProgressBarUIState( + isFirstProgressBarActive = true, + isFirstProgressBarAttendance = progressState.isAttended, + isFirstToSecondLineActive = true, + isThirdProgressBarBeforeAttendance = true + ) + } + is AttendanceProgressState.SecondAttendanceCompleted -> { + val finalAttendanceStatus = determineFinalAttendanceStatus( + progressState.firstAttended, + progressState.secondAttended + ) + + ProgressBarUIState( + isFirstProgressBarActive = true, + isFirstProgressBarAttendance = progressState.firstAttended, + isFirstToSecondLineActive = true, + isSecondProgressBarActive = true, + isSecondProgressBarAttendance = progressState.secondAttended, + isSecondToThirdLineActive = true, + isThirdProgressBarActive = true, + isThirdProgressBarAttendance = finalAttendanceStatus.isAttendance, + isThirdProgressBarTardy = finalAttendanceStatus.isTardy, + isThirdProgressBarBeforeAttendance = finalAttendanceStatus.isBeforeAttendance + ) + } + } + } + + private fun determineFinalAttendanceStatus( + firstAttended: Boolean, + secondAttended: Boolean + ): FinalAttendanceStatus { + return when { + firstAttended && secondAttended -> { + // 완전 출석 + FinalAttendanceStatus( + isAttendance = true, + isTardy = false, + isBeforeAttendance = true + ) + } + (firstAttended && !secondAttended) || (!firstAttended && secondAttended) -> { + // 지각 + FinalAttendanceStatus( + isAttendance = true, + isTardy = true, + isBeforeAttendance = true + ) + } + else -> { + // 결석 + FinalAttendanceStatus( + isAttendance = false, + isTardy = false, + isBeforeAttendance = false + ) + } + } + } + + private data class FinalAttendanceStatus( + val isAttendance: Boolean, + val isTardy: Boolean, + val isBeforeAttendance: Boolean + ) + } +} diff --git a/app/src/main/java/org/sopt/official/feature/attendance/model/AttendanceState.kt b/feature/attendance/src/main/java/org/sopt/official/feature/attendance/model/AttendanceState.kt similarity index 96% rename from app/src/main/java/org/sopt/official/feature/attendance/model/AttendanceState.kt rename to feature/attendance/src/main/java/org/sopt/official/feature/attendance/model/AttendanceState.kt index 0497d5a88..9cab901d9 100644 --- a/app/src/main/java/org/sopt/official/feature/attendance/model/AttendanceState.kt +++ b/feature/attendance/src/main/java/org/sopt/official/feature/attendance/model/AttendanceState.kt @@ -1,6 +1,6 @@ /* * MIT License - * Copyright 2023 SOPT - Shout Our Passion Together + * Copyright 2023-2025 SOPT - Shout Our Passion Together * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal diff --git a/feature/attendance/src/main/java/org/sopt/official/feature/attendance/model/AttendanceUiState.kt b/feature/attendance/src/main/java/org/sopt/official/feature/attendance/model/AttendanceUiState.kt new file mode 100644 index 000000000..55a7f1bb8 --- /dev/null +++ b/feature/attendance/src/main/java/org/sopt/official/feature/attendance/model/AttendanceUiState.kt @@ -0,0 +1,165 @@ +/* + * MIT License + * Copyright 2025 SOPT - Shout Our Passion Together + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.sopt.official.feature.attendance.model + +import org.sopt.official.domain.attendance.entity.AttendanceHistory +import org.sopt.official.domain.attendance.entity.SoptEvent + +/** + * 출석 화면의 모든 UI 상태를 하나로 통합한 데이터 클래스 + * - 상태 응집성 개선 + * - 단일 진실 공급원 (Single Source of Truth) + * - 테스트 용이성 증대 + */ +data class AttendanceUiState( + val isLoading: Boolean = false, + val soptEvent: SoptEvent? = null, + val attendanceHistory: AttendanceHistory? = null, + val progressState: ProgressBarUIState = ProgressBarUIState(), + val buttonState: AttendanceButtonState = AttendanceButtonState(), + val dialogState: AttendanceDialogState = AttendanceDialogState.Hidden, + val error: String? = null +) { + /** + * 데이터 로딩이 완료되었는지 확인 + */ + val isDataReady: Boolean + get() = !isLoading && soptEvent != null && attendanceHistory != null && error == null + + /** + * 에러 상태인지 확인 + */ + val hasError: Boolean + get() = error != null + + companion object { + /** + * 초기 상태 + */ + val Initial = AttendanceUiState(isLoading = true) + + /** + * 에러 상태 생성 + */ + fun error(message: String) = AttendanceUiState( + isLoading = false, + error = message + ) + + /** + * 성공 상태 생성 + */ + fun success( + soptEvent: SoptEvent, + attendanceHistory: AttendanceHistory, + progressState: ProgressBarUIState, + buttonState: AttendanceButtonState + ) = AttendanceUiState( + isLoading = false, + soptEvent = soptEvent, + attendanceHistory = attendanceHistory, + progressState = progressState, + buttonState = buttonState + ) + } +} + +/** + * 출석 버튼 상태 + */ +data class AttendanceButtonState( + val isVisible: Boolean = false, + val isEnabled: Boolean = false, + val text: String = "" +) { + companion object { + val Hidden = AttendanceButtonState() + + fun visible(text: String, isEnabled: Boolean = true) = AttendanceButtonState( + isVisible = true, + isEnabled = isEnabled, + text = text + ) + } +} + +/** + * 출석 다이얼로그 상태 + */ +sealed class AttendanceDialogState { + object Hidden : AttendanceDialogState() + + data class CodeInput( + val title: String + ) : AttendanceDialogState() + + data class Error( + val title: String, + val message: String + ) : AttendanceDialogState() +} + +/** + * 출석 라운드 상태 - 비즈니스 로직 분리 + */ +sealed class AttendanceRoundState { + object NoSession : AttendanceRoundState() + object BeforeTime : AttendanceRoundState() + object AfterTime : AttendanceRoundState() + + data class Available( + val subLectureId: Long, + val roundText: String + ) : AttendanceRoundState() + + data class Completed( + val roundText: String + ) : AttendanceRoundState() + + companion object { + /** + * AttendanceRound ID를 기반으로 상태 결정 + */ + fun fromRoundId( + id: Long, + roundText: String, + userAttendanceCount: Int + ): AttendanceRoundState { + return when (id) { + AttendanceConstants.ERROR_CODE -> NoSession + AttendanceConstants.NO_SESSION_CODE -> NoSession + AttendanceConstants.TIME_RESTRICTION_CODE -> BeforeTime + else -> { + val expectedAttendanceCount = roundText.firstOrNull()?.digitToIntOrNull() ?: 0 + if (userAttendanceCount >= expectedAttendanceCount) { + Completed(roundText) + } else { + Available(id, roundText) + } + } + } + } + } +} diff --git a/app/src/main/java/org/sopt/official/feature/attendance/model/DialogState.kt b/feature/attendance/src/main/java/org/sopt/official/feature/attendance/model/DialogState.kt similarity index 95% rename from app/src/main/java/org/sopt/official/feature/attendance/model/DialogState.kt rename to feature/attendance/src/main/java/org/sopt/official/feature/attendance/model/DialogState.kt index 953e23380..d1fa3c0df 100644 --- a/app/src/main/java/org/sopt/official/feature/attendance/model/DialogState.kt +++ b/feature/attendance/src/main/java/org/sopt/official/feature/attendance/model/DialogState.kt @@ -1,6 +1,6 @@ /* * MIT License - * Copyright 2023 SOPT - Shout Our Passion Together + * Copyright 2023-2025 SOPT - Shout Our Passion Together * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal diff --git a/feature/attendance/src/main/java/org/sopt/official/feature/attendance/usecase/AttendanceUiStateMapper.kt b/feature/attendance/src/main/java/org/sopt/official/feature/attendance/usecase/AttendanceUiStateMapper.kt new file mode 100644 index 000000000..9c6a3efb5 --- /dev/null +++ b/feature/attendance/src/main/java/org/sopt/official/feature/attendance/usecase/AttendanceUiStateMapper.kt @@ -0,0 +1,129 @@ +/* + * MIT License + * Copyright 2025 SOPT - Shout Our Passion Together + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.sopt.official.feature.attendance.usecase + +import org.sopt.official.domain.attendance.entity.AttendanceHistory +import org.sopt.official.domain.attendance.entity.AttendanceRound +import org.sopt.official.domain.attendance.entity.SoptEvent +import org.sopt.official.feature.attendance.model.AttendanceButtonState +import org.sopt.official.feature.attendance.model.AttendanceProgressState +import org.sopt.official.feature.attendance.model.AttendanceRoundState +import org.sopt.official.feature.attendance.model.AttendanceUiState +import org.sopt.official.feature.attendance.model.ProgressBarUIState +import javax.inject.Inject + +/** + * 도메인 데이터를 UI 상태로 변환하는 매퍼 + * - 단일 책임: 데이터 변환만 담당 + * - 테스트 용이성: 순수 함수로 구성 + * - 재사용성: 다른 컴포넌트에서도 사용 가능 + */ +class AttendanceUiStateMapper @Inject constructor() { + + /** + * SoptEvent와 AttendanceHistory를 UI 상태로 변환 + */ + fun mapToSuccessState( + soptEvent: SoptEvent, + attendanceHistory: AttendanceHistory, + attendanceRound: AttendanceRound? = null + ): AttendanceUiState { + val progressState = createProgressState(soptEvent) + val buttonState = createButtonState(attendanceRound, soptEvent.attendances.size) + + return AttendanceUiState.success( + soptEvent = soptEvent, + attendanceHistory = attendanceHistory, + progressState = progressState, + buttonState = buttonState + ) + } + + /** + * 진행 상태 생성 + */ + private fun createProgressState(soptEvent: SoptEvent): ProgressBarUIState { + val progressState = AttendanceProgressState.fromSoptEvent(soptEvent) + return ProgressBarUIState.fromProgressState(progressState) + } + + /** + * 버튼 상태 생성 + */ + private fun createButtonState( + attendanceRound: AttendanceRound?, + userAttendanceCount: Int + ): AttendanceButtonState { + if (attendanceRound == null) { + return AttendanceButtonState.Hidden + } + + val roundState = AttendanceRoundState.fromRoundId( + id = attendanceRound.id, + roundText = attendanceRound.roundText, + userAttendanceCount = userAttendanceCount + ) + + return when (roundState) { + is AttendanceRoundState.NoSession -> AttendanceButtonState.Hidden + + is AttendanceRoundState.BeforeTime -> AttendanceButtonState.visible( + text = attendanceRound.roundText, + isEnabled = false + ) + + is AttendanceRoundState.AfterTime -> AttendanceButtonState.visible( + text = "출석이 이미 종료되었습니다", + isEnabled = false + ) + + is AttendanceRoundState.Available -> AttendanceButtonState.visible( + text = roundState.roundText, + isEnabled = true + ) + + is AttendanceRoundState.Completed -> AttendanceButtonState.visible( + text = "${roundState.roundText.take(5)} 종료", + isEnabled = false + ) + } + } + + /** + * 에러 상태 생성 + */ + fun mapToErrorState(error: Throwable): AttendanceUiState { + return AttendanceUiState.error( + message = error.message ?: "알 수 없는 오류가 발생했습니다" + ) + } + + /** + * 로딩 상태 생성 + */ + fun mapToLoadingState(): AttendanceUiState { + return AttendanceUiState.Initial + } +} diff --git a/feature/attendance/src/main/java/org/sopt/official/feature/attendance/usecase/LoadAttendanceDataUseCase.kt b/feature/attendance/src/main/java/org/sopt/official/feature/attendance/usecase/LoadAttendanceDataUseCase.kt new file mode 100644 index 000000000..170c10c23 --- /dev/null +++ b/feature/attendance/src/main/java/org/sopt/official/feature/attendance/usecase/LoadAttendanceDataUseCase.kt @@ -0,0 +1,71 @@ +/* + * MIT License + * Copyright 2023-2025 SOPT - Shout Our Passion Together + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.sopt.official.feature.attendance.usecase + +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import org.sopt.official.domain.attendance.entity.AttendanceHistory +import org.sopt.official.domain.attendance.entity.AttendanceRound +import org.sopt.official.domain.attendance.entity.SoptEvent +import org.sopt.official.domain.attendance.repository.AttendanceRepository +import org.sopt.official.feature.attendance.model.AttendanceUiState +import javax.inject.Inject + +/** + * 출석 데이터 로드 UseCase + * - 병렬 데이터 로드 + * - 비즈니스 로직 캡슐화 + * - 테스트 용이성 + */ +class LoadAttendanceDataUseCase @Inject constructor( + private val attendanceRepository: AttendanceRepository, + private val mapToUiStateUseCase: MapToAttendanceUiStateUseCase +) { + + suspend operator fun invoke(): Result = runCatching { + coroutineScope { + // 병렬로 기본 데이터 로드 + val soptEventDeferred = async { attendanceRepository.fetchSoptEvent() } + val historyDeferred = async { attendanceRepository.fetchAttendanceHistory() } + + val soptEvent = soptEventDeferred.await().getOrThrow() + val history = historyDeferred.await().getOrThrow() + + // AttendanceRound 로드 (실패해도 진행) + val attendanceRound = loadAttendanceRound(soptEvent.id.toLong()) + + // UI 상태로 변환 + mapToUiStateUseCase(soptEvent, history, attendanceRound) + } + } + + private suspend fun loadAttendanceRound(eventId: Long): AttendanceRound? { + return try { + attendanceRepository.fetchAttendanceRound(eventId).getOrNull() + } catch (error: Throwable) { + null // AttendanceRound 실패는 전체 프로세스에 영향 주지 않음 + } + } +} diff --git a/feature/attendance/src/main/java/org/sopt/official/feature/attendance/usecase/MapToAttendanceUiStateUseCase.kt b/feature/attendance/src/main/java/org/sopt/official/feature/attendance/usecase/MapToAttendanceUiStateUseCase.kt new file mode 100644 index 000000000..917e8ad29 --- /dev/null +++ b/feature/attendance/src/main/java/org/sopt/official/feature/attendance/usecase/MapToAttendanceUiStateUseCase.kt @@ -0,0 +1,110 @@ +/* + * MIT License + * Copyright 2025 SOPT - Shout Our Passion Together + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.sopt.official.feature.attendance.usecase + +import org.sopt.official.domain.attendance.entity.AttendanceHistory +import org.sopt.official.domain.attendance.entity.AttendanceRound +import org.sopt.official.domain.attendance.entity.SoptEvent +import org.sopt.official.feature.attendance.model.AttendanceButtonState +import org.sopt.official.feature.attendance.model.AttendanceProgressState +import org.sopt.official.feature.attendance.model.AttendanceRoundState +import org.sopt.official.feature.attendance.model.AttendanceUiState +import org.sopt.official.feature.attendance.model.ProgressBarUIState +import javax.inject.Inject + +/** + * 도메인 엔티티를 AttendanceUiState로 변환하는 UseCase + * - 단일 책임: 데이터 변환만 담당 + * - 순수 함수: 사이드 이펙트 없음 + * - 테스트 용이성: 입력/출력이 명확 + */ +class MapToAttendanceUiStateUseCase @Inject constructor() { + + operator fun invoke( + soptEvent: SoptEvent, + attendanceHistory: AttendanceHistory, + attendanceRound: AttendanceRound? = null + ): AttendanceUiState { + val progressState = createProgressState(soptEvent) + val buttonState = createButtonState(attendanceRound, soptEvent.attendances.size) + + return AttendanceUiState.success( + soptEvent = soptEvent, + attendanceHistory = attendanceHistory, + progressState = progressState, + buttonState = buttonState + ) + } + + /** + * 진행 상태 생성 + */ + private fun createProgressState(soptEvent: SoptEvent): ProgressBarUIState { + val progressState = AttendanceProgressState.fromSoptEvent(soptEvent) + return ProgressBarUIState.fromProgressState(progressState) + } + + /** + * 버튼 상태 생성 + */ + private fun createButtonState( + attendanceRound: AttendanceRound?, + userAttendanceCount: Int + ): AttendanceButtonState { + if (attendanceRound == null) { + return AttendanceButtonState.Hidden + } + + val roundState = AttendanceRoundState.fromRoundId( + id = attendanceRound.id, + roundText = attendanceRound.roundText, + userAttendanceCount = userAttendanceCount + ) + + return when (roundState) { + is AttendanceRoundState.NoSession -> AttendanceButtonState.Hidden + + is AttendanceRoundState.BeforeTime -> AttendanceButtonState.visible( + text = attendanceRound.roundText, + isEnabled = false + ) + + is AttendanceRoundState.AfterTime -> AttendanceButtonState.visible( + text = "출석이 이미 종료되었습니다", + isEnabled = false + ) + + is AttendanceRoundState.Available -> AttendanceButtonState.visible( + text = roundState.roundText, + isEnabled = true + ) + + is AttendanceRoundState.Completed -> AttendanceButtonState.visible( + text = "${roundState.roundText.take(5)} 종료", + isEnabled = false + ) + } + } +} diff --git a/feature/attendance/src/main/res/drawable/ic_attendacne_check_white.xml b/feature/attendance/src/main/res/drawable/ic_attendacne_check_white.xml new file mode 100644 index 000000000..3c59b842c --- /dev/null +++ b/feature/attendance/src/main/res/drawable/ic_attendacne_check_white.xml @@ -0,0 +1,37 @@ + + + + + \ No newline at end of file diff --git a/feature/attendance/src/main/res/drawable/ic_attendance_arrow_left_white.xml b/feature/attendance/src/main/res/drawable/ic_attendance_arrow_left_white.xml new file mode 100644 index 000000000..76e2fae4e --- /dev/null +++ b/feature/attendance/src/main/res/drawable/ic_attendance_arrow_left_white.xml @@ -0,0 +1,37 @@ + + + + + diff --git a/feature/attendance/src/main/res/drawable/ic_attendance_check_gray.xml b/feature/attendance/src/main/res/drawable/ic_attendance_check_gray.xml new file mode 100644 index 000000000..ed448d345 --- /dev/null +++ b/feature/attendance/src/main/res/drawable/ic_attendance_check_gray.xml @@ -0,0 +1,37 @@ + + + + + diff --git a/feature/attendance/src/main/res/drawable/ic_attendance_close_gray.xml b/feature/attendance/src/main/res/drawable/ic_attendance_close_gray.xml new file mode 100644 index 000000000..3e8439f05 --- /dev/null +++ b/feature/attendance/src/main/res/drawable/ic_attendance_close_gray.xml @@ -0,0 +1,44 @@ + + + + + + diff --git a/feature/attendance/src/main/res/drawable/ic_attendance_close_white.xml b/feature/attendance/src/main/res/drawable/ic_attendance_close_white.xml new file mode 100644 index 000000000..4caa40b68 --- /dev/null +++ b/feature/attendance/src/main/res/drawable/ic_attendance_close_white.xml @@ -0,0 +1,44 @@ + + + + + + diff --git a/feature/attendance/src/main/res/drawable/ic_attendance_event_date.xml b/feature/attendance/src/main/res/drawable/ic_attendance_event_date.xml new file mode 100644 index 000000000..afb209379 --- /dev/null +++ b/feature/attendance/src/main/res/drawable/ic_attendance_event_date.xml @@ -0,0 +1,36 @@ + + + + + + diff --git a/feature/attendance/src/main/res/drawable/ic_attendance_event_location.xml b/feature/attendance/src/main/res/drawable/ic_attendance_event_location.xml new file mode 100644 index 000000000..2773b14e4 --- /dev/null +++ b/feature/attendance/src/main/res/drawable/ic_attendance_event_location.xml @@ -0,0 +1,37 @@ + + + + + + diff --git a/feature/attendance/src/main/res/drawable/ic_close.xml b/feature/attendance/src/main/res/drawable/ic_close.xml new file mode 100644 index 000000000..83a88563e --- /dev/null +++ b/feature/attendance/src/main/res/drawable/ic_close.xml @@ -0,0 +1,44 @@ + + + + + + diff --git a/feature/attendance/src/main/res/drawable/ic_point.xml b/feature/attendance/src/main/res/drawable/ic_point.xml new file mode 100644 index 000000000..efdd3bd16 --- /dev/null +++ b/feature/attendance/src/main/res/drawable/ic_point.xml @@ -0,0 +1,44 @@ + + + + + + diff --git a/feature/attendance/src/main/res/drawable/ic_refresh.xml b/feature/attendance/src/main/res/drawable/ic_refresh.xml new file mode 100644 index 000000000..c2990c96c --- /dev/null +++ b/feature/attendance/src/main/res/drawable/ic_refresh.xml @@ -0,0 +1,37 @@ + + + + + diff --git a/feature/attendance/src/main/res/values/strings.xml b/feature/attendance/src/main/res/values/strings.xml new file mode 100644 index 000000000..4a858e4e6 --- /dev/null +++ b/feature/attendance/src/main/res/values/strings.xml @@ -0,0 +1,32 @@ + + + + 1차 출석 + 2차 출석 + 지각 + 출석 완료 + 결석 + 출석 전 + \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 0694551e6..59c48f1c0 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -32,6 +32,7 @@ include( ":core:webview", ":core:navigation", + ":data:attendance", ":data:auth", ":data:fortune", ":data:home", @@ -42,6 +43,7 @@ include( ":data:soptamp", ":data:soptlog", + ":domain:attendance", ":domain:auth", ":domain:fortune", ":domain:home", @@ -52,6 +54,7 @@ include( ":domain:soptamp", ":domain:soptlog", + ":feature:attendance", ":feature:auth", ":feature:fortune", ":feature:home",