diff --git a/docs/superpowers/plans/2026-05-31-rewarded-ad-item-promotion.md b/docs/superpowers/plans/2026-05-31-rewarded-ad-item-promotion.md new file mode 100644 index 00000000..a4ba10bb --- /dev/null +++ b/docs/superpowers/plans/2026-05-31-rewarded-ad-item-promotion.md @@ -0,0 +1,643 @@ +# 보상형 광고 기반 '내 물건 우선 노출' Implementation Plan (이슈 #819) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 내 물건 관리 탭에서 보상형 광고를 끝까지 시청하면 해당 물건을 백엔드 우선노출 대상으로 활성화한다. + +**Architecture:** AdMob 보상형 광고 생명주기는 `RewardedAdService`가 캡슐화(결과 boolean), 백엔드 활성화는 `PromotionRepository`가 래핑, 둘의 오케스트레이션과 우선노출 상태는 동기 `Notifier`(`promotionProvider`)가 단일 소유한다. 화면은 provider를 구독해 버튼/뱃지를 전환하고, notifier가 반환하는 `PromoteResult` enum으로 토스트를 분기한다. + +**Tech Stack:** Flutter, Riverpod(Notifier), google_mobile_ads(RewardedAd), 기존 `ApiClient`/`AppColors`/`CustomTextStyles`/`CommonSnackBar`. + +> **백엔드 미확정:** 우선노출 API 엔드포인트·요청 바디는 가정값(`POST /api/item/promote`)이다. BE 확정 시 `PromotionApi`/`PromotionRepository` 내부만 교체하면 상위 레이어 무영향. 가정 지점은 코드에 `// TODO(#819 BE)` 주석으로 표시한다. + +> **커밋 규칙(CLAUDE.md):** 각 Task의 커밋 step은 **사용자 명시 허락 시에만** 실행한다. 무단 `git add`/`git commit` 금지. 서브에이전트에게도 커밋 금지를 명시 전달한다. + +--- + +## File Structure + +**신규** +- `lib/enums/promote_result.dart` — 우선노출 시도 결과 enum +- `lib/services/rewarded_ad_service.dart` — 보상형 광고 로드/표시/보상 콜백 +- `lib/states/promotion_state.dart` — 우선노출 활성 itemId 집합(immutable) +- `lib/services/apis/promotion_api.dart` — 백엔드 우선노출 HTTP 호출 +- `lib/repositories/promotion_repository.dart` — PromotionApi 래핑 +- `lib/providers/promotion_repository_provider.dart` — repository/service 주입 provider +- `lib/providers/promotion_provider.dart` — 오케스트레이터 Notifier + provider +- `test/providers/promotion_provider_test.dart` — notifier 단위 테스트 + +**수정** +- `lib/screens/my_page/my_register_item_screen.dart` — item tile에 버튼/뱃지, provider 구독, 토스트 분기 + +--- + +### Task 1: PromoteResult enum + +**Files:** +- Create: `lib/enums/promote_result.dart` + +- [ ] **Step 1: enum 작성** + +```dart +/// 우선노출(롬업) 시도 결과. +/// 화면은 이 값으로 토스트를 분기한다. notifier가 UI에 의존하지 않게 하기 위함. +enum PromoteResult { + success, // 광고 보상 + 백엔드 활성화 성공 + adNotEarned, // 광고 미시청/중도이탈/로드실패 — 보상 미적립 + failed, // 보상은 받았으나 백엔드 활성화 실패 + alreadyInFlight, // 동일 itemId 처리 중 — 중복 무시 +} +``` + +- [ ] **Step 2: 분석 통과 확인** + +Run: `source ~/.zshrc && flutter analyze lib/enums/promote_result.dart` +Expected: No issues found. + +- [ ] **Step 3: 커밋 (사용자 허락 시에만)** + +```bash +git add lib/enums/promote_result.dart +git commit -m "feat: 우선노출 결과 enum PromoteResult 추가" +``` + +--- + +### Task 2: PromotionState + +**Files:** +- Create: `lib/states/promotion_state.dart` + +- [ ] **Step 1: 상태 모델 작성** + +`my_items_state.dart`의 `@immutable` + `copyWith`/`==`/`hashCode` 패턴을 따른다. + +```dart +import 'package:flutter/foundation.dart'; + +/// 우선노출(롬업) 활성화된 itemId 집합을 단일 소유하는 상태. +@immutable +class PromotionState { + final Set promotedItemIds; + + const PromotionState({this.promotedItemIds = const {}}); + + bool isPromoted(String itemId) => promotedItemIds.contains(itemId); + + PromotionState copyWith({Set? promotedItemIds}) => + PromotionState(promotedItemIds: promotedItemIds ?? this.promotedItemIds); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is PromotionState && + runtimeType == other.runtimeType && + setEquals(promotedItemIds, other.promotedItemIds); + + @override + int get hashCode => Object.hashAll(promotedItemIds); + + @override + String toString() => 'PromotionState(promoted: ${promotedItemIds.length})'; +} +``` + +- [ ] **Step 2: 분석 통과 확인** + +Run: `source ~/.zshrc && flutter analyze lib/states/promotion_state.dart` +Expected: No issues found. + +- [ ] **Step 3: 커밋 (사용자 허락 시에만)** + +```bash +git add lib/states/promotion_state.dart +git commit -m "feat: 우선노출 상태 PromotionState 추가" +``` + +--- + +### Task 3: RewardedAdService + +**Files:** +- Create: `lib/services/rewarded_ad_service.dart` + +- [ ] **Step 1: 서비스 작성** + +`AdMobService.rewardedAdUnitId`를 unit ID로 사용한다. 결과는 boolean 하나로 수렴(보상 받음/아님). 로드 실패·표시 실패·중도 이탈 전부 false. + +```dart +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:google_mobile_ads/google_mobile_ads.dart'; +import 'package:romrom_fe/services/ad_mob_service.dart'; + +/// AdMob 보상형 광고의 로드·표시·보상 콜백을 캡슐화한다. +/// 비즈니스 로직(우선노출)을 전혀 모른다 — 결과는 "보상 받았나" boolean 하나. +class RewardedAdService { + RewardedAd? _ad; + bool _isLoading = false; + + /// 광고 미리 로드. 이미 로드됐거나 로딩 중이면 무시. + Future load() async { + if (_ad != null || _isLoading) return; + final unitId = AdMobService.rewardedAdUnitId; + if (unitId == null) return; // 실제 unit ID는 .env 기반(테스트 빌드만 테스트 ID 반환) + _isLoading = true; + final completer = Completer(); + RewardedAd.load( + adUnitId: unitId, + request: const AdRequest(), + rewardedAdLoadCallback: RewardedAdLoadCallback( + onAdLoaded: (ad) { + _ad = ad; + _isLoading = false; + if (!completer.isCompleted) completer.complete(); + }, + onAdFailedToLoad: (error) { + debugPrint('[RewardedAd] 로드 실패: $error'); + _ad = null; + _isLoading = false; + if (!completer.isCompleted) completer.complete(); + }, + ), + ); + return completer.future; + } + + /// 광고를 표시하고 보상 여부를 반환한다. + /// true = 보상 적립 / false = 미적립(이탈·실패·미로드). + Future showAndAwaitReward() async { + if (_ad == null) await load(); + final ad = _ad; + if (ad == null) return false; // 로드 실패 + + final completer = Completer(); + var earned = false; + + ad.fullScreenContentCallback = FullScreenContentCallback( + // 보상 콜백(onUserEarnedReward)은 닫히기 전에 도착 → 닫힘 시점에 결과 확정 + onAdDismissedFullScreenContent: (ad) { + ad.dispose(); + _ad = null; + if (!completer.isCompleted) completer.complete(earned); + }, + onAdFailedToShowFullScreenContent: (ad, error) { + debugPrint('[RewardedAd] 표시 실패: $error'); + ad.dispose(); + _ad = null; + if (!completer.isCompleted) completer.complete(false); + }, + ); + + ad.show(onUserEarnedReward: (ad, reward) => earned = true); + return completer.future; + } +} +``` + +- [ ] **Step 2: 분석 통과 확인** + +Run: `source ~/.zshrc && flutter analyze lib/services/rewarded_ad_service.dart` +Expected: No issues found. + +> 단위 테스트는 작성하지 않는다 — `RewardedAd.load`/`show`는 정적 SDK 호출이라 mock 불가. notifier 테스트(Task 7)에서 서비스를 통째로 Fake로 주입해 검증한다. + +- [ ] **Step 3: 커밋 (사용자 허락 시에만)** + +```bash +git add lib/services/rewarded_ad_service.dart +git commit -m "feat: 보상형 광고 생명주기 RewardedAdService 추가" +``` + +--- + +### Task 4: PromotionApi + PromotionRepository + +**Files:** +- Create: `lib/services/apis/promotion_api.dart` +- Create: `lib/repositories/promotion_repository.dart` + +- [ ] **Step 1: PromotionApi 작성** + +`ItemApi` 싱글톤 패턴 + `ApiClient.sendHttpRequest` JSON POST 패턴을 따른다. 엔드포인트는 BE 미확정이라 가정값 + TODO 표시. + +```dart +import 'package:romrom_fe/models/app_urls.dart'; +import 'package:romrom_fe/services/api_client.dart'; + +class PromotionApi { + static final PromotionApi _instance = PromotionApi._internal(); + factory PromotionApi() => _instance; + PromotionApi._internal(); + + /// 우선노출 활성화. 광고 보상 시청 완료 후 호출. + /// TODO(#819 BE): 엔드포인트/요청 바디/보상 검증 토큰은 백엔드 확정 시 교체. + Future activatePromotion(String itemId) async { + final url = '${AppUrls.baseUrl}/api/item/promote'; + await ApiClient.sendHttpRequest( + url: url, + method: 'POST', + body: {'itemId': itemId}, + onSuccess: (_) {}, + ); + } +} +``` + +- [ ] **Step 2: PromotionRepository 작성** + +`ItemRepository` 패턴(생성자 주입, UI 모름)을 따른다. + +```dart +import 'package:romrom_fe/services/apis/promotion_api.dart'; + +/// 우선노출(롬업) 백엔드 API 래핑. UI를 모른다. +class PromotionRepository { + final PromotionApi _api; + + PromotionRepository(this._api); + + /// 우선노출 활성화 요청. + Future activate(String itemId) => _api.activatePromotion(itemId); +} +``` + +- [ ] **Step 3: 분석 통과 확인** + +Run: `source ~/.zshrc && flutter analyze lib/services/apis/promotion_api.dart lib/repositories/promotion_repository.dart` +Expected: No issues found. + +- [ ] **Step 4: 커밋 (사용자 허락 시에만)** + +```bash +git add lib/services/apis/promotion_api.dart lib/repositories/promotion_repository.dart +git commit -m "feat: 우선노출 PromotionApi/PromotionRepository 추가" +``` + +--- + +### Task 5: 주입 Provider + +**Files:** +- Create: `lib/providers/promotion_repository_provider.dart` + +- [ ] **Step 1: provider 작성** + +`item_repository_provider.dart` 패턴(plain `Provider`, 테스트 override 가능)을 따른다. repository와 광고 서비스 둘 다 여기서 주입한다. + +```dart +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:romrom_fe/repositories/promotion_repository.dart'; +import 'package:romrom_fe/services/apis/promotion_api.dart'; +import 'package:romrom_fe/services/rewarded_ad_service.dart'; + +/// 우선노출 repository 주입용 공유 Provider. +final promotionRepositoryProvider = + Provider((ref) => PromotionRepository(PromotionApi())); + +/// 보상형 광고 서비스 주입용 공유 Provider. +final rewardedAdServiceProvider = + Provider((ref) => RewardedAdService()); +``` + +- [ ] **Step 2: 분석 통과 확인** + +Run: `source ~/.zshrc && flutter analyze lib/providers/promotion_repository_provider.dart` +Expected: No issues found. + +- [ ] **Step 3: 커밋 (사용자 허락 시에만)** + +```bash +git add lib/providers/promotion_repository_provider.dart +git commit -m "feat: 우선노출 repository/광고서비스 주입 provider 추가" +``` + +--- + +### Task 6: PromotionNotifier + promotionProvider + +**Files:** +- Create: `lib/providers/promotion_provider.dart` + +- [ ] **Step 1: notifier 작성** + +동기 `Notifier` + `_inFlight` dedup. 광고→API→상태 오케스트레이션. UI 토스트 의존 없이 `PromoteResult` 반환. + +```dart +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:romrom_fe/enums/promote_result.dart'; +import 'package:romrom_fe/providers/promotion_repository_provider.dart'; +import 'package:romrom_fe/states/promotion_state.dart'; + +final promotionProvider = + NotifierProvider(PromotionNotifier.new); + +/// 우선노출(롬업) 상태를 단일 소유하는 오케스트레이터. +/// 광고 보상을 받아야만 백엔드 활성화를 호출한다. +class PromotionNotifier extends Notifier { + final Set _inFlight = {}; // 중복 요청 방지 + + @override + PromotionState build() => const PromotionState(); + + Future promoteItem(String itemId) async { + if (_inFlight.contains(itemId)) return PromoteResult.alreadyInFlight; + _inFlight.add(itemId); + try { + // 1. 광고 — 보상 받아야만 진행 + final earned = await ref.read(rewardedAdServiceProvider).showAndAwaitReward(); + if (!earned) return PromoteResult.adNotEarned; + + // 2. 백엔드 활성화 + await ref.read(promotionRepositoryProvider).activate(itemId); + + // 3. optimistic 반영 (아직 추가 안 했으므로 롤백 불필요) + state = state.copyWith(promotedItemIds: {...state.promotedItemIds, itemId}); + return PromoteResult.success; + } catch (_) { + return PromoteResult.failed; + } finally { + _inFlight.remove(itemId); + } + } +} +``` + +- [ ] **Step 2: 분석 통과 확인** + +Run: `source ~/.zshrc && flutter analyze lib/providers/promotion_provider.dart` +Expected: No issues found. + +- [ ] **Step 3: 커밋 (사용자 허락 시에만)** + +```bash +git add lib/providers/promotion_provider.dart +git commit -m "feat: 우선노출 오케스트레이터 PromotionNotifier 추가" +``` + +--- + +### Task 7: notifier 단위 테스트 + +**Files:** +- Test: `test/providers/promotion_provider_test.dart` + +- [ ] **Step 1: 실패하는 테스트 작성** + +`item_like_provider_test.dart`의 Fake 주입 + `ProviderContainer.overrides` 패턴을 따른다. 광고 서비스와 repository 둘 다 Fake로 주입. + +```dart +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:romrom_fe/enums/promote_result.dart'; +import 'package:romrom_fe/providers/promotion_provider.dart'; +import 'package:romrom_fe/providers/promotion_repository_provider.dart'; +import 'package:romrom_fe/repositories/promotion_repository.dart'; +import 'package:romrom_fe/services/apis/promotion_api.dart'; +import 'package:romrom_fe/services/rewarded_ad_service.dart'; + +class FakeRewardedAdService extends RewardedAdService { + bool reward = true; + int showCount = 0; + @override + Future load() async {} + @override + Future showAndAwaitReward() async { + showCount++; + return reward; + } +} + +class FakePromotionApi extends PromotionApi { + bool shouldThrow = false; + int activateCount = 0; + @override + Future activatePromotion(String itemId) async { + activateCount++; + if (shouldThrow) throw Exception('boom'); + } +} + +void main() { + group('promotionProvider', () { + late FakeRewardedAdService ad; + late FakePromotionApi api; + late ProviderContainer container; + + setUp(() { + ad = FakeRewardedAdService(); + api = FakePromotionApi(); + container = ProviderContainer(overrides: [ + rewardedAdServiceProvider.overrideWithValue(ad), + promotionRepositoryProvider.overrideWithValue(PromotionRepository(api)), + ]); + }); + + tearDown(() => container.dispose()); + + test('보상 O + BE 성공 → success, state에 itemId 포함', () async { + ad.reward = true; + final result = await container.read(promotionProvider.notifier).promoteItem('A'); + expect(result, PromoteResult.success); + expect(container.read(promotionProvider).isPromoted('A'), isTrue); + expect(api.activateCount, 1); + }); + + test('보상 X → adNotEarned, state 변화 없음, BE 미호출', () async { + ad.reward = false; + final result = await container.read(promotionProvider.notifier).promoteItem('A'); + expect(result, PromoteResult.adNotEarned); + expect(container.read(promotionProvider).isPromoted('A'), isFalse); + expect(api.activateCount, 0); + }); + + test('보상 O + BE 실패 → failed, state 변화 없음', () async { + ad.reward = true; + api.shouldThrow = true; + final result = await container.read(promotionProvider.notifier).promoteItem('A'); + expect(result, PromoteResult.failed); + expect(container.read(promotionProvider).isPromoted('A'), isFalse); + }); + + test('진행 중 동일 itemId 재호출 → alreadyInFlight (광고 1회만)', () async { + ad.reward = true; + final f1 = container.read(promotionProvider.notifier).promoteItem('A'); + final f2 = container.read(promotionProvider.notifier).promoteItem('A'); + final results = await Future.wait([f1, f2]); + expect(results, containsAll([PromoteResult.success, PromoteResult.alreadyInFlight])); + expect(ad.showCount, 1); + }); + }); +} +``` + +- [ ] **Step 2: 테스트 실행 (구현 존재 시 통과 확인)** + +Run: `source ~/.zshrc && flutter test test/providers/promotion_provider_test.dart` +Expected: All tests passed. (Task 1~6 구현이 있으므로 통과해야 함. 실패 시 notifier/state 수정) + +- [ ] **Step 3: 커밋 (사용자 허락 시에만)** + +```bash +git add test/providers/promotion_provider_test.dart +git commit -m "test: PromotionNotifier 단위 테스트 추가" +``` + +--- + +### Task 8: UI — my_register_item_screen 버튼/뱃지/토스트 + +**Files:** +- Modify: `lib/screens/my_page/my_register_item_screen.dart` + +`_buildItemTile`은 현재 `Item item, int index`를 받고 `Stack > AppPressable > Row` 구조다(`my_register_item_screen.dart:154`). item tile 하단에 우선노출 버튼/뱃지를 추가하고, 등록 물건(available)에만 노출한다. + +- [ ] **Step 1: import 추가** + +파일 상단 import 블록에 추가: + +```dart +import 'package:romrom_fe/enums/promote_result.dart'; +import 'package:romrom_fe/enums/snack_bar_type.dart'; +import 'package:romrom_fe/providers/promotion_provider.dart'; +import 'package:romrom_fe/widgets/common/common_snack_bar.dart'; +``` + +- [ ] **Step 2: 우선노출 핸들러 메서드 추가** + +`_onToggleChanged`(라인 259 부근) 아래에 추가. notifier 호출 후 결과 enum으로 토스트 분기. + +```dart + // 우선노출(롬업) 버튼 핸들러. 광고 시청 → 백엔드 활성화. + Future _onPromoteTap(Item item) async { + final itemId = item.itemId; + if (itemId == null) return; + final result = await ref.read(promotionProvider.notifier).promoteItem(itemId); + if (!mounted) return; + switch (result) { + case PromoteResult.success: + CommonSnackBar.show(context: context, message: '내 물건이 우선 노출돼요 ⚡', type: SnackBarType.success); + case PromoteResult.adNotEarned: + CommonSnackBar.show(context: context, message: '광고를 끝까지 시청해야 적립돼요', type: SnackBarType.info); + case PromoteResult.failed: + CommonSnackBar.show(context: context, message: '잠시 후 다시 시도해주세요', type: SnackBarType.error); + case PromoteResult.alreadyInFlight: + break; // 무시 + } + } +``` + +- [ ] **Step 3: 버튼/뱃지 위젯 빌더 추가** + +`_onPromoteTap` 아래에 추가. iPad 규칙 준수(고정 높이 + vertical padding 동시 사용 금지 → padding만, 고정 px). `AppColors`/`CustomTextStyles` 사용. + +```dart + // 우선노출 버튼(활성 전) / 노출 중 뱃지(활성 후). + Widget _buildPromoteControl(Item item) { + final itemId = item.itemId; + if (itemId == null) return const SizedBox.shrink(); + final isPromoted = ref.watch(promotionProvider).isPromoted(itemId); + + if (isPromoted) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: AppColors.opacity10White, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '⚡ 노출 중', + style: CustomTextStyles.p3.copyWith(color: AppColors.opacity60White, fontWeight: FontWeight.w600), + ), + ); + } + + return AppPressable( + onTap: () => _onPromoteTap(item), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: AppColors.primaryYellow, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '⚡ 우선 노출', + style: CustomTextStyles.p3.copyWith(color: AppColors.primaryBlack, fontWeight: FontWeight.w600), + ), + ), + ); + } +``` + +- [ ] **Step 4: tile에 버튼 배치** + +`_buildItemTile`의 `Expanded > Column` 마지막 children(라인 239~248 tradeOptions Row) 다음에 우선노출 컨트롤을 추가한다. 단, **등록 물건만** 노출(교환완료 제외). + +`tradeOptions` Row 다음(`Column` children 끝)에 삽입: + +```dart + // 우선노출 컨트롤 — 교환완료 물건엔 표시 안 함 + if (item.itemStatus != ItemStatus.exchanged.serverName) ...[ + SizedBox(height: 8.h), + Align( + alignment: Alignment.centerRight, + child: _buildPromoteControl(item), + ), + ], +``` + +> `ItemStatus`는 이미 import됨(라인 4). `AppPressable`도 import됨(라인 17). + +- [ ] **Step 5: 분석 + 포맷** + +Run: `source ~/.zshrc && dart format --line-length=120 lib/screens/my_page/my_register_item_screen.dart && flutter analyze lib/screens/my_page/my_register_item_screen.dart` +Expected: No issues found. + +- [ ] **Step 6: 커밋 (사용자 허락 시에만)** + +```bash +git add lib/screens/my_page/my_register_item_screen.dart +git commit -m "feat: 내 물건 관리 탭에 우선 노출 버튼/뱃지 추가" +``` + +--- + +### Task 9: 전체 검증 + +- [ ] **Step 1: 포맷 전체 적용** + +Run: `source ~/.zshrc && dart format --line-length=120 .` +Expected: 포맷 변경 파일 목록 출력 (에러 없음) + +- [ ] **Step 2: 린트 전체 분석** + +Run: `source ~/.zshrc && flutter analyze` +Expected: No issues found. (에러 발생 시 수정 후 재실행 — CLAUDE.md 자동 처리 규칙) + +- [ ] **Step 3: 테스트 전체 실행** + +Run: `source ~/.zshrc && flutter test test/providers/promotion_provider_test.dart` +Expected: All tests passed. + +--- + +## Self-Review 결과 + +**스펙 커버리지:** +- §2 신규 6파일 → enum(Task1)·state(Task2)·service(Task3)·api+repo(Task4)·주입provider(Task5)·notifier(Task6)·test(Task7) 전부 매핑. 단, spec은 6파일이나 plan은 PromoteResult enum과 PromotionApi를 별도 파일로 분리(8파일) — 코드 스타일(enum은 `lib/enums/` 개별 파일, API는 `services/apis/`)에 맞춰 분리. spec §4 의도와 일치. +- §3 데이터 흐름(보상→활성화 순서, `_inFlight`) → Task6 notifier에 구현. +- §4 RewardedAdService boolean 수렴 → Task3. +- §5 UI 버튼/뱃지/토스트 분기 + 등록물건만 노출 → Task8. +- §6 에러 매트릭스 → PromoteResult 분기(Task1·6·8). +- §7 테스트 4케이스 → Task7에 1:1 매핑. +- §9 fetchPromotedIds/만료표시/상태복원 → 1차 범위 제외(YAGNI), plan에서도 미포함 (spec과 일치). + +**Placeholder 스캔:** 모든 code step에 실제 코드 포함. "TBD/TODO 적절히" 없음. BE 미확정 가정은 `// TODO(#819 BE)`로 명시 + 실제 동작하는 가정값 제공. + +**타입 일관성:** `PromoteResult`(success/adNotEarned/failed/alreadyInFlight), `promotionProvider`, `rewardedAdServiceProvider`/`promotionRepositoryProvider`, `showAndAwaitReward()`, `activate()`/`activatePromotion()`, `isPromoted()`/`promotedItemIds` — Task 간 명칭 일치 확인 완료. + +**알려진 가정:** +- `CommonSnackBar.show(context:, message:, type:)` 시그니처는 `home_feed_item_widget.dart` 실사용 기준. +- `SnackBarType.success/info/error`는 `lib/enums/snack_bar_type.dart` 기준. +- 백엔드 엔드포인트는 가정 — 실제 연동 전 BE 확정 필요(§9). diff --git a/docs/superpowers/specs/2026-05-31-rewarded-ad-item-promotion-design.md b/docs/superpowers/specs/2026-05-31-rewarded-ad-item-promotion-design.md new file mode 100644 index 00000000..39f11b0e --- /dev/null +++ b/docs/superpowers/specs/2026-05-31-rewarded-ad-item-promotion-design.md @@ -0,0 +1,252 @@ +# 보상형 광고 기반 '내 물건 우선 노출' 설계 (이슈 #819) + +## 1. 배경 & 목표 + +당근마켓의 '끌올'을 롬롬의 쇼츠형 스와이프 피드에 맞게 변형한 기능. 가칭 **롬업**. + +유저가 **AdMob 보상형 영상 광고**를 끝까지 시청하면, 그 보상으로 **내 물건이 타겟 유저들의 스와이프 피드에 우선 교차 노출**된다. + +- **트리거**: 내 물건 관리 탭(`나의 등록된 물건` 화면)에서 물건별 `[우선 노출]` 버튼 클릭 +- **보상**: 광고 시청 완료 → 백엔드에 우선노출 활성화 요청 → 해당 물건이 추천 피드에 우선 노출 + +### 범위 경계 (중요) + +이슈 #819의 핵심 로직 중 **백엔드 담당 영역**과 **프론트엔드 담당 영역**을 명확히 분리한다. 본 spec은 **프론트엔드 전체 플로우**만 다룬다. + +**백엔드 담당 (본 spec 범위 밖)**: +- 타겟 유저 매칭 (관심 카테고리 + 거리 범위 필터) +- 피드 노출 비율 믹싱 알고리즘 (우선노출 카드 vs 기존 추천 카드, 예: 50:50 / 30:70) +- 우선노출 대기열 관리 (최대 100개 순번 등) +- 광고 보상 검증 (서버사이드 verification) + +**프론트엔드 담당 (본 spec)**: +- `[우선 노출]` 버튼 UI + 활성 상태 뱃지 +- AdMob 보상형 광고 로드/표시/보상 콜백 처리 +- 광고 보상 성공 시 백엔드 활성화 API 호출 +- 우선노출 상태를 Riverpod provider로 중앙 관리, 화면 구독 + +> 백엔드 API 스펙(엔드포인트·요청 바디·보상 검증 토큰)은 미확정이다(이슈 #819 백엔드 담당 미정). FE는 repository 인터페이스만 고정하고, BE 확정 시 repository 내부 구현만 교체한다. + +## 2. 아키텍처 + +``` +[my_register_item_screen] ← 화면(ConsumerStatefulWidget) + │ [우선 노출] 버튼 탭 + ▼ +[promotionProvider.notifier.promoteItem(itemId)] ← 행위(오케스트레이터) + │ + ├─ 1. RewardedAdService.showAndAwaitReward() ──► AdMob SDK + │ 보상 받음(true) / 미적립(false) + │ + ├─ 2. PromotionRepository.activate(itemId) ──► 백엔드 API + │ + └─ 3. PromotionState 갱신 (해당 itemId 우선노출 활성) + │ + ▼ 결과 enum 반환 +[화면이 결과로 토스트 분기] +``` + +### 신규 파일 (6개) + +| 파일 | 책임 | 종류 | +|---|---|---| +| `lib/services/rewarded_ad_service.dart` | 보상형 광고 로드·표시·보상 콜백 캡슐화. UI/비즈니스 로직 모름 | Service | +| `lib/repositories/promotion_repository.dart` | 백엔드 우선노출 API 래핑. UI 모름 | Repository | +| `lib/states/promotion_state.dart` | `@immutable` 상태 모델 (우선노출 활성 itemId 집합) | State | +| `lib/providers/promotion_provider.dart` | 동기 `Notifier` + `_inFlight` dedup. 광고→API→상태 오케스트레이션 | Provider | +| `lib/providers/promotion_repository_provider.dart` | repository plain Provider 주입 (테스트 override용) | Provider | +| `test/promotion_notifier_test.dart` | notifier 단위 테스트 | Test | + +### 수정 파일 (1개) + +- `lib/screens/my_page/my_register_item_screen.dart` — item tile에 `[우선 노출]` 버튼 + 활성 뱃지 추가, `promotionProvider` 구독 + +### 손대지 않는 파일 + +- `lib/services/ad_mob_service.dart` — 보상형 광고 unit ID(`rewardedAdUnitId`)는 이미 존재. 그대로 유지. 광고 생명주기 로직은 신규 `RewardedAdService`가 담당 + +### 책임 경계 + +- `RewardedAdService` = AdMob SDK 생명주기만. 결과는 **boolean 하나**(보상 받았나). 우선노출이 뭔지 모름 +- `PromotionRepository` = 백엔드 API만. 광고 모름 +- `PromotionNotifier` = 둘을 엮는 오케스트레이터. **"광고 보상 받아야만 API 호출"** 순서 보장. UI 토스트 의존 안 함 (결과 enum 반환) + +본 구조는 CLAUDE.md 규칙 1(repository→state→provider 4-레이어), 규칙 2(optimistic 토글 → 동기 Notifier + `_inFlight`), 규칙 3(mutation은 notifier 메서드로만)을 따른다. + +## 3. 데이터 흐름 + +``` +유저: [우선 노출] 버튼 탭 + │ + ▼ +promotionProvider.notifier.promoteItem(itemId) → Future + │ + ├─ _inFlight.contains(itemId)? → return alreadyInFlight (중복 방지) + ├─ _inFlight.add(itemId) + │ + ├─ 1. final earned = await rewardedAdService.showAndAwaitReward() + │ earned == false → return adNotEarned (BE 호출 안 함) + │ + ├─ 2. await promotionRepository.activate(itemId) + │ 성공 → state에 itemId 추가 (optimistic 활성화) → return success + │ 실패 → return failed (state 미반영) + │ + └─ finally: _inFlight.remove(itemId) +``` + +### 보상 확정 시점 (AdMob 표준) + +`onUserEarnedReward` 콜백에서 보상 플래그를 set하고, 광고가 닫힐 때(`onAdDismissedFullScreenContent`) Completer를 완료해 최종 결과를 확정한다. 보상 콜백은 광고가 닫히기 전에 도착하므로 이 순서가 안전하다. + +## 4. 컴포넌트 상세 + +### RewardedAdService + +```dart +class RewardedAdService { + RewardedAd? _ad; + + Future load(); // 광고 미리 로드 (prefetch) + + /// 광고 표시 후 보상 여부 반환. + /// true = 보상 적립 / false = 미적립(이탈·실패·미로드) + Future showAndAwaitReward(); +} +``` + +- 단일 진입점 `showAndAwaitReward()`. 결과는 boolean. +- 로드 실패·중도 이탈·표시 실패 전부 `false`로 수렴 → 호출측 분기 단순화. +- `AdMobService.rewardedAdUnitId` 사용 (테스트 빌드면 테스트 ID, 아니면 .env 실제 ID). + +### PromotionState + +```dart +@immutable +class PromotionState { + final Set promotedItemIds; // 우선노출 활성 itemId 집합 + const PromotionState({this.promotedItemIds = const {}}); + bool isPromoted(String itemId) => promotedItemIds.contains(itemId); + PromotionState copyWith({Set? promotedItemIds}); + // == / hashCode (Set 비교) +} +``` + +### PromotionRepository + +```dart +class PromotionRepository { + /// 우선노출 활성화 요청. (보상 검증 토큰 등은 BE 스펙 확정 시 파라미터 추가) + Future activate(String itemId); + + /// (선택) 현재 우선노출 중인 내 물건 ID 목록 조회 — 앱 재시작 후 상태 복원용 + Future> fetchPromotedIds(); +} +``` + +`fetchPromotedIds()`는 앱 재시작 시 우선노출 상태 복원을 위한 것. BE에 조회 API가 없으면 1차 구현에서 생략 가능(YAGNI). 본 spec에서는 인터페이스만 정의하고 1차 구현은 `activate`만 연결한다. + +### PromotionNotifier + 결과 enum + +```dart +enum PromoteResult { success, adNotEarned, failed, alreadyInFlight } + +class PromotionNotifier extends Notifier { + final Set _inFlight = {}; + + @override + PromotionState build() => const PromotionState(); + + Future promoteItem(String itemId) async { + if (_inFlight.contains(itemId)) return PromoteResult.alreadyInFlight; + _inFlight.add(itemId); + try { + final earned = await ref.read(rewardedAdServiceProvider).showAndAwaitReward(); + if (!earned) return PromoteResult.adNotEarned; + + await ref.read(promotionRepositoryProvider).activate(itemId); + state = state.copyWith(promotedItemIds: {...state.promotedItemIds, itemId}); + return PromoteResult.success; + } catch (e) { + return PromoteResult.failed; + } finally { + _inFlight.remove(itemId); + } + } +} + +final promotionProvider = + NotifierProvider(PromotionNotifier.new); +``` + +**결과 전달 방식 = enum 반환(채택)**. notifier가 UI 토스트에 의존하지 않아 테스트가 쉽고 레이어 경계가 깨끗하다. 토스트는 화면이 결과 enum을 보고 분기한다. + +## 5. UI 변경 (my_register_item_screen.dart) + +`_buildItemTile`의 item tile에 우선노출 버튼/뱃지를 추가한다. + +``` +┌─────────────────────────────────────┐ +│ [이미지] 물건명 │ +│ 위치 · 3시간 전 │ +│ 12,000원 │ +│ [직거래][택배] │ +│ [⚡우선노출] │ ← 신규 +└─────────────────────────────────────┘ +``` + +- **활성 전**: `[⚡ 우선 노출]` 버튼 (등록 물건 = `available`만 노출, 교환완료 `exchanged` 제외) +- **활성 후**: `[⚡ 노출 중]` 뱃지 (비활성) +- 화면은 `ref.watch(promotionProvider)`로 `isPromoted(item.itemId)` 구독 → 버튼/뱃지 자동 전환 +- 탭 → `ref.read(promotionProvider.notifier).promoteItem(itemId)` 호출, 반환 enum으로 토스트 분기 + +### 토스트 분기 (결과별) + +| PromoteResult | 토스트 | +|---|---| +| `success` | "내 물건이 우선 노출돼요 ⚡" | +| `adNotEarned` | "광고를 끝까지 시청해야 적립돼요" | +| `failed` | "잠시 후 다시 시도해주세요" (보상은 받았으나 서버 적립 실패 — BE 멱등/재시도로 보완) | +| `alreadyInFlight` | (무시, 토스트 없음) | + +토스트는 프로젝트 공통 토스트 패턴 사용(`CommonModal`/공통 토스트 위젯 — 코드 확인 후 기존 것 재사용). + +### 절대 규칙 준수 + +- 색상 `AppColors`, 텍스트 `CustomTextStyles` 사용 +- iPad 대응: 버튼은 고정 높이 + vertical height/padding 동시 사용 금지 (CLAUDE.md iPad 규칙) +- API 중복 요청 방지: `_inFlight` Set 패턴 (notifier 내부) + +## 6. 에러 처리 매트릭스 + +| 상황 | 처리 | +|---|---| +| 광고 로드 실패 | `earned=false` → `adNotEarned` → 토스트, BE 호출 안 함 | +| 광고 중도 이탈 | `earned=false` → `adNotEarned` → 토스트, BE 호출 안 함 | +| 보상 받음 + BE 성공 | state 활성화, `success` 토스트 | +| 보상 받음 + BE 실패 | `failed` 토스트, state 미반영. (보상 손실은 BE 멱등 처리/재시도 영역) | +| 중복 탭 | `_inFlight`로 무시, `alreadyInFlight` | + +## 7. 테스트 (promotion_notifier_test.dart) + +`rewardedAdServiceProvider`·`promotionRepositoryProvider`를 mock으로 override하여: + +1. 보상 O + BE 성공 → `success`, state에 itemId 포함 +2. 보상 X → `adNotEarned`, state 변화 없음, BE 미호출 +3. 보상 O + BE 실패(throw) → `failed`, state 변화 없음 +4. 진행 중 동일 itemId 재호출 → `alreadyInFlight` + +## 8. 작업 분해 (구현 단계 미리보기) + +1. `RewardedAdService` 작성 (+ 광고 표시/보상 콜백) +2. `PromotionState` + `PromotionRepository` + repository provider +3. `PromotionNotifier` + `promotionProvider` + `PromoteResult` enum +4. `promotion_notifier_test.dart` (4케이스) +5. `my_register_item_screen.dart` 버튼/뱃지 + 구독 + 토스트 분기 +6. `dart format` + `flutter analyze` 통과 + +## 9. 미확정 / 후속 논의 필요 + +- **백엔드 API 스펙**: 엔드포인트 경로, 요청 바디, 보상 검증 토큰 필요 여부 → BE 담당자와 확정. FE는 repository 인터페이스만 고정. +- **우선노출 만료/기간 표시**: "노출 중" 뱃지에 남은 시간/순번 표시 여부는 BE가 데이터를 주는지에 따라. 1차에서는 단순 활성/비활성만. +- **상태 복원**: 앱 재시작 후 우선노출 상태 복원(`fetchPromotedIds`)은 BE 조회 API 유무에 따라 후속. +- 이슈가 `보류` 라벨 상태 — 우선순위는 팀 결정. diff --git a/lib/enums/promote_result.dart b/lib/enums/promote_result.dart new file mode 100644 index 00000000..3bc4b45e --- /dev/null +++ b/lib/enums/promote_result.dart @@ -0,0 +1,8 @@ +/// 우선노출(롬업) 시도 결과. +/// 화면은 이 값으로 토스트를 분기한다. notifier가 UI에 의존하지 않게 하기 위함. +enum PromoteResult { + success, // 광고 보상 + 백엔드 활성화 성공 + adNotEarned, // 광고 미시청/중도이탈/로드실패 — 보상 미적립 + failed, // 보상은 받았으나 백엔드 활성화 실패 + alreadyInFlight, // 동일 itemId 처리 중 — 중복 무시 +} diff --git a/lib/providers/promotion_provider.dart b/lib/providers/promotion_provider.dart new file mode 100644 index 00000000..d786976b --- /dev/null +++ b/lib/providers/promotion_provider.dart @@ -0,0 +1,42 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:romrom_fe/enums/promote_result.dart'; +import 'package:romrom_fe/providers/promotion_repository_provider.dart'; +import 'package:romrom_fe/states/promotion_state.dart'; + +final promotionProvider = NotifierProvider(PromotionNotifier.new); + +/// 우선노출(롬업) 상태를 단일 소유하는 오케스트레이터. +/// 광고 보상을 받아야만 백엔드 활성화를 호출한다. +class PromotionNotifier extends Notifier { + final Set _inFlight = {}; // 중복 요청 방지 + + // TODO(#819 BE): 현재는 휘발성 상태라 앱 재시작 시 우선노출 뱃지가 사라진다. + // BE에 "현재 우선노출 중인 내 물건" 조회 API가 확정되면 build()에서 초기 fetch해 + // 복원할 것(필요 시 AsyncNotifier 전환). + @override + PromotionState build() => const PromotionState(); + + Future promoteItem(String itemId) async { + if (_inFlight.contains(itemId)) return PromoteResult.alreadyInFlight; + _inFlight.add(itemId); + try { + // 1. 광고 — 보상 받아야만 진행 + final earned = await ref.read(rewardedAdServiceProvider).showAndAwaitReward(); + if (!earned) return PromoteResult.adNotEarned; + + // 2. 백엔드 활성화 + await ref.read(promotionRepositoryProvider).activate(itemId); + + // 3. optimistic 반영 (아직 추가 안 했으므로 롤백 불필요) + state = state.copyWith(promotedItemIds: {...state.promotedItemIds, itemId}); + return PromoteResult.success; + } catch (e, st) { + // 보상은 받았으나 백엔드 활성화 실패 — 원인 추적용 로그 (스테이징 디버그) + debugPrint('[Promotion] 활성화 실패: $e\n$st'); + return PromoteResult.failed; + } finally { + _inFlight.remove(itemId); + } + } +} diff --git a/lib/providers/promotion_repository_provider.dart b/lib/providers/promotion_repository_provider.dart new file mode 100644 index 00000000..f21effdf --- /dev/null +++ b/lib/providers/promotion_repository_provider.dart @@ -0,0 +1,10 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:romrom_fe/repositories/promotion_repository.dart'; +import 'package:romrom_fe/services/apis/promotion_api.dart'; +import 'package:romrom_fe/services/rewarded_ad_service.dart'; + +/// 우선노출 repository 주입용 공유 Provider. +final promotionRepositoryProvider = Provider((ref) => PromotionRepository(PromotionApi())); + +/// 보상형 광고 서비스 주입용 공유 Provider. +final rewardedAdServiceProvider = Provider((ref) => RewardedAdService()); diff --git a/lib/repositories/promotion_repository.dart b/lib/repositories/promotion_repository.dart new file mode 100644 index 00000000..4fb4aeeb --- /dev/null +++ b/lib/repositories/promotion_repository.dart @@ -0,0 +1,11 @@ +import 'package:romrom_fe/services/apis/promotion_api.dart'; + +/// 우선노출(롬업) 백엔드 API 래핑. UI를 모른다. +class PromotionRepository { + final PromotionApi _api; + + PromotionRepository(this._api); + + /// 우선노출 활성화 요청. + Future activate(String itemId) => _api.activatePromotion(itemId); +} diff --git a/lib/screens/my_page/my_register_item_screen.dart b/lib/screens/my_page/my_register_item_screen.dart index a2683028..971ada1b 100644 --- a/lib/screens/my_page/my_register_item_screen.dart +++ b/lib/screens/my_page/my_register_item_screen.dart @@ -4,6 +4,8 @@ import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:romrom_fe/enums/item_status.dart'; import 'package:romrom_fe/enums/item_trade_option.dart'; import 'package:romrom_fe/enums/my_item_toggle_status.dart'; +import 'package:romrom_fe/enums/promote_result.dart'; +import 'package:romrom_fe/enums/snack_bar_type.dart'; import 'package:romrom_fe/enums/trade_status.dart'; import 'package:romrom_fe/models/apis/objects/item.dart'; import 'package:romrom_fe/models/app_colors.dart'; @@ -11,10 +13,12 @@ import 'package:romrom_fe/models/app_motion.dart'; import 'package:romrom_fe/widgets/common/app_fade_slide_in.dart'; import 'package:romrom_fe/models/app_theme.dart'; import 'package:romrom_fe/providers/my_items_provider.dart'; +import 'package:romrom_fe/providers/promotion_provider.dart'; import 'package:romrom_fe/screens/item_detail_description_screen.dart'; import 'package:romrom_fe/utils/common_utils.dart'; import 'package:romrom_fe/widgets/common/ai_badge.dart'; import 'package:romrom_fe/widgets/common/app_pressable.dart'; +import 'package:romrom_fe/widgets/common/common_snack_bar.dart'; import 'package:romrom_fe/widgets/common/cached_image.dart'; import 'package:romrom_fe/widgets/common/error_image_placeholder.dart'; import 'package:romrom_fe/widgets/common/request_management_trade_option_tag.dart'; @@ -246,6 +250,11 @@ class _MyRegisterItemScreenState extends ConsumerState wit ) .toList(), ), + // 우선노출 컨트롤 — 교환완료 물건엔 표시하지 않음 + if (item.itemStatus != ItemStatus.exchanged.serverName) ...[ + SizedBox(height: 8.h), + Align(alignment: Alignment.centerRight, child: _buildPromoteControl(item)), + ], ], ), ), @@ -262,6 +271,54 @@ class _MyRegisterItemScreenState extends ConsumerState wit _toggleAnimationController.animateTo(newStatus.id.toDouble(), duration: AppMotion.normal, curve: Curves.easeInOut); } + // 우선노출(롬업) 버튼 핸들러. 광고 시청 → 백엔드 활성화. 결과 enum으로 토스트 분기. + Future _onPromoteTap(Item item) async { + final itemId = item.itemId; + if (itemId == null) return; + final result = await ref.read(promotionProvider.notifier).promoteItem(itemId); + if (!mounted) return; + switch (result) { + case PromoteResult.success: + CommonSnackBar.show(context: context, message: '내 물건이 우선 노출돼요 ⚡', type: SnackBarType.success); + case PromoteResult.adNotEarned: + CommonSnackBar.show(context: context, message: '광고를 끝까지 시청해야 적립돼요', type: SnackBarType.info); + case PromoteResult.failed: + CommonSnackBar.show(context: context, message: '잠시 후 다시 시도해주세요', type: SnackBarType.error); + case PromoteResult.alreadyInFlight: + break; // 중복 요청 — 무시 + } + } + + // 우선노출 버튼(활성 전) / 노출 중 뱃지(활성 후). + Widget _buildPromoteControl(Item item) { + final itemId = item.itemId; + if (itemId == null) return const SizedBox.shrink(); + final isPromoted = ref.watch(promotionProvider).isPromoted(itemId); + + if (isPromoted) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration(color: AppColors.opacity10White, borderRadius: BorderRadius.circular(8)), + child: Text( + '⚡ 노출 중', + style: CustomTextStyles.p3.copyWith(color: AppColors.opacity60White, fontWeight: FontWeight.w600), + ), + ); + } + + return AppPressable( + onTap: () => _onPromoteTap(item), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration(color: AppColors.primaryYellow, borderRadius: BorderRadius.circular(8)), + child: Text( + '⚡ 우선 노출', + style: CustomTextStyles.p3.copyWith(color: AppColors.primaryBlack, fontWeight: FontWeight.w600), + ), + ), + ); + } + Widget _buildImage(String? imageUrl) { if (imageUrl == null || imageUrl.trim().isEmpty) { return const ErrorImagePlaceholder(); diff --git a/lib/services/apis/promotion_api.dart b/lib/services/apis/promotion_api.dart new file mode 100644 index 00000000..cd1cb396 --- /dev/null +++ b/lib/services/apis/promotion_api.dart @@ -0,0 +1,15 @@ +import 'package:romrom_fe/models/app_urls.dart'; +import 'package:romrom_fe/services/api_client.dart'; + +class PromotionApi { + static final PromotionApi _instance = PromotionApi._internal(); + factory PromotionApi() => _instance; + PromotionApi._internal(); + + /// 우선노출 활성화. 광고 보상 시청 완료 후 호출. + /// TODO(#819 BE): 엔드포인트/요청 바디/보상 검증 토큰은 백엔드 확정 시 교체. + Future activatePromotion(String itemId) async { + final url = '${AppUrls.baseUrl}/api/item/promote'; + await ApiClient.sendHttpRequest(url: url, method: 'POST', body: {'itemId': itemId}, onSuccess: (_) {}); + } +} diff --git a/lib/services/rewarded_ad_service.dart b/lib/services/rewarded_ad_service.dart new file mode 100644 index 00000000..b417822e --- /dev/null +++ b/lib/services/rewarded_ad_service.dart @@ -0,0 +1,71 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:google_mobile_ads/google_mobile_ads.dart'; +import 'package:romrom_fe/services/ad_mob_service.dart'; + +/// AdMob 보상형 광고의 로드·표시·보상 콜백을 캡슐화한다. +/// 비즈니스 로직(우선노출)을 전혀 모른다 — 결과는 "보상 받았나" boolean 하나. +/// +/// 주의: 동시 호출에 안전하지 않다(단일 `_ad` 인스턴스 공유). 동시 호출 dedup은 +/// 호출자 책임이다(`PromotionNotifier`가 `_inFlight`로 보장). 단일 탭 흐름만 가정. +class RewardedAdService { + RewardedAd? _ad; + bool _isLoading = false; + + /// 광고 미리 로드. 이미 로드됐거나 로딩 중이면 무시. + Future load() async { + if (_ad != null || _isLoading) return; + final unitId = AdMobService.rewardedAdUnitId; + if (unitId == null) return; // 실제 unit ID는 .env 기반(테스트 빌드만 테스트 ID 반환) + _isLoading = true; + final completer = Completer(); + RewardedAd.load( + adUnitId: unitId, + request: const AdRequest(), + rewardedAdLoadCallback: RewardedAdLoadCallback( + onAdLoaded: (ad) { + _ad = ad; + _isLoading = false; + if (!completer.isCompleted) completer.complete(); + }, + onAdFailedToLoad: (error) { + debugPrint('[RewardedAd] 로드 실패: $error'); + _ad = null; + _isLoading = false; + if (!completer.isCompleted) completer.complete(); + }, + ), + ); + return completer.future; + } + + /// 광고를 표시하고 보상 여부를 반환한다. + /// true = 보상 적립 / false = 미적립(이탈·실패·미로드). + Future showAndAwaitReward() async { + if (_ad == null) await load(); + final ad = _ad; + if (ad == null) return false; // 로드 실패 + + final completer = Completer(); + var earned = false; + + ad.fullScreenContentCallback = FullScreenContentCallback( + // 보상 콜백(onUserEarnedReward)은 닫히기 전에 도착 → 닫힘 시점에 결과 확정 + onAdDismissedFullScreenContent: (ad) { + ad.dispose(); + _ad = null; + if (!completer.isCompleted) completer.complete(earned); + }, + onAdFailedToShowFullScreenContent: (ad, error) { + debugPrint('[RewardedAd] 표시 실패: $error'); + ad.dispose(); + _ad = null; + if (!completer.isCompleted) completer.complete(false); + }, + ); + + ad.show(onUserEarnedReward: (ad, reward) => earned = true); + return completer.future; + } +} diff --git a/lib/states/promotion_state.dart b/lib/states/promotion_state.dart new file mode 100644 index 00000000..81585c01 --- /dev/null +++ b/lib/states/promotion_state.dart @@ -0,0 +1,25 @@ +import 'package:flutter/foundation.dart'; + +/// 우선노출(롬업) 활성화된 itemId 집합을 단일 소유하는 상태. +@immutable +class PromotionState { + final Set promotedItemIds; + + const PromotionState({this.promotedItemIds = const {}}); + + bool isPromoted(String itemId) => promotedItemIds.contains(itemId); + + PromotionState copyWith({Set? promotedItemIds}) => + PromotionState(promotedItemIds: promotedItemIds ?? this.promotedItemIds); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is PromotionState && runtimeType == other.runtimeType && setEquals(promotedItemIds, other.promotedItemIds); + + @override + int get hashCode => Object.hashAll(promotedItemIds); + + @override + String toString() => 'PromotionState(promoted: ${promotedItemIds.length})'; +} diff --git a/test/providers/promotion_provider_test.dart b/test/providers/promotion_provider_test.dart new file mode 100644 index 00000000..4bc5a23c --- /dev/null +++ b/test/providers/promotion_provider_test.dart @@ -0,0 +1,85 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:romrom_fe/enums/promote_result.dart'; +import 'package:romrom_fe/providers/promotion_provider.dart'; +import 'package:romrom_fe/providers/promotion_repository_provider.dart'; +import 'package:romrom_fe/repositories/promotion_repository.dart'; +import 'package:romrom_fe/services/rewarded_ad_service.dart'; + +// 광고 서비스는 public 생성자라 extends 가능 — 보상 결과를 주입한다. +class FakeRewardedAdService extends RewardedAdService { + bool reward = true; + int showCount = 0; + @override + Future load() async {} + @override + Future showAndAwaitReward() async { + showCount++; + return reward; + } +} + +// PromotionApi는 싱글톤(private 생성자)이라 subclass 불가 → repository를 통째로 fake. +class FakePromotionRepository implements PromotionRepository { + bool shouldThrow = false; + int activateCount = 0; + @override + Future activate(String itemId) async { + activateCount++; + if (shouldThrow) throw Exception('boom'); + } +} + +void main() { + group('promotionProvider', () { + late FakeRewardedAdService ad; + late FakePromotionRepository repo; + late ProviderContainer container; + + setUp(() { + ad = FakeRewardedAdService(); + repo = FakePromotionRepository(); + container = ProviderContainer( + overrides: [ + rewardedAdServiceProvider.overrideWithValue(ad), + promotionRepositoryProvider.overrideWithValue(repo), + ], + ); + }); + + tearDown(() => container.dispose()); + + test('보상 O + BE 성공 → success, state에 itemId 포함', () async { + ad.reward = true; + final result = await container.read(promotionProvider.notifier).promoteItem('A'); + expect(result, PromoteResult.success); + expect(container.read(promotionProvider).isPromoted('A'), isTrue); + expect(repo.activateCount, 1); + }); + + test('보상 X → adNotEarned, state 변화 없음, BE 미호출', () async { + ad.reward = false; + final result = await container.read(promotionProvider.notifier).promoteItem('A'); + expect(result, PromoteResult.adNotEarned); + expect(container.read(promotionProvider).isPromoted('A'), isFalse); + expect(repo.activateCount, 0); + }); + + test('보상 O + BE 실패 → failed, state 변화 없음', () async { + ad.reward = true; + repo.shouldThrow = true; + final result = await container.read(promotionProvider.notifier).promoteItem('A'); + expect(result, PromoteResult.failed); + expect(container.read(promotionProvider).isPromoted('A'), isFalse); + }); + + test('진행 중 동일 itemId 재호출 → alreadyInFlight (광고 1회만)', () async { + ad.reward = true; + final f1 = container.read(promotionProvider.notifier).promoteItem('A'); + final f2 = container.read(promotionProvider.notifier).promoteItem('A'); + final results = await Future.wait([f1, f2]); + expect(results, containsAll([PromoteResult.success, PromoteResult.alreadyInFlight])); + expect(ad.showCount, 1); + }); + }); +}