-
Notifications
You must be signed in to change notification settings - Fork 1
[FEAT/#730] 피드백 확인 전면 광고 구현 #736
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 7 commits
a16f897
77289f2
388394f
da41098
19889b7
4031480
32c167d
c7dfc6a
a437a07
b8a8e7d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| package com.hilingual.core.ads.interstitial | ||
|
|
||
| import android.app.Activity | ||
| import com.google.android.libraries.ads.mobile.sdk.common.AdLoadCallback | ||
| import com.google.android.libraries.ads.mobile.sdk.common.AdRequest | ||
| import com.google.android.libraries.ads.mobile.sdk.common.FullScreenContentError | ||
| import com.google.android.libraries.ads.mobile.sdk.common.LoadAdError | ||
| import com.google.android.libraries.ads.mobile.sdk.interstitial.InterstitialAd | ||
| import com.google.android.libraries.ads.mobile.sdk.interstitial.InterstitialAdEventCallback | ||
| import com.google.android.libraries.ads.mobile.sdk.interstitial.InterstitialAdPreloader | ||
| import timber.log.Timber | ||
|
|
||
| fun showInterstitialAd( | ||
| activity: Activity, | ||
| adUnitId: String, | ||
| onAdDismissed: () -> Unit, | ||
| ) { | ||
| val preloadedAd = InterstitialAdPreloader.pollAd(adUnitId) | ||
|
|
||
| if (preloadedAd != null) { | ||
| Timber.tag("GMA").d("프리로드된 전면 광고를 표시합니다.") | ||
| preloadedAd.adEventCallback = createEventCallback(onAdDismissed) | ||
| preloadedAd.show(activity) | ||
| } else { | ||
| Timber.tag("GMA").d("프리로드된 광고 없음, 새로 로드 후 표시합니다.") | ||
| val adRequest = AdRequest.Builder(adUnitId).build() | ||
| InterstitialAd.load( | ||
| adRequest, | ||
| object : AdLoadCallback<InterstitialAd> { | ||
| override fun onAdLoaded(ad: InterstitialAd) { | ||
| if (!activity.isFinishing && !activity.isDestroyed) { | ||
| ad.adEventCallback = createEventCallback(onAdDismissed) | ||
| ad.show(activity) | ||
| } else { | ||
| Timber.tag("GMA").w("Activity가 이미 종료 상태라 전면 광고를 표시하지 않습니다.") | ||
| onAdDismissed() | ||
| } | ||
| } | ||
|
|
||
| override fun onAdFailedToLoad(adError: LoadAdError) { | ||
| Timber.tag("GMA").e("전면 광고 로드 실패: %s", adError) | ||
| onAdDismissed() | ||
| } | ||
| }, | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| private fun createEventCallback(onAdDismissed: () -> Unit) = object : InterstitialAdEventCallback { | ||
| override fun onAdDismissedFullScreenContent() { | ||
| Timber.tag("GMA").d("전면 광고 닫힘 → 피드백 화면으로 이동") | ||
| onAdDismissed() | ||
| } | ||
|
|
||
| override fun onAdFailedToShowFullScreenContent(fullScreenContentError: FullScreenContentError) { | ||
| Timber.tag("GMA").e("전면 광고 표시 실패: %s", fullScreenContentError) | ||
| onAdDismissed() | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,6 +16,7 @@ | |
| package com.hilingual.presentation.diaryfeedback | ||
|
|
||
| import androidx.activity.compose.BackHandler | ||
| import androidx.activity.compose.LocalActivity | ||
| import androidx.compose.foundation.background | ||
| import androidx.compose.foundation.layout.Box | ||
| import androidx.compose.foundation.layout.Column | ||
|
|
@@ -41,6 +42,8 @@ import androidx.compose.ui.tooling.preview.Preview | |
| import androidx.compose.ui.unit.dp | ||
| import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel | ||
| import androidx.lifecycle.compose.collectAsStateWithLifecycle | ||
| import com.hilingual.core.ads.BuildConfig | ||
| import com.hilingual.core.ads.interstitial.showInterstitialAd | ||
| import com.hilingual.core.common.analytics.FakeTracker | ||
| import com.hilingual.core.common.analytics.Page.FEEDBACK | ||
| import com.hilingual.core.common.analytics.Tracker | ||
|
|
@@ -80,6 +83,7 @@ internal fun DiaryFeedbackRoute( | |
| viewModel: DiaryFeedbackViewModel = hiltViewModel(), | ||
| ) { | ||
| val context = LocalContext.current | ||
| val activity = LocalActivity.current | ||
|
|
||
| val state by viewModel.uiState.collectAsStateWithLifecycle() | ||
| var isImageDetailVisible by remember { mutableStateOf(false) } | ||
|
|
@@ -88,6 +92,10 @@ internal fun DiaryFeedbackRoute( | |
| val messageController = LocalMessageController.current | ||
| val tracker = LocalTracker.current | ||
|
|
||
| LaunchedEffect(Unit) { | ||
| viewModel.loadInitialData() | ||
| } | ||
|
|
||
| BackHandler { | ||
| if (isImageDetailVisible) { | ||
| isImageDetailVisible = false | ||
|
|
@@ -98,7 +106,20 @@ internal fun DiaryFeedbackRoute( | |
|
|
||
| viewModel.sideEffect.collectSideEffect { | ||
| when (it) { | ||
| is DiaryFeedbackSideEffect.ShowErrorDialog -> dialogTrigger.show(onClick = navigateUp) | ||
| is DiaryFeedbackSideEffect.ShowInterstitialAd -> { | ||
| if (activity != null) { | ||
| showInterstitialAd( | ||
| activity = activity, | ||
| adUnitId = BuildConfig.ADMOB_INTERSTITIAL_UNIT_ID, | ||
| onAdDismissed = { viewModel.fetchAdWatched() }, | ||
|
||
| ) | ||
| } else { | ||
| viewModel.fetchAdWatched() | ||
| } | ||
|
Comment on lines
+115
to
+117
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 넵 광고 정상 시청과 실패 모두 동일하게 onAdDismissed를 호출하고 fetchAdWatched()로 넘어가도록 의도적으로 설계했습니다 ! fetchAdWatched() 내부에서 낙관적 업데이트 후 patchAdWatch() API를 호출하고 실패 시 onLogFailure에서 처리하는 구조로 모든 케이스를 일관되게 흘리는 방식입니다. 광고 실패가 사용자 책임이 아니기 때문에 실패했다고 피드백 화면 진입을 막는 건 잘못된 UX라고 판단했고 서버 동기화는 재시도 로직으로 보완할 예정입니다 !!
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 고민이 되는 부분이었을텐데 광고 실패를 사용자의 책임으로 판단하지 않고 화면 진입을 할 수 있도록 처리한 설계가 좋은 것 같아요 🤩 |
||
| } | ||
|
|
||
| is DiaryFeedbackSideEffect.ShowErrorDialog -> | ||
| dialogTrigger.show(onClick = navigateUp) | ||
|
|
||
| is DiaryFeedbackSideEffect.ShowDiaryPublishSnackbar -> { | ||
| messageController( | ||
|
|
@@ -143,40 +164,52 @@ internal fun DiaryFeedbackRoute( | |
| tracker.logEvent(trigger = TriggerType.VIEW, page = FEEDBACK, event = "page") | ||
| } | ||
|
|
||
| DiaryFeedbackScreen( | ||
| paddingValues = paddingValues, | ||
| uiState = state, | ||
| diaryId = viewModel.diaryId, | ||
| onBackClick = { | ||
| tracker.logEvent( | ||
| trigger = TriggerType.CLICK, | ||
| page = FEEDBACK, | ||
| event = "back_feedback", | ||
| properties = mapOf( | ||
| "entry_id" to viewModel.diaryId, | ||
| "back_source" to "ui_button", | ||
| ), | ||
| ) | ||
| navigateUp() | ||
| }, | ||
| onReportClick = { context.launchCustomTabs(UrlConstant.FEEDBACK_REPORT) }, | ||
| isImageDetailVisible = isImageDetailVisible, | ||
| onChangeImageDetailVisible = { isImageDetailVisible = !isImageDetailVisible }, | ||
| onToggleIsPublished = { isPublished -> | ||
| if (isPublished) { | ||
| tracker.logEvent( | ||
| trigger = TriggerType.CLICK, | ||
| page = FEEDBACK, | ||
| event = "submitted_post_diary", | ||
| properties = mapOf("entry_id" to viewModel.diaryId), | ||
| when (val currentState = state) { | ||
| is UiState.Success -> { | ||
| if (!currentState.data.isAdWatched) { | ||
| HilingualLoadingIndicator() | ||
| } else { | ||
| DiaryFeedbackScreen( | ||
| paddingValues = paddingValues, | ||
| uiState = currentState, | ||
| diaryId = viewModel.diaryId, | ||
| onBackClick = { | ||
| tracker.logEvent( | ||
| trigger = TriggerType.CLICK, | ||
| page = FEEDBACK, | ||
| event = "back_feedback", | ||
| properties = mapOf( | ||
| "entry_id" to viewModel.diaryId, | ||
| "back_source" to "ui_button", | ||
| ), | ||
| ) | ||
| navigateUp() | ||
| }, | ||
| onReportClick = { context.launchCustomTabs(UrlConstant.FEEDBACK_REPORT) }, | ||
| isImageDetailVisible = isImageDetailVisible, | ||
| onChangeImageDetailVisible = { isImageDetailVisible = !isImageDetailVisible }, | ||
| onToggleIsPublished = { isPublished -> | ||
| if (isPublished) { | ||
| tracker.logEvent( | ||
| trigger = TriggerType.CLICK, | ||
| page = FEEDBACK, | ||
| event = "submitted_post_diary", | ||
| properties = mapOf("entry_id" to viewModel.diaryId), | ||
| ) | ||
| } | ||
| viewModel.toggleIsPublished(isPublished) | ||
| }, | ||
| onToggleBookmark = viewModel::toggleBookmark, | ||
| onDeleteDiary = viewModel::deleteDiary, | ||
| tracker = tracker, | ||
| ) | ||
| } | ||
| viewModel.toggleIsPublished(isPublished) | ||
| }, | ||
| onToggleBookmark = viewModel::toggleBookmark, | ||
| onDeleteDiary = viewModel::deleteDiary, | ||
| tracker = tracker, | ||
| ) | ||
| } | ||
|
|
||
| is UiState.Loading -> HilingualLoadingIndicator() | ||
|
|
||
| else -> {} | ||
| } | ||
| } | ||
|
|
||
| @Composable | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -54,16 +54,15 @@ internal class DiaryFeedbackViewModel @Inject constructor( | |
| private val _sideEffect = MutableSharedFlow<DiaryFeedbackSideEffect>() | ||
| val sideEffect: SharedFlow<DiaryFeedbackSideEffect> = _sideEffect.asSharedFlow() | ||
|
|
||
| init { | ||
| loadInitialData() | ||
| } | ||
|
|
||
| private fun loadInitialData() { | ||
| fun loadInitialData() { | ||
| viewModelScope.launch { | ||
| suspendRunCatching { | ||
| requestDiaryFeedbackData() | ||
| }.onSuccess { newUiState -> | ||
| _uiState.update { UiState.Success(newUiState) } | ||
| if (!newUiState.isAdWatched) { | ||
| _sideEffect.emit(DiaryFeedbackSideEffect.ShowInterstitialAd) | ||
| } | ||
angryPodo marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| }.onLogFailure { | ||
| _uiState.update { UiState.Failure } | ||
| _sideEffect.emit( | ||
|
|
@@ -85,13 +84,28 @@ internal class DiaryFeedbackViewModel @Inject constructor( | |
|
|
||
| DiaryFeedbackUiState( | ||
| isPublished = diaryResult.isPublished, | ||
| isAdWatched = diaryResult.isAdWatched, | ||
| writtenDate = diaryResult.writtenDate, | ||
| diaryContent = diaryResult.toState(), | ||
| feedbackList = feedbacksResult.map { it.toState() }.toImmutableList(), | ||
| recommendExpressionList = recommendExpressionsResult.map { it.toState() }.toImmutableList(), | ||
| ) | ||
| } | ||
|
|
||
| fun fetchAdWatched() { | ||
| _uiState.updateSuccess { it.copy(isAdWatched = true) } | ||
|
|
||
| viewModelScope.launch { | ||
| diaryRepository.patchAdWatch(diaryId) | ||
| .onLogFailure { | ||
| _uiState.updateSuccess { it.copy(isAdWatched = true) } | ||
angryPodo marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| /*TODO: 서버 동기화 실패 처리 | ||
| patchAdWatch API 실패 시 서버는 여전히 isAdWatched = false 상태입니다. | ||
| WorkManager로 Sync 필요*/ | ||
|
||
| } | ||
| } | ||
| } | ||
|
|
||
| fun toggleIsPublished(isPublished: Boolean) { | ||
| val currentState = _uiState.value | ||
| if (currentState !is UiState.Success) return | ||
|
|
@@ -190,6 +204,7 @@ internal class DiaryFeedbackViewModel @Inject constructor( | |
| sealed interface DiaryFeedbackSideEffect { | ||
| data object NavigateToHome : DiaryFeedbackSideEffect | ||
| data object ShowErrorDialog : DiaryFeedbackSideEffect | ||
| data object ShowInterstitialAd : DiaryFeedbackSideEffect | ||
| data class ShowDiaryPublishSnackbar(val message: String, val actionLabel: String) : DiaryFeedbackSideEffect | ||
| data class ShowVocaOverflowSnackbar(val message: String, val actionLabel: String) : DiaryFeedbackSideEffect | ||
| data class ShowToast(val message: String) : DiaryFeedbackSideEffect | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.