diff --git a/app/build.gradle b/app/build.gradle
index bd9df773..a563d07b 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -41,6 +41,7 @@ android {
buildConfigField "String", "REMOTE_KEY_APP_VERSION", properties["REMOTE_KEY_APP_VERSION"]
buildConfigField "String", "REMOTE_KEY_FORCE_UPDATE", properties["REMOTE_KEY_FORCE_UPDATE"]
buildConfigField "String", "KAKAO_CHANNEL_ID", properties["KAKAO_CHANNEL_ID"]
+ buildConfigField "String", "ADMOB_REWARDED_AD_UNIT_ID", properties["ADMOB_REWARDED_AD_UNIT_ID"]
}
signingConfigs {
@@ -184,6 +185,7 @@ dependencies {
// Google
implementation libs.play.services.auth
+ implementation libs.play.services.ads
// Logging
implementation libs.timber
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 2008ace7..623260ca 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -133,6 +133,11 @@
android:name="com.naver.maps.map.NCP_KEY_ID"
android:value="${NAVER_CLIENT_ID}" />
+
+
+
> =
+ remoteMarkerQuotaDataSource.getMarkerQuota().mapToFlowResult {
+ it.toMarkerQuota()
+ }
+
+ override suspend fun consumeMarkerQuota(amount: Int): Flow> =
+ remoteMarkerQuotaDataSource.consumeMarkerQuota(amount = amount).mapToFlowResult {
+ it.toMarkerQuota()
+ }
+
+ override suspend fun grantMarkerReward(
+ amount: Int,
+ rewardTransactionId: String
+ ): Flow> =
+ remoteMarkerQuotaDataSource.grantMarkerReward(
+ amount = amount,
+ rewardTransactionId = rewardTransactionId
+ ).mapToFlowResult {
+ it.toMarkerRewardResult()
+ }
+}
diff --git a/app/src/main/java/com/runnect/runnect/data/service/MarkerQuotaService.kt b/app/src/main/java/com/runnect/runnect/data/service/MarkerQuotaService.kt
new file mode 100644
index 00000000..ac544930
--- /dev/null
+++ b/app/src/main/java/com/runnect/runnect/data/service/MarkerQuotaService.kt
@@ -0,0 +1,25 @@
+package com.runnect.runnect.data.service
+
+import com.runnect.runnect.data.dto.request.RequestConsumeMarkerQuota
+import com.runnect.runnect.data.dto.request.RequestGrantMarkerReward
+import com.runnect.runnect.data.dto.response.ResponseGrantMarkerReward
+import com.runnect.runnect.data.dto.response.ResponseMarkerQuota
+import retrofit2.http.Body
+import retrofit2.http.GET
+import retrofit2.http.POST
+
+interface MarkerQuotaService {
+
+ @GET("/api/marker-quota")
+ suspend fun getMarkerQuota(): Result
+
+ @POST("/api/marker-quota/consume")
+ suspend fun consumeMarkerQuota(
+ @Body requestConsumeMarkerQuota: RequestConsumeMarkerQuota,
+ ): Result
+
+ @POST("/api/marker-quota/reward")
+ suspend fun grantMarkerReward(
+ @Body requestGrantMarkerReward: RequestGrantMarkerReward,
+ ): Result
+}
diff --git a/app/src/main/java/com/runnect/runnect/data/source/remote/RemoteMarkerQuotaDataSource.kt b/app/src/main/java/com/runnect/runnect/data/source/remote/RemoteMarkerQuotaDataSource.kt
new file mode 100644
index 00000000..4d08081f
--- /dev/null
+++ b/app/src/main/java/com/runnect/runnect/data/source/remote/RemoteMarkerQuotaDataSource.kt
@@ -0,0 +1,20 @@
+package com.runnect.runnect.data.source.remote
+
+import com.runnect.runnect.data.dto.request.RequestConsumeMarkerQuota
+import com.runnect.runnect.data.dto.request.RequestGrantMarkerReward
+import com.runnect.runnect.data.service.MarkerQuotaService
+import javax.inject.Inject
+
+class RemoteMarkerQuotaDataSource @Inject constructor(
+ private val markerQuotaService: MarkerQuotaService,
+) {
+ suspend fun getMarkerQuota() = markerQuotaService.getMarkerQuota()
+
+ suspend fun consumeMarkerQuota(amount: Int) =
+ markerQuotaService.consumeMarkerQuota(RequestConsumeMarkerQuota(amount = amount))
+
+ suspend fun grantMarkerReward(amount: Int, rewardTransactionId: String) =
+ markerQuotaService.grantMarkerReward(
+ RequestGrantMarkerReward(amount = amount, rewardTransactionId = rewardTransactionId)
+ )
+}
diff --git a/app/src/main/java/com/runnect/runnect/di/RepositoryModule.kt b/app/src/main/java/com/runnect/runnect/di/RepositoryModule.kt
index b257cb68..0cae0797 100644
--- a/app/src/main/java/com/runnect/runnect/di/RepositoryModule.kt
+++ b/app/src/main/java/com/runnect/runnect/di/RepositoryModule.kt
@@ -8,6 +8,7 @@ import com.runnect.runnect.domain.repository.BannerRepository
import com.runnect.runnect.domain.repository.CourseRepository
import com.runnect.runnect.domain.repository.DepartureSearchRepository
import com.runnect.runnect.domain.repository.LoginRepository
+import com.runnect.runnect.domain.repository.MarkerQuotaRepository
import com.runnect.runnect.domain.repository.ReverseGeocodingRepository
import com.runnect.runnect.domain.repository.StorageRepository
import com.runnect.runnect.domain.repository.UserRepository
@@ -47,4 +48,8 @@ interface RepositoryModule {
@Singleton
@Binds
fun bindBannerRepository(bannerRepositoryImpl: BannerRepositoryImpl): BannerRepository
+
+ @Singleton
+ @Binds
+ fun bindMarkerQuotaRepository(markerQuotaRepositoryImpl: MarkerQuotaRepositoryImpl): MarkerQuotaRepository
}
\ No newline at end of file
diff --git a/app/src/main/java/com/runnect/runnect/di/ServiceModule.kt b/app/src/main/java/com/runnect/runnect/di/ServiceModule.kt
index 8de7ec35..0ec58917 100644
--- a/app/src/main/java/com/runnect/runnect/di/ServiceModule.kt
+++ b/app/src/main/java/com/runnect/runnect/di/ServiceModule.kt
@@ -3,6 +3,7 @@ package com.runnect.runnect.di
import com.runnect.runnect.data.service.BannerService
import com.runnect.runnect.data.service.CourseService
import com.runnect.runnect.data.service.LoginService
+import com.runnect.runnect.data.service.MarkerQuotaService
import com.runnect.runnect.data.service.ReverseGeocodingService
import com.runnect.runnect.data.service.SearchService
import com.runnect.runnect.data.service.UserService
@@ -46,4 +47,9 @@ object ServiceModule {
@Provides
fun providePBannerService(@RetrofitModule.RetrofitV2 retrofitV2: Retrofit) =
retrofitV2.create(BannerService::class.java)
+
+ @Singleton
+ @Provides
+ fun provideMarkerQuotaService(@RetrofitModule.RetrofitV2 retrofitV2: Retrofit) =
+ retrofitV2.create(MarkerQuotaService::class.java)
}
\ No newline at end of file
diff --git a/app/src/main/java/com/runnect/runnect/domain/entity/MarkerQuota.kt b/app/src/main/java/com/runnect/runnect/domain/entity/MarkerQuota.kt
new file mode 100644
index 00000000..49ab3844
--- /dev/null
+++ b/app/src/main/java/com/runnect/runnect/domain/entity/MarkerQuota.kt
@@ -0,0 +1,5 @@
+package com.runnect.runnect.domain.entity
+
+data class MarkerQuota(
+ val balance: Int
+)
diff --git a/app/src/main/java/com/runnect/runnect/domain/entity/MarkerRewardResult.kt b/app/src/main/java/com/runnect/runnect/domain/entity/MarkerRewardResult.kt
new file mode 100644
index 00000000..0928f8b6
--- /dev/null
+++ b/app/src/main/java/com/runnect/runnect/domain/entity/MarkerRewardResult.kt
@@ -0,0 +1,6 @@
+package com.runnect.runnect.domain.entity
+
+data class MarkerRewardResult(
+ val granted: Boolean,
+ val balance: Int
+)
diff --git a/app/src/main/java/com/runnect/runnect/domain/repository/MarkerQuotaRepository.kt b/app/src/main/java/com/runnect/runnect/domain/repository/MarkerQuotaRepository.kt
new file mode 100644
index 00000000..ac614390
--- /dev/null
+++ b/app/src/main/java/com/runnect/runnect/domain/repository/MarkerQuotaRepository.kt
@@ -0,0 +1,14 @@
+package com.runnect.runnect.domain.repository
+
+import com.runnect.runnect.domain.entity.MarkerQuota
+import com.runnect.runnect.domain.entity.MarkerRewardResult
+import kotlinx.coroutines.flow.Flow
+
+interface MarkerQuotaRepository {
+
+ suspend fun getMarkerQuota(): Flow>
+
+ suspend fun consumeMarkerQuota(amount: Int): Flow>
+
+ suspend fun grantMarkerReward(amount: Int, rewardTransactionId: String): Flow>
+}
diff --git a/app/src/main/java/com/runnect/runnect/presentation/draw/DrawActivity.kt b/app/src/main/java/com/runnect/runnect/presentation/draw/DrawActivity.kt
index f75df882..0675348d 100644
--- a/app/src/main/java/com/runnect/runnect/presentation/draw/DrawActivity.kt
+++ b/app/src/main/java/com/runnect/runnect/presentation/draw/DrawActivity.kt
@@ -41,6 +41,7 @@ import com.runnect.runnect.data.dto.CourseData
import com.runnect.runnect.data.dto.SearchResultEntity
import com.runnect.runnect.data.dto.UploadLatLng
import com.runnect.runnect.databinding.ActivityDrawBinding
+import com.runnect.runnect.databinding.BottomsheetMarkerQuotaEmptyBinding
import com.runnect.runnect.databinding.BottomsheetRequireCourseNameBinding
import com.runnect.runnect.databinding.CustomDialogMakeCourseBinding
import com.runnect.runnect.presentation.MainActivity
@@ -55,6 +56,7 @@ import com.runnect.runnect.util.custom.dialog.RequireLoginDialogFragment
import com.runnect.runnect.util.extension.PermissionUtil
import com.runnect.runnect.util.extension.hideKeyboard
import com.runnect.runnect.util.extension.setActivityDialog
+import com.runnect.runnect.util.ad.RewardedAdManager
import com.runnect.runnect.util.extension.showToast
import com.runnect.runnect.util.multipart.ContentUriRequestBody
import dagger.hilt.android.AndroidEntryPoint
@@ -74,6 +76,9 @@ class DrawActivity : BindingActivity(R.layout.activity_draw
@Inject
lateinit var visitorModeManager: VisitorModeManager
+ @Inject
+ lateinit var rewardedAdManager: RewardedAdManager
+
private lateinit var locationSource: FusedLocationSource
private lateinit var currentLocation: LatLng
private lateinit var fusedLocation: FusedLocationProviderClient // 현재 위치 반환 객체 변수
@@ -102,6 +107,13 @@ class DrawActivity : BindingActivity(R.layout.activity_draw
private var distanceSum: Float = 0.0f
private var sumList = mutableListOf()
private var isMarkerAvailable: Boolean = false
+
+ /**
+ * null: 서버 조회가 아직 끝나지 않은 상태(진짜 0과 구분해야 함).
+ * 이 값이 null인 동안은 마커 배치를 막아, 잔량 조회가 끝나기 전에 "재화가
+ * 부족하다"고 잘못 판단해 팝업을 띄우는 일이 없게 한다.
+ */
+ private var markerQuotaBalance: Int? = null
val isVisitorMode: Boolean get() = visitorModeManager.isVisitorMode
var isFirstInit: Boolean = true
@@ -118,6 +130,9 @@ class DrawActivity : BindingActivity(R.layout.activity_draw
addObserver()
backButton()
courseFinish()
+ viewModel.fetchMarkerQuota()
+ rewardedAdManager.load(this)
+ setupMarkerQuotaChargeButton()
}
private fun getSearchIntent() {
@@ -406,6 +421,7 @@ class DrawActivity : BindingActivity(R.layout.activity_draw
frameCourseDistance.isVisible = true
tvCourseDistanceRecord.isVisible = true
tvCourseDistanceKm.isVisible = true
+ frameMarkerQuota.isVisible = true
}
}
@@ -438,6 +454,7 @@ class DrawActivity : BindingActivity(R.layout.activity_draw
private fun addObserver() {
observeIsBtnAvailable()
observeDrawState()
+ observeMarkerQuota()
viewModel.reverseGeocodingResult.observe(this) {
val buildingName = viewModel.reverseGeocodingResult.value!!.buildingName
@@ -480,6 +497,78 @@ class DrawActivity : BindingActivity(R.layout.activity_draw
}
}
+ private fun observeMarkerQuota() {
+ viewModel.markerQuotaBalance.observe(this) { balance ->
+ markerQuotaBalance = balance
+ binding.tvMarkerQuotaBalance.text = balance.toString()
+ }
+
+ // drawState(UiState)의 Failure는 업로드 실패/잔량 부족 둘 다에서 발생하는데,
+ // loading bar 숨김은 이미 observeDrawState()가 처리한다. 여기서는 "잔량
+ // 부족이라 실패했다"는 사실만 별도 이벤트로 받아 팝업을 띄운다.
+ viewModel.markerQuotaInsufficientEvent.observe(this) { isInsufficient ->
+ if (isInsufficient) {
+ showToast(NOTIFY_MARKER_QUOTA_INSUFFICIENT)
+ showMarkerQuotaEmptyBottomSheet()
+ viewModel.consumeMarkerQuotaInsufficientEventHandled()
+ }
+ }
+
+ viewModel.markerRewardGrantState.observe(this) {
+ if (it == UiState.Success) {
+ showToast(NOTIFY_MARKER_QUOTA_CHARGED)
+ }
+ }
+ }
+
+ private fun setupMarkerQuotaChargeButton() {
+ binding.btnMarkerQuotaCharge.setOnClickListener {
+ watchRewardedAdForMarkerQuota()
+ }
+ }
+
+ private fun watchRewardedAdForMarkerQuota() {
+ rewardedAdManager.show(
+ activity = this,
+ onUserEarnedReward = { rewardTransactionId ->
+ viewModel.grantMarkerRewardFromAd(rewardTransactionId)
+ },
+ onNotReady = {
+ showToast(NOTIFY_AD_NOT_READY)
+ }
+ )
+ }
+
+ /**
+ * 마커 잔량이 0인데 지도를 탭했을 때 뜨는 팝업.
+ */
+ private fun showMarkerQuotaEmptyBottomSheet() {
+ val bottomSheetBinding = BottomsheetMarkerQuotaEmptyBinding.inflate(layoutInflater)
+ val bottomSheetDialog = BottomSheetDialog(this)
+ bottomSheetDialog.setContentView(bottomSheetBinding.root)
+
+ bottomSheetBinding.btnMarkerQuotaWatchAd.setOnClickListener {
+ bottomSheetDialog.dismiss()
+ watchRewardedAdForMarkerQuota()
+ }
+ bottomSheetBinding.tvMarkerQuotaClose.setOnClickListener {
+ bottomSheetDialog.dismiss()
+ }
+ bottomSheetDialog.show()
+ }
+
+ private fun decrementMarkerQuotaBalance() {
+ val updated = (markerQuotaBalance ?: 0) - 1
+ markerQuotaBalance = updated
+ binding.tvMarkerQuotaBalance.text = updated.toString()
+ }
+
+ private fun incrementMarkerQuotaBalance() {
+ val updated = (markerQuotaBalance ?: 0) + 1
+ markerQuotaBalance = updated
+ binding.tvMarkerQuotaBalance.text = updated.toString()
+ }
+
private fun showLoadingBar() {
binding.indeterminateBar.isVisible = true
}
@@ -664,11 +753,22 @@ class DrawActivity : BindingActivity(R.layout.activity_draw
if (!isMarkerAvailable) return@setOnMapClickListener
viewModel.isBtnAvailable.value = true
+ val balance = markerQuotaBalance
+ if (balance == null) {
+ showToast(NOTIFY_MARKER_QUOTA_LOADING)
+ return@setOnMapClickListener
+ }
+
if (touchList.size < MAX_MARKER_NUM) {
- addCoordsToTouchList(coord)
- setRouteMarker(coord)
- generateRouteLine(coord)
- calcDistance()
+ if (balance > 0) {
+ addCoordsToTouchList(coord)
+ setRouteMarker(coord)
+ generateRouteLine(coord)
+ calcDistance()
+ decrementMarkerQuotaBalance()
+ } else {
+ showMarkerQuotaEmptyBottomSheet()
+ }
} else {
Toast.makeText(this, NOTIFY_LIMIT_MARKER_NUM, Toast.LENGTH_SHORT).show()
}
@@ -729,6 +829,7 @@ class DrawActivity : BindingActivity(R.layout.activity_draw
touchList.removeAt(touchList.lastIndex)
markerList.last().map = null
markerList.removeAt(markerList.lastIndex)
+ incrementMarkerQuotaBalance()
}
private fun updateRouteLineData() {
@@ -803,7 +904,8 @@ class DrawActivity : BindingActivity(R.layout.activity_draw
)
) //Uri -> RequestBody
setViewModelValue(calcDistanceList)
- viewModel.uploadCourse()
+ showLoadingBar()
+ viewModel.saveCourse(touchList.size)
}
}
@@ -863,6 +965,10 @@ class DrawActivity : BindingActivity(R.layout.activity_draw
const val EXTRA_FRAGMENT_REPLACEMENT_DIRECTION = "fragmentReplacementDirection"
const val CUSTOM_DEPARTURE = "내가 설정한 출발지"
const val NOTIFY_LIMIT_MARKER_NUM = "마커는 20개까지 생성 가능합니다"
+ const val NOTIFY_MARKER_QUOTA_INSUFFICIENT = "마커 재화가 부족합니다. 광고를 보고 채워주세요"
+ const val NOTIFY_MARKER_QUOTA_LOADING = "마커 정보를 불러오는 중이에요. 잠시 후 다시 시도해주세요"
+ const val NOTIFY_MARKER_QUOTA_CHARGED = "마커가 충전되었습니다"
+ const val NOTIFY_AD_NOT_READY = "광고를 불러오는 중입니다. 잠시 후 다시 시도해주세요"
const val ERROR_COURSE_NULL = "Error: Course data is incomplete"
}
diff --git a/app/src/main/java/com/runnect/runnect/presentation/draw/DrawViewModel.kt b/app/src/main/java/com/runnect/runnect/presentation/draw/DrawViewModel.kt
index c0bfc51b..cb4d15d1 100644
--- a/app/src/main/java/com/runnect/runnect/presentation/draw/DrawViewModel.kt
+++ b/app/src/main/java/com/runnect/runnect/presentation/draw/DrawViewModel.kt
@@ -7,6 +7,7 @@ import com.runnect.runnect.data.dto.UploadLatLng
import com.runnect.runnect.domain.common.toLog
import com.runnect.runnect.domain.entity.LocationData
import com.runnect.runnect.domain.repository.CourseRepository
+import com.runnect.runnect.domain.repository.MarkerQuotaRepository
import com.runnect.runnect.domain.repository.ReverseGeocodingRepository
import com.runnect.runnect.presentation.base.BaseViewModel
import com.runnect.runnect.presentation.state.UiState
@@ -14,13 +15,15 @@ import com.runnect.runnect.util.extension.collectResult
import com.runnect.runnect.util.multipart.ContentUriRequestBody
import dagger.hilt.android.lifecycle.HiltViewModel
import timber.log.Timber
+import java.util.UUID
import javax.inject.Inject
import kotlin.math.acos
@HiltViewModel
class DrawViewModel @Inject constructor(
val courseRepository: CourseRepository,
- val reverseGeocodingRepository: ReverseGeocodingRepository
+ val reverseGeocodingRepository: ReverseGeocodingRepository,
+ val markerQuotaRepository: MarkerQuotaRepository
) : BaseViewModel() {
private var _drawState = MutableLiveData(UiState.Empty)
@@ -52,6 +55,131 @@ class DrawViewModel @Inject constructor(
_image.value = requestBody
}
+ private val _markerQuotaBalance = MutableLiveData()
+ val markerQuotaBalance: LiveData
+ get() = _markerQuotaBalance
+
+ private val _markerRewardGrantState = MutableLiveData(UiState.Empty)
+ val markerRewardGrantState: LiveData
+ get() = _markerRewardGrantState
+
+ /**
+ * 마커 재화가 부족해 코스 저장이 막혔을 때 1회성으로 쏘는 이벤트.
+ * drawState(UiState)는 ViewModel 생존 중 값이 유지되는 LiveData라, 액티비티가
+ * 재생성되면 관찰을 다시 시작하는 순간 이전 값을 다시 흘려보낸다(sticky). 그래서
+ * 부족 알림은 별도 LiveData로 분리하고, 처리한 즉시 false로 되돌려 재생을 막는다.
+ */
+ private val _markerQuotaInsufficientEvent = MutableLiveData(false)
+ val markerQuotaInsufficientEvent: LiveData
+ get() = _markerQuotaInsufficientEvent
+
+ fun consumeMarkerQuotaInsufficientEventHandled() {
+ _markerQuotaInsufficientEvent.value = false
+ }
+
+ fun fetchMarkerQuota() = launchWithHandler {
+ markerQuotaRepository.getMarkerQuota().collectResult(
+ onSuccess = {
+ _markerQuotaBalance.value = it.balance
+ },
+ onFailure = {
+ Timber.e(it.toLog())
+ }
+ )
+ }
+
+ /**
+ * 코스 저장. 이번 코스에서 실제로 사용한 마커 개수만큼 서버 잔액을 먼저 차감하고,
+ * 성공했을 때만 실제 업로드를 진행한다. "차감 성공"과 "업로드 시작"을 액티비티 쪽의
+ * 서로 다른 LiveData 관찰로 나누면, 액티비티가 재생성될 때 살아있는 ViewModel의
+ * LiveData가 이전 성공 값을 다시 흘려보내(sticky) 업로드가 중복 호출될 수 있다 —
+ * 그래서 이 흐름 전체를 하나의 코루틴 안에서 순차 실행해 그럴 여지를 없앤다.
+ * 업로드가 실패하면 이미 차감한 마커를 되돌려줘야, 저장되지 않은 코스 때문에
+ * 사용자가 마커만 잃는 일이 없다.
+ */
+ fun saveCourse(markerCount: Int) {
+ _drawState.value = UiState.Loading
+ launchWithHandler {
+ markerQuotaRepository.consumeMarkerQuota(markerCount).collectResult(
+ onSuccess = {
+ _markerQuotaBalance.value = it.balance
+ uploadCourse(consumedMarkerCount = markerCount)
+ },
+ onFailure = {
+ _drawState.value = UiState.Failure
+ _markerQuotaInsufficientEvent.value = true
+ Timber.e(it.toLog())
+ }
+ )
+ }
+ }
+
+ private fun uploadCourse(consumedMarkerCount: Int) {
+ launchWithHandler {
+ courseRepository.uploadCourse(
+ image = _image.value!!.toFormData(),
+ data = CourseCreateRequestDto(
+ path = path,
+ title = courseTitle,
+ distance = distanceSum.value!!,
+ departureAddress = departureAddress,
+ departureName = departureName
+ ).toRequestBody()
+ ).collectResult(
+ onSuccess = {
+ uploadCourseId = it
+ _drawState.value = UiState.Success
+ },
+ onFailure = {
+ _drawState.value = UiState.Failure
+ Timber.e(it.toLog())
+ refundMarkerQuota(consumedMarkerCount)
+ }
+ )
+ }
+ }
+
+ /**
+ * 업로드 실패로 저장되지 않은 코스에 대해, 저장 시점에 미리 차감했던 마커를 되돌려준다.
+ * 전용 환불 API가 없어 광고 리워드 지급 API를 재사용한다 — 지급 성공/실패 판단이
+ * rewardTransactionId 유니크 제약 기반 멱등 처리로 이미 구현돼 있어, 이 요청이
+ * 네트워크 재시도로 중복 전송되어도 마커가 두 번 환불되지 않는다.
+ */
+ private fun refundMarkerQuota(amount: Int) {
+ launchWithHandler {
+ markerQuotaRepository.grantMarkerReward(
+ amount = amount,
+ rewardTransactionId = "upload-fail-refund-${UUID.randomUUID()}"
+ ).collectResult(
+ onSuccess = { _markerQuotaBalance.value = it.balance },
+ onFailure = { Timber.e(it.toLog()) }
+ )
+ }
+ }
+
+ fun grantMarkerRewardFromAd(rewardTransactionId: String) {
+ _markerRewardGrantState.value = UiState.Loading
+ launchWithHandler {
+ markerQuotaRepository.grantMarkerReward(
+ amount = MARKER_REWARD_AMOUNT,
+ rewardTransactionId = rewardTransactionId
+ ).collectResult(
+ onSuccess = {
+ _markerQuotaBalance.value = it.balance
+ _markerRewardGrantState.value = UiState.Success
+ },
+ onFailure = {
+ _markerRewardGrantState.value = UiState.Failure
+ Timber.e(it.toLog())
+ }
+ )
+ }
+ }
+
+ companion object {
+ const val MARKER_REWARD_AMOUNT = 5
+ }
+
fun distance(lat1: Double, lon1: Double, lat2: Double, lon2: Double, unit: String): Double {
@@ -83,32 +211,6 @@ class DrawViewModel @Inject constructor(
return (rad * 180 / Math.PI)
}
- fun uploadCourse() {
- _drawState.value = UiState.Loading
- launchWithHandler {
- courseRepository.uploadCourse(
- image = _image.value!!.toFormData(),
- data = CourseCreateRequestDto(
- path = path,
- title = courseTitle,
- distance = distanceSum.value!!,
- departureAddress = departureAddress,
- departureName = departureName
- ).toRequestBody()
- ).collectResult(
- onSuccess = {
- uploadCourseId = it
- _drawState.value = UiState.Success
- },
- onFailure = {
- _drawState.value = UiState.Failure
- Timber.e(it.toLog())
- }
- )
- }
-
- }
-
fun getLocationInfoUsingLatLng(lat: Double, lon: Double) = launchWithHandler {
reverseGeocodingRepository.getLocationInfoUsingLatLng(
lat = lat, lon = lon
diff --git a/app/src/main/java/com/runnect/runnect/util/ad/RewardedAdManager.kt b/app/src/main/java/com/runnect/runnect/util/ad/RewardedAdManager.kt
new file mode 100644
index 00000000..019e5596
--- /dev/null
+++ b/app/src/main/java/com/runnect/runnect/util/ad/RewardedAdManager.kt
@@ -0,0 +1,80 @@
+package com.runnect.runnect.util.ad
+
+import android.app.Activity
+import android.content.Context
+import com.google.android.gms.ads.AdError
+import com.google.android.gms.ads.AdRequest
+import com.google.android.gms.ads.FullScreenContentCallback
+import com.google.android.gms.ads.LoadAdError
+import com.google.android.gms.ads.rewarded.RewardedAd
+import com.google.android.gms.ads.rewarded.RewardedAdLoadCallback
+import com.runnect.runnect.BuildConfig
+import timber.log.Timber
+import java.util.UUID
+import javax.inject.Inject
+
+/**
+ * 마커 충전용 리워드 광고 로드/노출을 감싼다. 광고 시청 완료(onUserEarnedReward)는
+ * AdMob이 정확히 한 번만 호출해주지만, 그 뒤 서버로 지급 요청을 보내는 과정에서
+ * 네트워크 재시도가 붙을 수 있으므로 이 시청 1건을 식별하는 rewardTransactionId를
+ * 여기서 발급해 호출부에 넘긴다 — 서버는 이 값으로 중복 지급을 막는다.
+ */
+class RewardedAdManager @Inject constructor() {
+
+ private var rewardedAd: RewardedAd? = null
+ private var isLoading = false
+
+ fun load(context: Context) {
+ if (isLoading || rewardedAd != null) return
+ isLoading = true
+ RewardedAd.load(
+ context,
+ BuildConfig.ADMOB_REWARDED_AD_UNIT_ID,
+ AdRequest.Builder().build(),
+ object : RewardedAdLoadCallback() {
+ override fun onAdLoaded(ad: RewardedAd) {
+ isLoading = false
+ rewardedAd = ad
+ }
+
+ override fun onAdFailedToLoad(error: LoadAdError) {
+ isLoading = false
+ rewardedAd = null
+ Timber.e("리워드 광고 로드 실패: ${error.message}")
+ }
+ }
+ )
+ }
+
+ fun isReady(): Boolean = rewardedAd != null
+
+ fun show(
+ activity: Activity,
+ onUserEarnedReward: (rewardTransactionId: String) -> Unit,
+ onNotReady: () -> Unit = {},
+ ) {
+ val ad = rewardedAd
+ if (ad == null) {
+ onNotReady()
+ load(activity)
+ return
+ }
+
+ ad.fullScreenContentCallback = object : FullScreenContentCallback() {
+ override fun onAdDismissedFullScreenContent() {
+ rewardedAd = null
+ load(activity)
+ }
+
+ override fun onAdFailedToShowFullScreenContent(adError: AdError) {
+ rewardedAd = null
+ Timber.e("리워드 광고 노출 실패: ${adError.message}")
+ load(activity)
+ }
+ }
+
+ ad.show(activity) {
+ onUserEarnedReward(UUID.randomUUID().toString())
+ }
+ }
+}
diff --git a/app/src/main/res/drawable/circle_m3_bg.xml b/app/src/main/res/drawable/circle_m3_bg.xml
new file mode 100644
index 00000000..9131ca97
--- /dev/null
+++ b/app/src/main/res/drawable/circle_m3_bg.xml
@@ -0,0 +1,5 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_marker_quota.xml b/app/src/main/res/drawable/ic_marker_quota.xml
new file mode 100644
index 00000000..3983ed48
--- /dev/null
+++ b/app/src/main/res/drawable/ic_marker_quota.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_marker_quota_charge.xml b/app/src/main/res/drawable/ic_marker_quota_charge.xml
new file mode 100644
index 00000000..01827810
--- /dev/null
+++ b/app/src/main/res/drawable/ic_marker_quota_charge.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_play_filled.xml b/app/src/main/res/drawable/ic_play_filled.xml
new file mode 100644
index 00000000..9ce7a25f
--- /dev/null
+++ b/app/src/main/res/drawable/ic_play_filled.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/layout/activity_draw.xml b/app/src/main/res/layout/activity_draw.xml
index c6903bc2..8e8fc1e2 100644
--- a/app/src/main/res/layout/activity_draw.xml
+++ b/app/src/main/res/layout/activity_draw.xml
@@ -190,6 +190,59 @@
android:layout_width="match_parent"
android:layout_height="match_parent" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 3a0f2fa4..70f5533f 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -65,6 +65,7 @@ kakao-sdk = "2.23.3"
# Google
play-services-auth = "20.4.1"
+play-services-ads = "23.6.0"
# Logging
timber = "5.0.1"
@@ -169,6 +170,7 @@ kakao-navi = { group = "com.kakao.sdk", name = "v2-navi", version.ref = "kakao-s
# Google
play-services-auth = { group = "com.google.android.gms", name = "play-services-auth", version.ref = "play-services-auth" }
+play-services-ads = { group = "com.google.android.gms", name = "play-services-ads", version.ref = "play-services-ads" }
# Logging
timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" }