Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 2 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
# idle_hippo

A new Flutter project.

## 功能特色

- 🦛 可愛的河馬角色互動
Expand All @@ -13,11 +11,7 @@ A new Flutter project.
- 🐾 寵物系統與抽獎
- 🌍 多國語系支援 (繁中/英文/日文/韓文)

A few resources to get you started if this is your first Flutter project:
## build 時需要記得修改

- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
build.gradle.kts 中的 versionCode, versionName

For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.
8 changes: 6 additions & 2 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ android {
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = 21
targetSdk = 35
versionCode = 2
versionName = "0.0.23-2-1"
versionCode = 3
versionName = "0.0.27-8"
}

signingConfigs {
Expand Down Expand Up @@ -67,3 +67,7 @@ android {
flutter {
source = "../.."
}

dependencies {
implementation("com.android.billingclient:billing:6.0.1")
}
1 change: 1 addition & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Allow network access for downloading audio files -->
<uses-permission android:name="com.android.vending.BILLING" />
<uses-permission android:name="android.permission.INTERNET" />
<application
android:label="idle_hippo"
Expand Down
21 changes: 21 additions & 0 deletions assets/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,27 @@
"ad_buy": "Ad Purchase",
"restore": "Restore"
},
"purchase_success": "Purchase successful!",
"purchase_failed": "Purchase failed!",
"ad_purchase_failed": "Ad purchase failed!",
"error": "Store error",
"not_initialized": "Store is initializing, please retry later.",
"errors": {
"verify_failed": "Verification failed. Please contact support.",
"verify_exception": "Verification service error. Please try again later.",
"canceled": "Purchase cancelled.",
"purchase_error": "Purchase failed. Please retry.",
"not_initialized": "Store is not ready yet.",
"unknown": "Unexpected store error."
},
"unavailable": {
"limited_cap": "Purchase limit reached.",
"daily_cap": "Daily limit reached.",
"monthly_cap": "Monthly limit reached.",
"first7_expired": "First 7-day offer expired.",
"first30_expired": "First 30-day offer expired.",
"product_not_found": "Product unavailable."
},
"restore": {
"success": "Restored {n} item(s)",
"none": "Nothing to restore"
Expand Down
21 changes: 21 additions & 0 deletions assets/lang/jp.json
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,27 @@
"ad_buy": "広告で購入",
"restore": "購入の復元"
},
"purchase_success": "購入に成功しました!",
"purchase_failed": "購入に失敗しました。",
"ad_purchase_failed": "動画購入に失敗しました。",
"error": "ストアエラー",
"not_initialized": "ストアを初期化しています。しばらくしてからお試しください。",
"errors": {
"verify_failed": "検証に失敗しました。サポートへご連絡ください。",
"verify_exception": "検証サービスでエラーが発生しました。しばらくしてから再試行してください。",
"canceled": "購入がキャンセルされました。",
"purchase_error": "購入に失敗しました。再度お試しください。",
"not_initialized": "ストアの準備が完了していません。",
"unknown": "不明なストアエラーが発生しました。"
},
"unavailable": {
"limited_cap": "購入上限に達しました。",
"daily_cap": "本日の購入回数は上限です。",
"monthly_cap": "今月の購入回数は上限です。",
"first7_expired": "7日間限定パックは終了しました。",
"first30_expired": "30日間限定パックは終了しました。",
"product_not_found": "商品が見つかりません。"
},
"restore": {
"success": "{n} 件を復元しました",
"none": "復元する項目はありません"
Expand Down
21 changes: 21 additions & 0 deletions assets/lang/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,27 @@
"ad_buy": "광고로 구매",
"restore": "구매 복원"
},
"purchase_success": "구매에 성공했습니다!",
"purchase_failed": "구매에 실패했습니다.",
"ad_purchase_failed": "영상 구매에 실패했습니다.",
"error": "스토어 오류",
"not_initialized": "스토어를 초기화하는 중입니다. 잠시 후 다시 시도해주세요.",
"errors": {
"verify_failed": "검증에 실패했습니다. 고객센터에 문의해주세요.",
"verify_exception": "검증 서비스 오류가 발생했습니다. 잠시 후 다시 시도해주세요.",
"canceled": "구매가 취소되었습니다.",
"purchase_error": "구매에 실패했습니다. 다시 시도해주세요.",
"not_initialized": "스토어 준비가 완료되지 않았습니다.",
"unknown": "알 수 없는 스토어 오류가 발생했습니다."
},
"unavailable": {
"limited_cap": "구매 한도에 도달했습니다.",
"daily_cap": "오늘 구매 횟수 한도를 모두 사용했습니다.",
"monthly_cap": "이번 달 구매 횟수 한도를 모두 사용했습니다.",
"first7_expired": "7일 한정 패키지가 종료되었습니다.",
"first30_expired": "30일 한정 패키지가 종료되었습니다.",
"product_not_found": "상품을 찾을 수 없습니다."
},
"restore": {
"success": "{n}개 복원됨",
"none": "복원할 항목이 없습니다"
Expand Down
21 changes: 21 additions & 0 deletions assets/lang/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,27 @@
"ad_buy": "看廣告購買",
"restore": "恢復購買"
},
"purchase_success": "購買成功!",
"purchase_failed": "購買失敗!",
"ad_purchase_failed": "影片購買失敗!",
"error": "商城錯誤",
"not_initialized": "商城初始化中,請稍後再試。",
"errors": {
"verify_failed": "驗證失敗,請聯絡客服。",
"verify_exception": "驗證服務異常,請稍後再試。",
"canceled": "已取消購買。",
"purchase_error": "購買失敗,請再試一次。",
"not_initialized": "商城尚未準備完成。",
"unknown": "發生未知的商城錯誤。"
},
"unavailable": {
"limited_cap": "已達購買上限。",
"daily_cap": "今日購買次數已滿。",
"monthly_cap": "本月購買次數已滿。",
"first7_expired": "新手 7 日禮包已過期。",
"first30_expired": "新手 30 日禮包已過期。",
"product_not_found": "商品已下架或不存在。"
},
"restore": {
"success": "已恢復 {n} 項",
"none": "沒有可恢復項目"
Expand Down
30 changes: 30 additions & 0 deletions docs/step27-8/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# 階段 27-8|測試與健全性檢查

## 目標

為前述各層提供單元與整合測試規格(不需寫測試碼,只列出案例)。

### 規格(測試清單)

* `SkuMapper`:
* 所有 `tabs.*[].items` → `getSkuId` 皆成功
* 異常鍵位回錯誤
* `PurchaseOrchestrator`:
* 成功/失敗/重試/restore 流程
* 查價快取命中
* `EntitlementManager`:
* non-consumable/consumable 冪等
* 遺失 orderId 的 non-consumable 恢復
* `PurchaseLimitPolicy`:
* `limited/daily/monthly/first7/first30` 規則
* UI 狀態機:
* 全主要分支的狀態轉移
* 錯誤碼對文案 key 映射

## 驗收實例化需求

* 建立一組 mock 資料:
* `storeIds`:取你 JSON 裡 permanent/limited 的若干項
* 模擬:`buy` 成功、驗證 `ok=true` → 應觸發 `grant`
* 模擬:`verify ok=false` → 不觸發 `grant`、UI 顯示錯誤
* 模擬:每日/月限制 → UI 正確顯示 `limitedReached/owned`
15 changes: 15 additions & 0 deletions lib/models/purchase_models.dart
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,21 @@ class PurchaseAvailability {
}
}

/// 購買流程錯誤:攜帶可供 UI 映射的錯誤代碼。
class PurchaseFlowException implements Exception {
final String code;
final String? message;
final Object? cause;

const PurchaseFlowException({required this.code, this.message, this.cause});

@override
String toString() {
final msg = message ?? code;
return 'PurchaseFlowException(code: $code, message: $msg)';
}
}

/// 購買記錄資料結構
class PurchaseRecord {
final int? total;
Expand Down
27 changes: 27 additions & 0 deletions lib/services/integrated_store_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,21 @@ class IntegratedStoreService extends ChangeNotifier {
StreamSubscription<PurchaseEvent>? _purchaseSubscription;
bool _initialized = false;
bool _disposed = false;
String? _mockNextErrorCode;

bool get isInitialized => _initialized;

@visibleForTesting
void debugSetNextPurchaseError(String? code) {
_mockNextErrorCode = code;
}

String? _takeMockErrorCode() {
final code = _mockNextErrorCode;
_mockNextErrorCode = null;
return code;
}

/// 初始化服務
Future<void> initialize({
ConfigService? configService,
Expand Down Expand Up @@ -105,6 +117,11 @@ class IntegratedStoreService extends ChangeNotifier {
throw Exception('IntegratedStoreService not initialized');
}

final mockCode = _takeMockErrorCode();
if (mockCode != null) {
throw PurchaseFlowException(code: mockCode);
}

final storeConfig = _configService.getStoreConfig();
final productConfig = storeConfig[productId] as Map<String, dynamic>?;

Expand Down Expand Up @@ -151,6 +168,11 @@ class IntegratedStoreService extends ChangeNotifier {
throw Exception('IntegratedStoreService not initialized');
}

final mockCode = _takeMockErrorCode();
if (mockCode != null) {
throw PurchaseFlowException(code: mockCode);
}

final storeConfig = _configService.getStoreConfig();
final productConfig = storeConfig[productId] as Map<String, dynamic>?;
if (productConfig == null) {
Expand All @@ -166,6 +188,11 @@ class IntegratedStoreService extends ChangeNotifier {
throw Exception('IntegratedStoreService not initialized');
}

final mockCode = _takeMockErrorCode();
if (mockCode != null) {
throw PurchaseFlowException(code: mockCode);
}

final storeConfig = _configService.getStoreConfig();
final productConfig = storeConfig[productId] as Map<String, dynamic>?;
if (productConfig == null) {
Expand Down
36 changes: 34 additions & 2 deletions lib/ui/widgets/shop_purchase_controls.dart
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,10 @@ class _ShopPurchaseControlsState extends State<ShopPurchaseControls> {

Future<void> _init() async {
// 載入價格:直接透過 IntegratedStoreService.queryProducts
setState(() => _state = UiPurchaseState.loadingPrice);
setState(() {
_state = UiPurchaseState.loadingPrice;
_errorKey = null;
});
try {
final List<ProductInfo> list =
await IntegratedStoreService().queryProducts([widget.itemKey]);
Expand All @@ -65,10 +68,14 @@ class _ShopPurchaseControlsState extends State<ShopPurchaseControls> {
await IntegratedStoreService().getAvailability(widget.itemKey);
if (!mounted) return;
if (availability.canBuy) {
setState(() => _state = UiPurchaseState.ready);
setState(() {
_state = UiPurchaseState.ready;
_errorKey = null;
});
} else {
setState(() {
_state = _mapUnavailabilityToState(widget.limitType);
_errorKey = availability.reasonKey ?? _mapErrorCode('unknown');
});
}
}
Expand Down Expand Up @@ -108,6 +115,19 @@ class _ShopPurchaseControlsState extends State<ShopPurchaseControls> {
}
}

String? _mapErrorCode(String code) {
const mapping = {
'verify_failed': 'store.errors.verify_failed',
'verify_exception': 'store.errors.verify_exception',
'canceled': 'store.errors.canceled',
'purchase_error': 'store.errors.purchase_error',
'not_initialized': 'store.errors.not_initialized',
'product_not_found': 'store.unavailable.product_not_found',
'unknown': 'store.errors.unknown',
};
return mapping[code];
}

@override
void dispose() {
_sub?.cancel();
Expand Down Expand Up @@ -247,9 +267,15 @@ class _ShopPurchaseControlsState extends State<ShopPurchaseControls> {
);
setState(() {
_state = _successLandingState(widget.limitType);
_errorKey = null;
});
} catch (e) {
if (!mounted) return;
if (e is PurchaseFlowException) {
_errorKey = _mapErrorCode(e.code) ?? 'store.purchase_failed';
} else {
_errorKey ??= 'store.errors.unknown';
}
final msgKey = _errorKey ?? 'store.purchase_failed';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
Expand Down Expand Up @@ -285,9 +311,15 @@ class _ShopPurchaseControlsState extends State<ShopPurchaseControls> {
);
setState(() {
_state = _successLandingState(widget.limitType);
_errorKey = null;
});
} catch (e) {
if (!mounted) return;
if (e is PurchaseFlowException) {
_errorKey = _mapErrorCode(e.code) ?? 'store.purchase_failed';
} else {
_errorKey ??= 'store.errors.unknown';
}
final msgKey = _errorKey ?? 'store.purchase_failed';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
Expand Down