diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/components/modal/V2ModalDialog.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/components/modal/V2ModalDialog.kt
new file mode 100644
index 00000000..76036ba3
--- /dev/null
+++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/components/modal/V2ModalDialog.kt
@@ -0,0 +1,77 @@
+package kr.co.vividnext.sodalive.v2.components.modal
+
+import android.app.Activity
+import android.graphics.Color
+import android.view.LayoutInflater
+import android.view.View
+import android.view.WindowManager
+import androidx.appcompat.app.AlertDialog
+import androidx.core.graphics.drawable.toDrawable
+import kr.co.vividnext.sodalive.databinding.DialogV2ModalBinding
+import kr.co.vividnext.sodalive.extensions.dpToPx
+
+class V2ModalDialog(
+ activity: Activity,
+ layoutInflater: LayoutInflater,
+ title: String,
+ desc: String,
+ confirmButtonTitle: String,
+ confirmButtonClick: () -> Unit,
+ cancelButtonTitle: String = "",
+ cancelButtonClick: (() -> Unit)? = null
+) {
+
+ private val alertDialog: AlertDialog
+ private var isButtonClicked = false
+
+ val dialogView = DialogV2ModalBinding.inflate(layoutInflater)
+
+ init {
+ val dialogBuilder = AlertDialog.Builder(activity)
+ dialogBuilder.setView(dialogView.root)
+
+ alertDialog = dialogBuilder.create()
+ alertDialog.setCancelable(false)
+ alertDialog.window?.setBackgroundDrawable(Color.TRANSPARENT.toDrawable())
+
+ dialogView.tvTitle.text = title
+ dialogView.tvDesc.text = desc
+
+ dialogView.tvCancel.text = cancelButtonTitle
+ dialogView.tvCancel.setOnClickListener {
+ dismissAndRun {
+ cancelButtonClick?.invoke()
+ }
+ }
+
+ dialogView.tvConfirm.text = confirmButtonTitle
+ dialogView.tvConfirm.setOnClickListener {
+ dismissAndRun(confirmButtonClick)
+ }
+
+ dialogView.tvCancel.visibility = if (cancelButtonTitle.isNotBlank()) {
+ View.VISIBLE
+ } else {
+ View.GONE
+ }
+ }
+
+ fun show(width: Int) {
+ alertDialog.show()
+
+ val lp = WindowManager.LayoutParams()
+ lp.copyFrom(alertDialog.window?.attributes)
+ lp.width = width - (26.7f.dpToPx()).toInt()
+ lp.height = WindowManager.LayoutParams.WRAP_CONTENT
+
+ alertDialog.window?.setBackgroundDrawable(Color.TRANSPARENT.toDrawable())
+ alertDialog.window?.attributes = lp
+ }
+
+ private fun dismissAndRun(action: () -> Unit) {
+ if (isButtonClicked) return
+ isButtonClicked = true
+ alertDialog.dismiss()
+ action()
+ }
+}
diff --git a/app/src/main/res/drawable/bg_v2_modal.xml b/app/src/main/res/drawable/bg_v2_modal.xml
new file mode 100644
index 00000000..c410cc4e
--- /dev/null
+++ b/app/src/main/res/drawable/bg_v2_modal.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/layout/dialog_v2_modal.xml b/app/src/main/res/layout/dialog_v2_modal.xml
new file mode 100644
index 00000000..907d762c
--- /dev/null
+++ b/app/src/main/res/layout/dialog_v2_modal.xml
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/components/modal/V2ModalDialogTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/components/modal/V2ModalDialogTest.kt
new file mode 100644
index 00000000..1aca224d
--- /dev/null
+++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/components/modal/V2ModalDialogTest.kt
@@ -0,0 +1,161 @@
+package kr.co.vividnext.sodalive.v2.components.modal
+
+import android.app.Application
+import android.view.View
+import androidx.appcompat.app.AlertDialog
+import androidx.fragment.app.FragmentActivity
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import java.io.File
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [28], application = Application::class)
+class V2ModalDialogTest {
+
+ @Test
+ fun `cancelButtonTitle이 blank이면 확인 버튼만 표시한다`() {
+ val activity = Robolectric.buildActivity(FragmentActivity::class.java).setup().get()
+ var confirmClickCount = 0
+ val dialog = V2ModalDialog(
+ activity = activity,
+ layoutInflater = activity.layoutInflater,
+ title = "안내",
+ desc = "확인만 있는 모달",
+ confirmButtonTitle = "확인",
+ confirmButtonClick = { confirmClickCount += 1 },
+ cancelButtonTitle = " "
+ )
+
+ dialog.show(width = 360)
+ val alertDialog = alertDialogOf(dialog)
+ assertTrue(alertDialog.isShowing)
+ dialog.dialogView.tvConfirm.performClick()
+
+ assertEquals(View.GONE, dialog.dialogView.tvCancel.visibility)
+ assertEquals(View.VISIBLE, dialog.dialogView.tvConfirm.visibility)
+ assertEquals("확인", dialog.dialogView.tvConfirm.text.toString())
+ assertFalse(alertDialog.isShowing)
+ assertEquals(1, confirmClickCount)
+ }
+
+ @Test
+ fun `확인 버튼을 빠르게 여러 번 눌러도 액션은 한 번만 실행한다`() {
+ val activity = Robolectric.buildActivity(FragmentActivity::class.java).setup().get()
+ var confirmClickCount = 0
+ val dialog = V2ModalDialog(
+ activity = activity,
+ layoutInflater = activity.layoutInflater,
+ title = "안내",
+ desc = "중복 클릭 방지",
+ confirmButtonTitle = "확인",
+ confirmButtonClick = { confirmClickCount += 1 }
+ )
+
+ dialog.show(width = 360)
+ dialog.dialogView.tvConfirm.performClick()
+ dialog.dialogView.tvConfirm.performClick()
+
+ assertEquals(1, confirmClickCount)
+ }
+
+ @Test
+ fun `cancelButtonTitle이 있으면 두 버튼을 표시하고 취소 액션을 실행한다`() {
+ val activity = Robolectric.buildActivity(FragmentActivity::class.java).setup().get()
+ var cancelClickCount = 0
+ val dialog = V2ModalDialog(
+ activity = activity,
+ layoutInflater = activity.layoutInflater,
+ title = "삭제",
+ desc = "정말 삭제할까요?",
+ confirmButtonTitle = "삭제",
+ confirmButtonClick = {},
+ cancelButtonTitle = "취소",
+ cancelButtonClick = { cancelClickCount += 1 }
+ )
+
+ dialog.show(width = 360)
+ val alertDialog = alertDialogOf(dialog)
+ assertTrue(alertDialog.isShowing)
+ dialog.dialogView.tvCancel.performClick()
+
+ assertEquals(View.VISIBLE, dialog.dialogView.tvCancel.visibility)
+ assertEquals(View.VISIBLE, dialog.dialogView.tvConfirm.visibility)
+ assertEquals("취소", dialog.dialogView.tvCancel.text.toString())
+ assertEquals("삭제", dialog.dialogView.tvConfirm.text.toString())
+ assertFalse(alertDialog.isShowing)
+ assertEquals(1, cancelClickCount)
+ }
+
+ @Test
+ fun `title과 desc는 파라미터 값을 표시한다`() {
+ val activity = Robolectric.buildActivity(FragmentActivity::class.java).setup().get()
+ val dialog = V2ModalDialog(
+ activity = activity,
+ layoutInflater = activity.layoutInflater,
+ title = "모달 제목",
+ desc = "첫 줄\n두 번째 줄",
+ confirmButtonTitle = "확인",
+ confirmButtonClick = {}
+ )
+
+ assertEquals("모달 제목", dialog.dialogView.tvTitle.text.toString())
+ assertEquals("첫 줄\n두 번째 줄", dialog.dialogView.tvDesc.text.toString())
+ }
+
+ @Test
+ fun `cancelButtonClick이 null이어도 취소 버튼은 모달을 닫는다`() {
+ val activity = Robolectric.buildActivity(FragmentActivity::class.java).setup().get()
+ val dialog = V2ModalDialog(
+ activity = activity,
+ layoutInflater = activity.layoutInflater,
+ title = "안내",
+ desc = "취소 액션 없음",
+ confirmButtonTitle = "확인",
+ confirmButtonClick = {},
+ cancelButtonTitle = "취소"
+ )
+
+ dialog.show(width = 360)
+ val alertDialog = alertDialogOf(dialog)
+ assertTrue(alertDialog.isShowing)
+ dialog.dialogView.tvCancel.performClick()
+
+ assertFalse(alertDialog.isShowing)
+ }
+
+ @Test
+ fun `layout과 drawable은 Figma 확정 리소스 계약을 포함한다`() {
+ val layout = projectFile("app/src/main/res/layout/dialog_v2_modal.xml").readText()
+ val background = projectFile("app/src/main/res/drawable/bg_v2_modal.xml").readText()
+
+ assertTrue(layout.contains("android:background=\"@drawable/bg_v2_modal\""))
+ assertTrue(layout.contains("android:id=\"@+id/tv_title\""))
+ assertTrue(layout.contains("android:minHeight=\"64dp\""))
+ assertTrue(layout.contains("android:id=\"@+id/tv_desc\""))
+ assertTrue(layout.contains("android:id=\"@+id/tv_cancel\""))
+ assertTrue(layout.contains("android:id=\"@+id/tv_confirm\""))
+ assertTrue(layout.contains("android:textColor=\"@color/soda_400\""))
+ assertTrue(layout.contains("android:textColor=\"@color/white\""))
+ assertTrue(background.contains("@color/gray_900"))
+ assertTrue(background.contains("@dimen/radius_14"))
+ }
+
+ private fun projectFile(path: String): File = File(projectRoot(), path)
+
+ private fun alertDialogOf(dialog: V2ModalDialog): AlertDialog {
+ val field = V2ModalDialog::class.java.getDeclaredField("alertDialog")
+ field.isAccessible = true
+ return field.get(dialog) as AlertDialog
+ }
+
+ private fun projectRoot(): File {
+ return generateSequence(File(System.getProperty("user.dir") ?: ".").absoluteFile) { it.parentFile }
+ .first { File(it, "settings.gradle").exists() }
+ }
+}