diff --git a/core/designsystem/src/main/java/com/neki/android/core/designsystem/dialog/DoubleButtonAlertDialog.kt b/core/designsystem/src/main/java/com/neki/android/core/designsystem/dialog/DoubleButtonAlertDialog.kt index 5c917cba4..ce924ae12 100644 --- a/core/designsystem/src/main/java/com/neki/android/core/designsystem/dialog/DoubleButtonAlertDialog.kt +++ b/core/designsystem/src/main/java/com/neki/android/core/designsystem/dialog/DoubleButtonAlertDialog.kt @@ -37,7 +37,9 @@ fun DoubleButtonAlertDialog( onClickPrimaryButton: () -> Unit, onClickGrayButton: () -> Unit, modifier: Modifier = Modifier, - properties: DialogProperties = DialogProperties(), + properties: DialogProperties = DialogProperties( + usePlatformDefaultWidth = false, + ), ) { Dialog( onDismissRequest = onDismissRequest, diff --git a/core/designsystem/src/main/java/com/neki/android/core/designsystem/dialog/SingleButtonAlertDialog.kt b/core/designsystem/src/main/java/com/neki/android/core/designsystem/dialog/SingleButtonAlertDialog.kt index cbdc24bd0..ba4cc3ec2 100644 --- a/core/designsystem/src/main/java/com/neki/android/core/designsystem/dialog/SingleButtonAlertDialog.kt +++ b/core/designsystem/src/main/java/com/neki/android/core/designsystem/dialog/SingleButtonAlertDialog.kt @@ -34,6 +34,7 @@ fun SingleButtonAlertDialog( onClick: () -> Unit, enabled: Boolean = true, properties: DialogProperties = DialogProperties( + usePlatformDefaultWidth = false, dismissOnBackPress = false, dismissOnClickOutside = false, ), diff --git a/core/designsystem/src/main/java/com/neki/android/core/designsystem/dialog/SingleButtonWithTextButtonAlertDialog.kt b/core/designsystem/src/main/java/com/neki/android/core/designsystem/dialog/SingleButtonWithTextButtonAlertDialog.kt new file mode 100644 index 000000000..27aeb1470 --- /dev/null +++ b/core/designsystem/src/main/java/com/neki/android/core/designsystem/dialog/SingleButtonWithTextButtonAlertDialog.kt @@ -0,0 +1,128 @@ +package com.neki.android.core.designsystem.dialog + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.vectorResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.button.CTAButtonPrimary +import com.neki.android.core.designsystem.modifier.clickableSingle +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +fun SingleButtonWithTextButtonAlertDialog( + title: String, + content: String, + buttonText: String, + textButtonText: String, + onDismissRequest: () -> Unit, + onButtonClick: () -> Unit, + onTextButtonClick: () -> Unit, + enabled: Boolean = true, + properties: DialogProperties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnBackPress = false, + dismissOnClickOutside = false, + ), +) { + Dialog( + onDismissRequest = onDismissRequest, + properties = properties, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .widthIn(max = 400.dp) + .clip(RoundedCornerShape(20.dp)) + .background(NekiTheme.colorScheme.white) + .padding(top = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.icon_dialog_alert), + tint = Color.Unspecified, + contentDescription = null, + ) + Column( + modifier = Modifier.padding(horizontal = 24.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = title, + style = NekiTheme.typography.title18Bold, + color = NekiTheme.colorScheme.gray900, + textAlign = TextAlign.Center, + ) + Text( + text = content, + style = NekiTheme.typography.body14Regular, + color = NekiTheme.colorScheme.gray500, + textAlign = TextAlign.Center, + ) + } + Column( + modifier = Modifier.padding(vertical = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + CTAButtonPrimary( + text = buttonText, + onClick = onButtonClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp), + enabled = enabled, + ) + Text( + modifier = Modifier + .padding( + vertical = 4.dp, + horizontal = 56.dp, + ) + .clickableSingle(onClick = onTextButtonClick), + text = textButtonText, + style = NekiTheme.typography.body14Regular, + color = NekiTheme.colorScheme.primary600, + textDecoration = TextDecoration.Underline, + ) + } + } + } +} + +@ComponentPreview +@Composable +private fun SingleButtonWithTextButtonAlertDialogPreview() { + NekiTheme { + SingleButtonWithTextButtonAlertDialog( + title = "메인 텍스트가 들어가는 곳", + content = "보조 설명 텍스트가 들어가는 공간입니다", + buttonText = "텍스트", + textButtonText = "텍스트 공간", + onDismissRequest = {}, + onButtonClick = {}, + onTextButtonClick = {}, + ) + } +} diff --git a/detekt-config.yml b/detekt-config.yml index 417cb4487..f034288ca 100644 --- a/detekt-config.yml +++ b/detekt-config.yml @@ -54,6 +54,8 @@ exceptions: active: false style: + FunctionOnlyReturningConstant: + active: false UnusedParameter: active: false MagicNumber: diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/navigation/ArchiveEntryProvider.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/navigation/ArchiveEntryProvider.kt index 8907ccbed..8774ab5dd 100644 --- a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/navigation/ArchiveEntryProvider.kt +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/navigation/ArchiveEntryProvider.kt @@ -21,6 +21,7 @@ import com.neki.android.feature.archive.impl.main.ArchiveMainViewModel import com.neki.android.feature.archive.impl.photo.AllPhotoRoute import com.neki.android.feature.archive.impl.photo_detail.PhotoDetailRoute import com.neki.android.feature.archive.impl.photo_detail.PhotoDetailViewModel +import com.neki.android.feature.photo_upload.api.QRScanResult import com.neki.android.feature.photo_upload.api.navigateToQRScan import com.neki.android.feature.photo_upload.api.navigateToUploadAlbum import dagger.Module @@ -44,8 +45,16 @@ private fun EntryProviderScope.archiveEntry(navigator: Navigator) { entry { val resultBus = LocalResultEventBus.current val viewModel = hiltViewModel() - ResultEffect(resultBus) { imageUrl -> - viewModel.store.onIntent(ArchiveMainIntent.QRCodeScanned(imageUrl)) + ResultEffect(resultBus) { result -> + when (result) { + is QRScanResult.QRCodeScanned -> { + viewModel.store.onIntent(ArchiveMainIntent.QRCodeScanned(result.imageUrl)) + } + + QRScanResult.OpenGallery -> { + viewModel.store.onIntent(ArchiveMainIntent.ClickGalleryUploadRow) + } + } } ArchiveMainRoute( diff --git a/feature/photo-upload/api/src/main/java/com/neki/android/feature/photo_upload/api/QRScanResult.kt b/feature/photo-upload/api/src/main/java/com/neki/android/feature/photo_upload/api/QRScanResult.kt new file mode 100644 index 000000000..e705e0870 --- /dev/null +++ b/feature/photo-upload/api/src/main/java/com/neki/android/feature/photo_upload/api/QRScanResult.kt @@ -0,0 +1,6 @@ +package com.neki.android.feature.photo_upload.api + +sealed interface QRScanResult { + data class QRCodeScanned(val imageUrl: String) : QRScanResult + data object OpenGallery : QRScanResult +} diff --git a/feature/photo-upload/impl/build.gradle.kts b/feature/photo-upload/impl/build.gradle.kts index ac7199d5b..9f0dac88c 100644 --- a/feature/photo-upload/impl/build.gradle.kts +++ b/feature/photo-upload/impl/build.gradle.kts @@ -20,6 +20,7 @@ android { } defaultConfig { + buildConfigField("String", "BRAND_PROPOSAL_URL", properties["BRAND_PROPOSAL_URL"].toString()) buildConfigField("String", "PHOTOISM_URL", properties["PHOTOISM_URL"].toString()) buildConfigField("String", "PHOTOISM_IMAGE_URL", properties["PHOTOISM_IMAGE_URL"].toString()) buildConfigField("String", "PHOTOISM_IMG_URL_MIME_TYPE", properties["PHOTOISM_IMG_URL_MIME_TYPE"].toString()) diff --git a/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/di/PhotoUploadEntryProvider.kt b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/di/PhotoUploadEntryProvider.kt index 6683afdb7..c2a7b90ef 100644 --- a/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/di/PhotoUploadEntryProvider.kt +++ b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/di/PhotoUploadEntryProvider.kt @@ -7,8 +7,8 @@ import com.neki.android.core.navigation.EntryProviderInstaller import com.neki.android.core.navigation.Navigator import com.neki.android.core.navigation.result.LocalResultEventBus import com.neki.android.feature.archive.api.navigateToAlbumDetail -import com.neki.android.feature.archive.api.navigateToArchive import com.neki.android.feature.photo_upload.api.PhotoUploadNavKey +import com.neki.android.feature.photo_upload.api.QRScanResult import com.neki.android.feature.photo_upload.impl.album.UploadAlbumRoute import com.neki.android.feature.photo_upload.impl.album.UploadAlbumViewModel import com.neki.android.feature.photo_upload.impl.qrscan.QRScanRoute @@ -35,10 +35,7 @@ private fun EntryProviderScope.photoUploadEntry(navigator: Navigator) { QRScanRoute( navigateBack = navigator::goBack, - navigateToHome = { - resultBus.sendResult(result = it) - navigator.navigateToArchive() - }, + setQRResult = { resultBus.sendResult(result = it) }, ) } entry { key -> diff --git a/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/qrscan/QRScanContract.kt b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/qrscan/QRScanContract.kt index 3630c8a6b..7cd80c42a 100644 --- a/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/qrscan/QRScanContract.kt +++ b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/qrscan/QRScanContract.kt @@ -5,7 +5,8 @@ data class QRScanState( val viewType: QRScanViewType = QRScanViewType.QR_SCAN, val scannedUrl: String? = null, val detectedImageUrl: String? = null, - val isShowInfoDialog: Boolean = false, + val isShowShouldDownloadDialog: Boolean = false, + val isShowUnSupportedBrandDialog: Boolean = false, val isTorchEnabled: Boolean = false, ) @@ -15,12 +16,19 @@ sealed interface QRScanIntent { data class ScanQRCode(val scannedUrl: String) : QRScanIntent data class SetViewType(val viewType: QRScanViewType) : QRScanIntent data class DetectImageUrl(val imageUrl: String) : QRScanIntent + data object DismissShouldDownloadDialog : QRScanIntent + data object ClickGoDownload : QRScanIntent + data object DismissUnSupportedBrandDialog : QRScanIntent + data object ClickUploadGallery : QRScanIntent + data object ClickProposeBrand : QRScanIntent } sealed interface QRScanSideEffect { data object NavigateBack : QRScanSideEffect - data class NavigateToHome(val imageUrl: String) : QRScanSideEffect + data class SetQRScannedResult(val imageUrl: String) : QRScanSideEffect data class ShowToast(val message: String) : QRScanSideEffect + data object OpenBrandProposalUrl : QRScanSideEffect + data object SetOpenGalleryResult : QRScanSideEffect } enum class QRScanViewType { diff --git a/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/qrscan/QRScanScreen.kt b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/qrscan/QRScanScreen.kt index 1332aa499..09d5d491d 100644 --- a/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/qrscan/QRScanScreen.kt +++ b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/qrscan/QRScanScreen.kt @@ -1,15 +1,24 @@ package com.neki.android.feature.photo_upload.impl.qrscan +import android.content.Intent import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview +import androidx.core.net.toUri import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.neki.android.core.designsystem.dialog.SingleButtonAlertDialog +import com.neki.android.core.designsystem.dialog.SingleButtonWithTextButtonAlertDialog import com.neki.android.core.designsystem.ui.theme.NekiTheme import com.neki.android.core.ui.compose.collectWithLifecycle +import com.neki.android.core.ui.toast.NekiToast +import com.neki.android.feature.photo_upload.api.QRScanResult +import com.neki.android.feature.photo_upload.impl.BuildConfig import com.neki.android.feature.photo_upload.impl.qrscan.component.PhotoWebViewContent import com.neki.android.feature.photo_upload.impl.qrscan.component.QRScannerContent @@ -17,15 +26,29 @@ import com.neki.android.feature.photo_upload.impl.qrscan.component.QRScannerCont internal fun QRScanRoute( viewModel: QRScanViewModel = hiltViewModel(), navigateBack: () -> Unit, - navigateToHome: (String) -> Unit, + setQRResult: (QRScanResult) -> Unit = {}, ) { val uiState by viewModel.store.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + val nekiToast = remember { NekiToast(context) } viewModel.store.sideEffects.collectWithLifecycle { sideEffect -> when (sideEffect) { QRScanSideEffect.NavigateBack -> navigateBack() - is QRScanSideEffect.NavigateToHome -> navigateToHome(sideEffect.imageUrl) - is QRScanSideEffect.ShowToast -> {} + is QRScanSideEffect.SetQRScannedResult -> { + setQRResult(QRScanResult.QRCodeScanned(sideEffect.imageUrl)) + navigateBack() + } + is QRScanSideEffect.ShowToast -> nekiToast.showToast(sideEffect.message) + QRScanSideEffect.OpenBrandProposalUrl -> { + val intent = Intent(Intent.ACTION_VIEW, BuildConfig.BRAND_PROPOSAL_URL.toUri()) + context.startActivity(intent) + } + + QRScanSideEffect.SetOpenGalleryResult -> { + setQRResult(QRScanResult.OpenGallery) + navigateBack() + } } } QRScanScreen( @@ -63,6 +86,28 @@ internal fun QRScanScreen( } } } + + if (uiState.isShowShouldDownloadDialog) { + SingleButtonAlertDialog( + title = "갤러리에 사진을 먼저 다운받아주세요", + content = "해당 브랜드는 웹사이트에서 사진을 저장해야\n네키에 자동으로 저장돼요", + buttonText = "사진 다운로드하러가기", + onDismissRequest = { onIntent(QRScanIntent.DismissShouldDownloadDialog) }, + onClick = { onIntent(QRScanIntent.ClickGoDownload) }, + ) + } + + if (uiState.isShowUnSupportedBrandDialog) { + SingleButtonWithTextButtonAlertDialog( + title = "지원하지 않는 브랜드예요", + content = "갤러리에서 사진을 추가해 바로 저장할 수 있어요\n원하는 브랜드가 있다면 제안해주세요!", + buttonText = "갤러리에서 추가하기", + textButtonText = "브랜드 제안하기", + onDismissRequest = { onIntent(QRScanIntent.DismissUnSupportedBrandDialog) }, + onButtonClick = { onIntent(QRScanIntent.ClickUploadGallery) }, + onTextButtonClick = { onIntent(QRScanIntent.ClickProposeBrand) }, + ) + } } @Preview(showBackground = true) diff --git a/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/qrscan/QRScanViewModel.kt b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/qrscan/QRScanViewModel.kt index 55f206884..081ae7321 100644 --- a/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/qrscan/QRScanViewModel.kt +++ b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/qrscan/QRScanViewModel.kt @@ -3,6 +3,7 @@ package com.neki.android.feature.photo_upload.impl.qrscan import androidx.lifecycle.ViewModel import com.neki.android.core.ui.MviIntentStore import com.neki.android.core.ui.mviIntentStore +import com.neki.android.feature.photo_upload.impl.BuildConfig import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @@ -24,11 +25,28 @@ internal class QRScanViewModel @Inject constructor() : ViewModel() { when (intent) { QRScanIntent.ToggleTorch -> reduce { copy(isTorchEnabled = !this.isTorchEnabled) } QRScanIntent.ClickCloseQRScan -> postSideEffect(QRScanSideEffect.NavigateBack) - is QRScanIntent.ScanQRCode -> reduce { - copy( - scannedUrl = intent.scannedUrl, - viewType = QRScanViewType.WEB_VIEW, - ) + is QRScanIntent.ScanQRCode -> { + val scannedUrl = intent.scannedUrl + + if (isSupportedBrand(scannedUrl)) { + if (isShouldFirstDownloadBrand(scannedUrl)) { + reduce { + copy( + scannedUrl = intent.scannedUrl, + isShowShouldDownloadDialog = true, + ) + } + } else { + reduce { + copy( + scannedUrl = intent.scannedUrl, + viewType = QRScanViewType.WEB_VIEW, + ) + } + } + } else { + reduce { copy(isShowUnSupportedBrandDialog = true) } + } } is QRScanIntent.SetViewType -> { @@ -36,7 +54,28 @@ internal class QRScanViewModel @Inject constructor() : ViewModel() { postSideEffect(QRScanSideEffect.ShowToast("QR코드를 인식하지 못했습니다.")) } - is QRScanIntent.DetectImageUrl -> postSideEffect(QRScanSideEffect.NavigateToHome(intent.imageUrl)) + is QRScanIntent.DetectImageUrl -> postSideEffect(QRScanSideEffect.SetQRScannedResult(intent.imageUrl)) + + QRScanIntent.DismissShouldDownloadDialog -> reduce { copy(isShowShouldDownloadDialog = false) } + QRScanIntent.ClickGoDownload -> reduce { + copy( + viewType = QRScanViewType.WEB_VIEW, + isShowShouldDownloadDialog = false, + ) + } + + QRScanIntent.DismissUnSupportedBrandDialog -> reduce { copy(isShowUnSupportedBrandDialog = false) } + QRScanIntent.ClickUploadGallery -> postSideEffect(QRScanSideEffect.SetOpenGalleryResult) + QRScanIntent.ClickProposeBrand -> postSideEffect(QRScanSideEffect.OpenBrandProposalUrl) } } + + private fun isSupportedBrand(url: String): Boolean { + return url.startsWith(BuildConfig.PHOTOISM_URL) || + url.startsWith(BuildConfig.LIFE_FOUR_CUT_URL) + } + + private fun isShouldFirstDownloadBrand(url: String): Boolean { + return false + } } diff --git a/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/qrscan/util/QRImageAnalyzer.kt b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/qrscan/util/QRImageAnalyzer.kt index 626725a4e..d5f9df675 100644 --- a/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/qrscan/util/QRImageAnalyzer.kt +++ b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/qrscan/util/QRImageAnalyzer.kt @@ -9,7 +9,6 @@ import com.google.mlkit.vision.barcode.BarcodeScannerOptions import com.google.mlkit.vision.barcode.BarcodeScanning import com.google.mlkit.vision.barcode.common.Barcode import com.google.mlkit.vision.common.InputImage -import com.neki.android.feature.photo_upload.impl.BuildConfig import timber.log.Timber class QRImageAnalyzer( @@ -50,14 +49,12 @@ class QRImageAnalyzer( val scanArea = scanAreaRatio() if (scanArea == null || isInScanArea(centerXRatio, centerYRatio, scanArea)) { - if (isSupportedPhotoBoothUrl(url)) { - onQRCodeScanned(url) - } + onQRCodeScanned(url) } } } } - .addOnFailureListener { e -> Timber.Forest.e(e, "Barcode scanning failed") } + .addOnFailureListener { e -> Timber.e(e, "Barcode scanning failed") } .addOnCompleteListener { imageProxy.close() } } else { imageProxy.close() @@ -70,9 +67,4 @@ class QRImageAnalyzer( yRatio >= scanArea.top && yRatio <= scanArea.bottom } - - private fun isSupportedPhotoBoothUrl(url: String): Boolean { - return url.startsWith(BuildConfig.PHOTOISM_URL) || - url.startsWith(BuildConfig.LIFE_FOUR_CUT_URL) - } }