Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ fun Project.configureBuildTypes(
"ADMOB_BANNER_UNIT_ID",
properties.getQuotedProperty("admob.banner.$prefix.id")
)
buildConfigField(
"String",
"ADMOB_INTERSTITIAL_UNIT_ID",
properties.getQuotedProperty("admob.interstitial.$prefix.id")
)
}

commonExtension.apply {
Expand Down
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
Expand Up @@ -2,4 +2,5 @@ package com.hilingual.core.ads.manager

interface AdsPreloadManager {
fun preloadBanner(adUnitId: String, maxHeight: Int? = null)
fun preloadInterstitial(adUnitId: String)
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import android.content.Context
import com.google.android.libraries.ads.mobile.sdk.banner.AdSize
import com.google.android.libraries.ads.mobile.sdk.banner.BannerAdPreloader
import com.google.android.libraries.ads.mobile.sdk.banner.BannerAdRequest
import com.google.android.libraries.ads.mobile.sdk.common.AdRequest
import com.google.android.libraries.ads.mobile.sdk.common.PreloadConfiguration
import com.google.android.libraries.ads.mobile.sdk.interstitial.InterstitialAdPreloader
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
Expand Down Expand Up @@ -35,4 +37,16 @@ internal class AdsPreloadManagerImpl @Inject constructor(
Timber.tag("GMA").e(e, "배너 프리로딩 시작 실패: %s", adUnitId)
}
}

override fun preloadInterstitial(adUnitId: String) {
try {
val adRequest = AdRequest.Builder(adUnitId).build()
val preloadConfig = PreloadConfiguration(adRequest)

InterstitialAdPreloader.start(adUnitId, preloadConfig)
Timber.tag("GMA").d("GMA Next Gen 전면 광고 프리로딩 시작: %s", adUnitId)
} catch (e: Exception) {
Timber.tag("GMA").e(e, "전면 광고 프리로딩 실패: %s", adUnitId)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,8 @@ interface DiaryRemoteDataSource {
suspend fun deleteDiary(
diaryId: Long,
): BaseResponse<Unit>

suspend fun patchAdWatch(
diaryId: Long,
): BaseResponse<Unit>
}
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,7 @@ internal class DiaryRemoteDataSourceImpl @Inject constructor(

override suspend fun deleteDiary(diaryId: Long): BaseResponse<Unit> =
diaryService.deleteDiary(diaryId)

override suspend fun patchAdWatch(diaryId: Long): BaseResponse<Unit> =
diaryService.patchAdWatch(diaryId)
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ data class DiaryContentResponseDto(
val imageUrl: String?,
@SerialName("isPublished")
val isPublished: Boolean,
/*TODO:: isAdWatched — 서버 연결 시 추가*/
)

@Serializable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ data class DiaryContentModel(
val diffRanges: List<DiaryContentFeedback>,
val imageUrl: String?,
val isPublished: Boolean,
val isAdWatched: Boolean,
)

data class DiaryContentFeedback(
Expand All @@ -41,4 +42,5 @@ internal fun DiaryContentResponseDto.toModel() = DiaryContentModel(
)
},
isPublished = this.isPublished,
isAdWatched = false, // TODO:: 서버 연결 시 this.isAdWatched로 교체
)
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,8 @@ interface DiaryRepository {
suspend fun deleteDiary(
diaryId: Long,
): Result<Unit>

suspend fun patchAdWatch(
diaryId: Long,
): Result<Unit>
}
Original file line number Diff line number Diff line change
Expand Up @@ -99,4 +99,9 @@ internal class DiaryRepositoryImpl @Inject constructor(
suspendRunCatching {
diaryRemoteDataSource.deleteDiary(diaryId)
}

override suspend fun patchAdWatch(diaryId: Long): Result<Unit> =
suspendRunCatching {
diaryRemoteDataSource.patchAdWatch(diaryId)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,9 @@ interface DiaryService {
suspend fun deleteDiary(
@Path("diaryId") diaryId: Long,
): BaseResponse<Unit>

@PATCH("/api/v1/diaries/{diaryId}/ad-watch")
suspend fun patchAdWatch(
@Path("diaryId") diaryId: Long,
): BaseResponse<Unit>
}
1 change: 1 addition & 0 deletions presentation/diaryfeedback/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,6 @@ android {
}

dependencies {
implementation(projects.core.ads)
implementation(projects.data.diary)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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) }
Expand All @@ -88,6 +92,10 @@ internal fun DiaryFeedbackRoute(
val messageController = LocalMessageController.current
val tracker = LocalTracker.current

LaunchedEffect(Unit) {
viewModel.loadInitialData()
}

BackHandler {
if (isImageDetailVisible) {
isImageDetailVisible = false
Expand All @@ -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() },
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onAdDismissed = viewModel::fetchAdWatched
로 안하구 람다 생성해서 넘기신 이유가 무엇인가요?? 다른 의도나 추후 확장성을 고려하신건지 궁금합니다 🙂

Copy link
Copy Markdown
Collaborator Author

@nhyeonii nhyeonii Mar 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

특별한 의도는 없었습니다! a437a07 말씀해주신 대로 viewModel::fetchAdWatched로 변경하겠습니다 !

)
} else {
viewModel.fetchAdWatched()
}
Comment on lines +115 to +117
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onAdWatched는 서버로 내가 Ad를 봤다고 보내는 로직으로 이해했는데요, 광고 시청을 완료했는지와 상관없이 호출해주는 걸까요??

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

넵 광고 정상 시청과 실패 모두 동일하게 onAdDismissed를 호출하고 fetchAdWatched()로 넘어가도록 의도적으로 설계했습니다 ! fetchAdWatched() 내부에서 낙관적 업데이트 후 patchAdWatch() API를 호출하고 실패 시 onLogFailure에서 처리하는 구조로 모든 케이스를 일관되게 흘리는 방식입니다. 광고 실패가 사용자 책임이 아니기 때문에 실패했다고 피드백 화면 진입을 막는 건 잘못된 UX라고 판단했고 서버 동기화는 재시도 로직으로 보완할 예정입니다 !!

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

고민이 되는 부분이었을텐데 광고 실패를 사용자의 책임으로 판단하지 않고 화면 진입을 할 수 있도록 처리한 설계가 좋은 것 같아요 🤩

}

is DiaryFeedbackSideEffect.ShowErrorDialog ->
dialogTrigger.show(onClick = navigateUp)

is DiaryFeedbackSideEffect.ShowDiaryPublishSnackbar -> {
messageController(
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import kotlinx.collections.immutable.toImmutableList
internal data class DiaryFeedbackUiState(
val writtenDate: String = "",
val isPublished: Boolean = false,
val isAdWatched: Boolean = false,
val diaryContent: DiaryContent = DiaryContent(),
val feedbackList: ImmutableList<FeedbackContent> = persistentListOf(),
val recommendExpressionList: ImmutableList<RecommendExpression> = persistentListOf(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}.onLogFailure {
_uiState.update { UiState.Failure }
_sideEffect.emit(
Expand All @@ -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) }
/*TODO: 서버 동기화 실패 처리
patchAdWatch API 실패 시 서버는 여전히 isAdWatched = false 상태입니다.
WorkManager로 Sync 필요*/
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요거 너무 좋은데 포맷팅만 조금 고칩시다! 공백이라던가ㅎㅎ

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

좋아요 !! c7dfc6a 커밋에 반영했습니다 !

}
}
}

fun toggleIsPublished(isPublished: Boolean) {
val currentState = _uiState.value
if (currentState !is UiState.Success) return
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions presentation/diarywrite/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ dependencies {
implementation(libs.balloon.compose)
implementation(projects.data.calendar)
implementation(projects.data.diary)
implementation(projects.core.ads)

implementation(libs.lottie)
}
Loading