From b464400b6b770d6eafaa9e20c59aa3377a6f0b0a Mon Sep 17 00:00:00 2001 From: ivan Date: Thu, 12 Mar 2026 14:30:19 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=EF=BC=9A=E6=94=AF=E4=BB=98?= =?UTF-8?q?=E5=AF=B9=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/build.gradle | 8 + design/pencil-app-client.pen | 282 +++++++---- docs/api_flow_summary.md | 195 ++++---- docs/generate_video.md | 6 +- docs/google_pay_product_not_found.md | 76 +++ docs/googlepay.md | 34 ++ docs/payment_flow.md | 135 +++++ docs/user_login.md | 124 +++++ ios/Flutter/Generated.xcconfig | 4 +- ios/Flutter/flutter_export_environment.sh | 4 +- ios/Runner/GeneratedPluginRegistrant.m | 21 + lib/app.dart | 51 +- lib/core/api/api_config.dart | 3 +- lib/core/api/proxy_client.dart | 156 +++++- lib/core/api/services/payment_api.dart | 40 ++ lib/core/api/services/user_api.dart | 25 + lib/core/auth/auth_service.dart | 106 +++- lib/core/log/app_logger.dart | 40 ++ lib/core/theme/app_colors.dart | 6 + lib/core/user/user_state.dart | 7 + .../generate_progress_screen.dart | 191 +++++-- .../generate_video/generate_video_screen.dart | 470 ++++++++++++------ .../generation_result_screen.dart | 10 +- lib/features/home/widgets/video_card.dart | 174 +++---- .../google_play_purchase_service.dart | 147 ++++++ .../recharge/models/activity_item.dart | 8 + .../recharge/models/payment_method_item.dart | 28 ++ .../recharge/payment_webview_screen.dart | 66 +++ lib/features/recharge/recharge_screen.dart | 403 +++++++++++++-- lib/shared/tab_selector_scope.dart | 21 + pubspec.yaml | 6 +- 31 files changed, 2266 insertions(+), 581 deletions(-) create mode 100644 docs/google_pay_product_not_found.md create mode 100644 docs/googlepay.md create mode 100644 docs/payment_flow.md create mode 100644 docs/user_login.md create mode 100644 lib/core/log/app_logger.dart create mode 100644 lib/features/recharge/google_play_purchase_service.dart create mode 100644 lib/features/recharge/models/payment_method_item.dart create mode 100644 lib/features/recharge/payment_webview_screen.dart create mode 100644 lib/shared/tab_selector_scope.dart diff --git a/android/app/build.gradle b/android/app/build.gradle index 9e36992..169edc2 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -28,6 +28,12 @@ if (flutterVersionName == null) { flutterVersionName = '1.0' } +configurations.all { + resolutionStrategy { + force 'com.android.billingclient:billing:7.1.1' + } +} + android { namespace "com.petsheroai.app" compileSdk 36 @@ -79,6 +85,8 @@ dependencies { implementation 'com.google.android.gms:play-services-ads-identifier:18.1.0' implementation 'com.android.installreferrer:installreferrer:2.2' implementation 'androidx.appcompat:appcompat:1.7.0' + // Play Billing Library 6.0.1+ (use 7.1.1 for in_app_purchase plugin compatibility) + implementation 'com.android.billingclient:billing:7.1.1' } flutter { diff --git a/design/pencil-app-client.pen b/design/pencil-app-client.pen index dbda78b..bea6be9 100644 --- a/design/pencil-app-client.pen +++ b/design/pencil-app-client.pen @@ -2691,13 +2691,40 @@ "fontWeight": "600" }, { - "type": "text", - "id": "Xvh9R", - "fill": "#71717A", - "content": "$5.99", - "fontFamily": "Inter", - "fontSize": 14, - "fontWeight": "500" + "type": "frame", + "id": "iVYPz", + "name": "priceLine", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "BO2Is", + "fill": "#71717A", + "content": "$5.99", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "text", + "id": "sgYV2", + "fill": "#A1A1AA", + "content": "$6.99", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "text", + "id": "1bb2Y", + "fill": "#8B5CF6", + "content": "+10 pts", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "600" + } + ] } ] }, @@ -2788,13 +2815,40 @@ "fontWeight": "600" }, { - "type": "text", - "id": "Rg4EJ", - "fill": "#71717A", - "content": "$9.99", - "fontFamily": "Inter", - "fontSize": 14, - "fontWeight": "500" + "type": "frame", + "id": "IAiZI", + "name": "priceLine", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "oFR6Q", + "fill": "#71717A", + "content": "$9.99", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "text", + "id": "bZrc4", + "fill": "#A1A1AA", + "content": "$12.99", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "text", + "id": "2oQd4", + "fill": "#8B5CF6", + "content": "+25 pts", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "600" + } + ] } ] }, @@ -2933,13 +2987,40 @@ "fontWeight": "600" }, { - "type": "text", - "id": "BxrWP", - "fill": "#71717A", - "content": "$19.99", - "fontFamily": "Inter", - "fontSize": 14, - "fontWeight": "500" + "type": "frame", + "id": "gSnzm", + "name": "priceLine", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "g3vvN", + "fill": "#71717A", + "content": "$19.99", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "text", + "id": "9DcxP", + "fill": "#A1A1AA", + "content": "$24.99", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "text", + "id": "SCF0u", + "fill": "#8B5CF6", + "content": "+80 pts", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "600" + } + ] } ] }, @@ -3061,13 +3142,40 @@ "fontWeight": "600" }, { - "type": "text", - "id": "lydr2", - "fill": "#71717A", - "content": "$49.99", - "fontFamily": "Inter", - "fontSize": 14, - "fontWeight": "500" + "type": "frame", + "id": "QcmBX", + "name": "priceLine", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "QbQa2", + "fill": "#71717A", + "content": "$49.99", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "text", + "id": "bvZ8D", + "fill": "#A1A1AA", + "content": "$59.99", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "text", + "id": "xKMOi", + "fill": "#8B5CF6", + "content": "+200 pts", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "600" + } + ] } ] }, @@ -3141,13 +3249,40 @@ "fontWeight": "600" }, { - "type": "text", - "id": "nG15k", - "fill": "#71717A", - "content": "$99.99", - "fontFamily": "Inter", - "fontSize": 14, - "fontWeight": "500" + "type": "frame", + "id": "xMHYZ", + "name": "priceLine", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "wLYzH", + "fill": "#71717A", + "content": "$99.99", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "text", + "id": "ch71f", + "fill": "#A1A1AA", + "content": "$119.99", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "text", + "id": "H6lDP", + "fill": "#8B5CF6", + "content": "+500 pts", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "600" + } + ] } ] }, @@ -3321,6 +3456,7 @@ "type": "frame", "id": "BNNL5", "name": "contentArea", + "context": "Content area: creditsCard + videoPreviewArea scrollable; resolutionRow + generateBtn fixed at bottom.", "width": "fill_container", "height": "fill_container", "layout": "vertical", @@ -3400,6 +3536,7 @@ "type": "frame", "id": "vA8QJ", "name": "videoPreviewArea", + "context": "Video preview: video URL from card. Auto-load and play. Width: fill container; height: adapt by video aspect ratio. Fit: contain (no crop). Loading animation until ready.", "width": "fill_container", "height": 280, "fill": { @@ -3413,80 +3550,13 @@ "thickness": 1, "fill": "#E4E4E7" }, - "layout": "none", - "children": [ - { - "type": "icon_font", - "id": "1VkSk", - "x": 151, - "y": 93, - "enabled": false, - "width": 48, - "height": 48, - "iconFontName": "image-plus", - "iconFontFamily": "lucide", - "fill": "#A1A1AA" - }, - { - "type": "text", - "id": "flPeY", - "x": 35, - "y": 105.5, - "enabled": false, - "fill": "#FFFFFF", - "effect": { - "type": "shadow", - "shadowType": "outer", - "color": "#00000080", - "offset": { - "x": 0, - "y": 1 - }, - "blur": 4 - }, - "textGrowth": "fixed-width", - "width": 280, - "content": "Video Preview", - "textAlign": "center", - "fontFamily": "Inter", - "fontSize": 14, - "fontWeight": "500" - }, - { - "type": "frame", - "id": "5y1EY", - "x": 12, - "y": 208, - "name": "iconWrap", - "width": 60, - "height": 60, - "fill": "#FFFFFF80", - "cornerRadius": 30, - "stroke": { - "thickness": 2, - "fill": "#18181B" - }, - "layout": "vertical", - "justifyContent": "center", - "alignItems": "center", - "children": [ - { - "type": "icon_font", - "id": "yMNqH", - "width": 28, - "height": 28, - "iconFontName": "image-plus", - "iconFontFamily": "lucide", - "fill": "#18181B" - } - ] - } - ] + "layout": "none" }, { "type": "frame", "id": "oYwsm", "name": "resolutionRow", + "context": "Resolution options: 480p/720p. Fixed at screen bottom. When switching, update Generate Video credits (RGKvY).", "width": "fill_container", "height": 44, "gap": 12, @@ -3583,6 +3653,7 @@ "type": "frame", "id": "yunXY", "name": "generateBtn", + "context": "Generate Video button. Fixed at screen bottom. Click flow: image picker -> API. See docs/generate_video.md.", "width": "fill_container", "height": 56, "fill": "#8B5CF6", @@ -3614,6 +3685,7 @@ "type": "frame", "id": "RGKvY", "name": "creditsCost", + "context": "Credits cost for current resolution. Updates when resolution (oYwsm) changes.", "fill": "#FFFFFF30", "cornerRadius": 8, "gap": 4, @@ -3803,6 +3875,7 @@ "type": "frame", "id": "rSN3T", "name": "videoDisplay", + "context": "Video area: display uploaded image with crop (fill/cover). Matches progress screen.", "clip": true, "width": "fill_container", "height": 360, @@ -3842,6 +3915,7 @@ "type": "text", "id": "Eghqc", "name": "progressLabel", + "context": "Progress label: 1=队列中 2=处理中 3=完成 4=超时 5=错误 6=中止. Updated from /v1/image/progress every 1s.", "fill": "#18181B", "content": "Generating...", "fontFamily": "Inter", diff --git a/docs/api_flow_summary.md b/docs/api_flow_summary.md index 68b9322..97f1514 100644 --- a/docs/api_flow_summary.md +++ b/docs/api_flow_summary.md @@ -1,146 +1,173 @@ # petsHeroAI 接口调用流程说明 -## 一、整体流程 +本文档说明客户端请求的加解密与代理流程,以及**按业务场景**的接口调用顺序。具体字段以 **petsHeroAI_client_guide.md** 为准。 + +--- + +## 一、请求流程(所有接口统一) ``` -┌─────────────────────────────────────────────────────────────────┐ -│ 客户端请求流程 │ -└─────────────────────────────────────────────────────────────────┘ - - 业务参数(原始字段) + 业务参数(V2 字段名) │ ▼ ┌─────────────┐ - │ 字段名映射 │ body / params / headers 中的原始字段 → V2 字段 + │ V2 包装 │ body → arsenal/vault/tome/codex/grimoire/sanctum └─────────────┘ │ ▼ ┌─────────────┐ - │ V2 包装 │ 将 body 包装为 arsenal/vault/tome/codex/grimoire/sanctum 结构 + │ JSON 序列化 │ headers / queryParams / body 分别序列化 └─────────────┘ │ ▼ ┌─────────────┐ - │ JSON 序列化 │ + │ AES+Base64 │ 各字段 AES-128-ECB、PKCS5 填充后 Base64 └─────────────┘ │ ▼ ┌─────────────┐ - │ AES+Base64 │ AES-128-ECB, PKCS5Padding 加密 - └─────────────┘ - │ - ▼ - ┌─────────────┐ - │ 构造代理请求 │ 填入 hero_class, pet_species, power_level 等参数 + │ 构造代理请求 │ hero_class, pet_species(path), power_level(method), + │ │ quest_rank(headers), battle_score(queryParams), + │ │ loyalty_index(body) + 噪音字段 └─────────────┘ │ ▼ POST {baseUrl}/quester/defender/summoner ``` +- **path**(如 `/v1/user/fast_login`)加密后放在 **pet_species**。 +- **method**(GET/POST)加密后放在 **power_level**。 +- 登录后 **knight**(userToken)由 ProxyClient 自动写入请求头并参与加密。 + +--- + ## 二、响应处理流程 ``` - POST 代理入口响应 + POST 代理入口响应(密文 body) │ ▼ - ┌─────────────┐ - │ 提取密文 │ 从响应中获取加密字段 - └─────────────┘ + Base64 解码 → AES 解密 → JSON 解析 │ ▼ - ┌─────────────┐ - │ Base64 解码 │ - └─────────────┘ + 取 vault.tome.codex.grimoire.sanctum │ ▼ - ┌─────────────┐ - │ AES 解密 │ AES-128-ECB 解密 - └─────────────┘ + helm → code, rampart → msg, sidekick → data │ ▼ - ┌─────────────┐ - │ JSON 解析 │ - └─────────────┘ - │ - ▼ - ┌─────────────┐ - │ 字段逆映射 │ V2 字段 → 原始字段 (便于业务使用) - └─────────────┘ - │ - ▼ - 业务数据 (code/msg/data) + ApiResponse(code, msg, data) ``` -## 三、接口分类与调用顺序 +--- -### 3.1 登录与用户 +## 三、按业务场景的接口调用顺序 + +### 3.1 应用启动与登录(AuthService.init) | 顺序 | 接口 | 方法 | 说明 | |------|------|------|------| -| 1 | `/v1/user/fast_login` | POST | 设备快速登录,获取 userToken | -| 2 | `/v1/user/common_info` | GET | 获取用户通用信息(含积分、头像等) | -| 3 | `/v1/user/account` | GET | 获取用户账户信息 | -| 4 | `/v1/user/referrer` | POST | 归因上报 | -| 5 | `/v1/user/delete` | GET | 注销账户 | +| 1 | `/v1/user/fast_login` | POST | 设备快速登录;body: digest, resolution(sign), origin(deviceId)。返回 reevaluate(userToken)、asset(uid)、reveal(积分) 等。 | +| 2 | (保存 token、用户信息到 UserState;首次登录打点 register) | — | — | +| 3 | `/v1/user/referrer` | POST | 归因上报;query: sentinel, asset(uid), portal;body: digest, origin。 | +| 4 | `/v1/user/common_info` | GET | 获取用户通用信息;query: sentinel, asset(uid)。解析 data 写入 UserState,并解析 surge 中 enable_third_party_payment 等。 | -### 3.2 支付 +**调用处**:`lib/core/auth/auth_service.dart`(init,登录成功后顺序执行)。 + +--- + +### 3.2 充值 / 支付 + +| 场景 | 顺序 | 接口 | 方法 | 说明 | +|------|------|------|------|------| +| 进入充值页 | 1 | `/v1/payment/getGooglePayActivities` 或 `/v1/payment/getApplePayActivities` | GET | 按平台获取商品列表;data.summon 为列表,单项含 helm(商品ID)、warrior(activityId)、guardian、curriculum、forge 等。 | +| 用户点 Buy(第三方支付开) | 2 | `/v1/payment/get-payment-methods` | POST | body: warrior(activityId), vambrace(国家可选)。返回 data.renew 支付方式列表。 | +| 用户选支付方式后 | 3 | `/v1/payment/createPayment` | POST | query: sentinel, asset(userId);body: sentinel, asset, warrior, resource, ceremony。返回 federation(订单ID)、convert(payUrl)。 | +| 若选中 Google Pay | 4 | 调起 Google Play 内购,成功后 | — | 使用 serverVerificationData 作为 merchant。 | +| | 5 | `/v1/payment/googlepay` | POST | body: merchant(凭据), federation(订单ID), asset(userId)。 | +| 若选其他支付方式 | — | 打开 createPayment 返回的 convert(payUrl) | — | 在外部浏览器完成支付。 | +| 用户点 Buy(第三方支付关) | — | 不调 2、3,直接调起 Google Play 内购(商品 ID = helm) | — | 无 createPayment / googlepay。 | + +**调用处**:`lib/features/recharge/recharge_screen.dart`、`PaymentApi`、`GooglePlayPurchaseService`。详见 **docs/payment_flow.md**。 + +--- + +### 3.3 生成视频(图生视频) | 顺序 | 接口 | 方法 | 说明 | |------|------|------|------| -| 1 | `/v1/payment/getGooglePayActivities` | GET | 获取 Google 商品列表 | -| 2 | `/v1/payment/getApplePayActivities` | GET | 获取 Apple 商品列表 | -| 3 | `/v1/payment/createPayment` | POST | 创建支付订单 | -| 4 | `/v1/payment/googlepay` | POST | Google 支付结果回调 | -| 5 | `/v1/payment/applepay` | POST | Apple 支付结果回调 | -| 6 | `/v1/payment/getPaymentDetailList` | GET | 获取支付订单列表 | +| 1 | `/v1/image/upload-presigned-url` | POST | 获取上传 URL;body: gateway(fileName1), action(fileName2), pauldron(contentType), stronghold(expectedSize)。返回的 URL 用于**直接 PUT 上传图片**(不经过代理)。 | +| 2 | (客户端 PUT 上传图片到 presigned URL) | PUT | 不经过代理,请求头按接口要求设置。 | +| 3 | `/v1/image/create-task` | POST | query: asset;body: commission(srcImg1Url 等)、guild(分辨率) 等。返回任务信息。 | +| 4 | `/v1/image/progress` | GET | query: sentinel, tree(taskId), asset。进度页**轮询**(如每 1s),直到 state 为 3(完成)/4/5/6。 | +| 5 | (生成完成后)`/v1/user/account` | GET | 刷新用户积分。 | -### 3.3 图片生成 +**调用处**:`lib/features/generate_video/generate_video_screen.dart`(1→2→3)、`generate_progress_screen.dart`(4、5)。 -| 顺序 | 接口 | 方法 | 说明 | +--- + +### 3.4 首页与图库 + +| 场景 | 接口 | 方法 | 说明 | |------|------|------|------| -| 1 | `/v1/image/prompt/recomends` | GET | 获取推荐提示词 | -| 2 | `/v1/image/txt2img_tags` | GET | 获取文生图标签 | -| 3 | `/v1/image/txt2img_prompts` | POST | 获取文生图提示词模板 | -| 4 | `/v1/image/txt2img_create` | POST | 创建文生图任务 | -| 5 | `/v1/image/progress` | GET | 查询图片生成进度 | +| 首页分类/列表 | `/v1/image/img2video/categories` | GET | 获取图转视频分类。 | +| | `/v1/image/img2video/tasks` | GET | 获取图转视频任务列表;可选 query: insignia(categoryId)。 | +| 图库「我的」 | `/v1/image/my-tasks` | GET | 获取我的任务列表;query: sentinel, trophy/heatmap/platoon 等。 | -### 3.4 图转视频 +**调用处**:`lib/features/home/home_screen.dart`、`lib/features/gallery/gallery_screen.dart`。 -| 顺序 | 接口 | 方法 | 说明 | -|------|------|------|------| -| 1 | `/v1/image/img2Video_pose_template` | GET | 获取图转视频姿态模板 | -| 2 | `/v1/image/img2video_pose_task` | POST | 创建图转视频姿态任务 | -| 3 | `/v1/image/progress` | GET | 查询任务进度 | +--- -### 3.5 换衣 / 换脸 - -| 顺序 | 接口 | 方法 | 说明 | -|------|------|------|------| -| 1 | `/v1/image/clothes_template` | GET | 获取换衣模板 | -| 2 | `/v1/image/clothes_swap_ex` | POST | 创建换衣任务 | -| 3 | `/v1/image/faceswap_task` | POST | 创建换脸任务 | -| 4 | `/v1/image/video_facewap_task` | POST | 创建视频换脸任务 | - -### 3.6 其他 +### 3.5 其他 | 接口 | 方法 | 说明 | |------|------|------| -| `/v1/image/category-list` | GET | 获取分类列表 | -| `/v1/log/appevent` | POST | App 事件打点上报 | -| `/v1/image/getCreditsPageInfo` | GET | 获取积分页面信息 | -| `/v1/log/uploadUrl` | POST | 获取预签名上传 URL | +| `/v1/user/account` | GET | 获取账户信息(积分等);个人页、生成视频完成后刷新积分时调用。 | +| `/v1/image/getCreditsPageInfo` | GET | 获取积分页信息。 | +| `/v1/image/prompt/recomends` | GET | 获取推荐提示词。 | +| `/v1/image/txt2img_create` | POST | 创建文生图任务。 | +| `/v1/image/img2Video_pose_template` | GET | 获取图转视频姿态模板。 | +| `/v1/image/img2video_pose_task` | POST | 创建图转视频姿态任务。 | -## 四、通用请求头 +--- -登录后所有请求需携带: +## 四、接口路径与客户端封装对照 + +| 接口路径 | 方法 | 客户端封装(lib/core/api/services/) | +|----------|------|--------------------------------------| +| /v1/user/fast_login | POST | UserApi.fastLogin | +| /v1/user/referrer | POST | UserApi.referrer | +| /v1/user/common_info | GET | UserApi.getCommonInfo | +| /v1/user/account | GET | UserApi.getAccount | +| /v1/payment/getGooglePayActivities | GET | PaymentApi.getGooglePayActivities | +| /v1/payment/getApplePayActivities | GET | PaymentApi.getApplePayActivities | +| /v1/payment/get-payment-methods | POST | PaymentApi.getPaymentMethods | +| /v1/payment/createPayment | POST | PaymentApi.createPayment | +| /v1/payment/googlepay | POST | PaymentApi.googlepay | +| /v1/image/img2video/categories | GET | ImageApi.getCategoryList | +| /v1/image/img2video/tasks | GET | ImageApi.getImg2VideoTasks | +| /v1/image/my-tasks | GET | ImageApi.getMyTasks | +| /v1/image/upload-presigned-url | POST | ImageApi.getUploadPresignedUrl | +| /v1/image/create-task | POST | ImageApi.createTask | +| /v1/image/progress | GET | ImageApi.getProgress | +| /v1/image/getCreditsPageInfo | GET | ImageApi.getCreditsPageInfo | +| /v1/image/prompt/recomends | GET | ImageApi.getPromptRecommends | +| /v1/image/txt2img_create | POST | ImageApi.createTxt2Img | +| /v1/image/img2Video_pose_template | GET | ImageApi.getImg2VideoPoseTemplates | +| /v1/image/img2video_pose_task | POST | ImageApi.createImg2VideoPose | + +--- + +## 五、通用请求头(登录后) | 原始字段 | V2 字段 | 说明 | |----------|---------|------| -| pkg | portal | 应用包名,必填,如 `com.petsheroai.app` | -| User_token | knight | 用户登录 token | +| pkg | portal | 应用包名,必填;ProxyClient 自动带。 | +| User_token | knight | 用户 token;登录成功后 ProxyClient 自动带。 | -## 五、通用响应结构 +--- + +## 六、通用响应结构(解密后业务层) ```json { @@ -150,7 +177,9 @@ } ``` -## 六、错误码 +--- + +## 七、错误码 | code | 说明 | |------|------| diff --git a/docs/generate_video.md b/docs/generate_video.md index d27f537..5d50f08 100644 --- a/docs/generate_video.md +++ b/docs/generate_video.md @@ -1,6 +1,6 @@ -# UI 开发流程 -当点击中间upload an image as the base for generation 区域的时候,调用手机相册功能选择一张图片,一次只允许一张。用户选完图片后图片显示在 upload an image as the base for generation区域,并隐藏提示信息。 -当点击Generate Video的时候如果用户没有选择图片择提示用户选择图片,如果已经选择了图片则开始按下面的步骤调用接口 +# UI逻辑 +1.点击Generate Video的时候先进入选择图片的流程,可以是拍照或相册选择。 +2.选择完图片后进入接口调用步骤 # 接口调用 ## 第一步 通过/v1/image/upload-presigned-url接口获取文件上传地址,通常是uploadUrl1或者uploadUrl2,以及对应的filePath1和filePath2用于生成视频接口的入参 diff --git a/docs/google_pay_product_not_found.md b/docs/google_pay_product_not_found.md new file mode 100644 index 0000000..76fe9b7 --- /dev/null +++ b/docs/google_pay_product_not_found.md @@ -0,0 +1,76 @@ +# 谷歌支付「系统无法找到您要购买的商品」排查 + +应用内购时提示「系统无法找到您要购买的商品」,而 Play 后台已配置商品,常见原因如下。 + +--- + +## 1. 商品 ID 不一致(最常见) + +- **来源**:客户端请求的商品 ID 来自接口 **getGooglePayActivities** 返回的 **helm**(产品代码),即 `ActivityItem.code`。 +- **要求**:该 ID 必须与 Google Play Console 里「创收」→「应用内商品」中配置的 **产品 ID** 完全一致(区分大小写、无多余空格)。 +- **排查**: + - 看日志:`[GooglePlayPurchase] 谷歌支付请求商品 ID(helm): "xxx"` 和 `notFoundIDs=xxx`。 + - 在 Play Console 打开对应应用 → 创收 → 应用内商品,对照「产品 ID」是否与日志里的 `productId` 一字不差。 +- **处理**:要么改后台/接口返回的 **helm** 与 Play 产品 ID 一致,要么在 Play 后台新建/修改产品,使产品 ID 与 helm 一致。 + +--- + +## 2. 应用未上架到任意版本轨道 + +- 应用必须至少发布到 **内部测试 / 封闭测试 / 开放测试 / 生产** 中的一条轨道,应用内商品才会对设备可见。 +- **排查**:Play Console → 发布 → 查看是否有版本在任一轨道上。 +- **处理**:上传 AAB 并发布到至少「内部测试」轨道;用于测试的账号需加入该轨道的测试人员名单。 + +--- + +## 3. 应用签名与 Play 不一致 + +- 设备上的应用必须用与 Play 登记一致的签名(或已加入「应用签名」的上传密钥)签名,否则 Play 不会返回该应用的商品。 +- **排查**: + - 正式包:是否用 Play 后台「应用签名」里显示的签名(或已登记的上传密钥)签名。 + - 调试包:若用 debug 签名测试,需在 Play Console 把 **SHA-1(或 SHA-256)** 加入该应用的「许可证测试」或使用同一签名。 +- **处理**:使用与 Play 一致的 keystore 打 release 包;调试时确保测试设备上的签名已被 Play 接受(或使用内部测试并加入测试账号)。 + +--- + +## 4. 商品未激活或未保存 + +- Play 后台新建的应用内商品需 **激活** 并 **保存**,否则不会出现在查询结果中。 +- **排查**:创收 → 应用内商品 → 对应商品状态是否为「已激活」。 +- **处理**:保存并激活商品,等待一段时间(通常几分钟到几小时)再试。 + +--- + +## 5. 地区 / 账号 + +- 商品可能仅在某些国家/地区提供;设备上的 Google 账号所在地区可能不在支持范围内。 +- 测试账号需有权限:内部测试等需将账号加入测试人员列表。 +- **排查**:设备 Google 账号地区、是否在测试名单内。 +- **处理**:在 Play Console 为商品勾选对应国家/地区;用已加入测试的账号登录设备。 + +--- + +## 6. 后端返回的 helm 与 Play 产品 ID 的映射 + +- 若后端 **getGooglePayActivities** 返回的 **helm** 是内部编码(例如活动 ID),而不是 Play 的「产品 ID」,就会导致查不到商品。 +- **处理**:确保 **helm** 字段就是该商品在 Play Console 里配置的「产品 ID」;如有映射表,需在服务端把活动/内部 ID 映射成真实的 Play 产品 ID 再填入 helm。 + +--- + +## 快速核对清单 + +| 检查项 | 说明 | +|--------|------| +| 产品 ID 一致 | 日志中的 productId 与 Play「应用内商品」→「产品 ID」完全一致 | +| 应用已发布 | 至少一条轨道(如内部测试)有版本 | +| 签名一致 | 安装包签名与 Play 应用签名/上传密钥一致 | +| 商品已激活 | 应用内商品已保存并激活 | +| 测试账号 | 使用已加入测试的 Google 账号、账号地区在商品支持范围内 | + +--- + +## 本应用中的日志 + +- 发起购买时会打日志:`[GooglePlayPurchase] 谷歌支付请求商品 ID(helm): "xxx"`。 +- 若商品未找到会打:`商品未找到: 请求的 productId="xxx", notFoundIDs=[xxx]`。 +- 根据上述 productId 与 notFoundIDs 到 Play 后台逐项对照「产品 ID」即可定位是否 ID 不一致。 diff --git a/docs/googlepay.md b/docs/googlepay.md new file mode 100644 index 0000000..85f139c --- /dev/null +++ b/docs/googlepay.md @@ -0,0 +1,34 @@ +# 支付界面 +recharge_screen.dart +``` + "extrapolate": "", // note (string) 备注 + "helm": "", // code (string) 产品代码 + "forge": 0, // bonus (int) 赠送积分 + "guardian": "", // actualAmount (string) 实际金额 + "lead": "", // discountOff (string) 折扣 + "glossary": "", // title (string) 标题 + "curriculum": "", // originAmount (string) 原价 + "warrior": "", // activityId (string) 活动ID + "distribute": "", // subscriptionPeriod (string) 订阅周期 + "greaves": 0, // credits (int) 积分数 + "shield": "", // client (string) 客户端 + "species": 0, // days (int) 天数 + "familiar": "", // currency (string) 货币 + "subtract": 0 // productType (int) 产品类型 +``` +其中helm 为google pay的商品ID +## 界面要展示 + 1.价格需显示$符号 字段guardian + 2.原价需显示中划线 字段curriculum + 3.赠送积分 字段forge + +# 购买 +从/v1/user/common_info接口里面取到 enable_third_party_payment字段如果这个字段为true则调用第三方支付 +## 三方支付流程 +获取商品列表/v1/payment/getGooglePayActivities -> +传activityId获取支付列表/v1/payment/get-payment-methods -> +创建订单的时候把选择的支付方式的字段填充,创建订单/v1/payment/createPayment +创建订单传app、userId、activityId、paymentMethod、subPaymentMethod(有的话),其他那些先不管 + +# 谷歌支付回调接口 +/v1/payment/googlepay diff --git a/docs/payment_flow.md b/docs/payment_flow.md new file mode 100644 index 0000000..997a0c1 --- /dev/null +++ b/docs/payment_flow.md @@ -0,0 +1,135 @@ +# 支付流程(当前实现) + +本文档描述充值页「Buy」点击后的完整支付流程,与 `recharge_screen.dart`、`PaymentApi`、`GooglePlayPurchaseService` 实现一致。 + +--- + +## 1. 流程总览 + +``` +用户点击 Buy + │ + ├─ enable_third_party_payment === true 且已登录 + │ │ + │ ├─ getPaymentMethods(activityId) + │ ├─ 弹窗选择支付方式(_PaymentMethodDialog) + │ ├─ createPayment(activityId, productId, paymentMethod, subPaymentMethod) + │ │ + │ ├─ 若选中的是 Google Pay(resource/ceremony == "GooglePay") + │ │ ├─ 调起 Google Play 内购(productId = item.code) + │ │ ├─ 拿到 serverVerificationData + │ │ └─ POST /v1/payment/googlepay(merchant, federation, asset) + │ │ + │ └─ 否则(其他支付方式) + │ └─ 打开 createPayment 返回的 payUrl(convert)在外部浏览器完成支付 + │ + └─ enable_third_party_payment !== true 或未登录 + └─ 仅 Android:直接调起 Google Play 内购(productId = item.code),无 createPayment +``` + +--- + +## 2. 支付分支依据 + +- **数据来源**:`/v1/user/common_info` 响应中的 **surge**(JSON 字符串),解析得到 **enable_third_party_payment**。 +- **客户端状态**:`UserState.enableThirdPartyPayment`(登录后由 AuthService 从 common_info 写入)。 +- **分支**: + - **第三方支付**:`enable_third_party_payment == true` 且 `UserState.userId` 非空 → 走「获取支付方式 → 弹窗选择 → 创建订单 → 按支付方式分支」。 + - **直接谷歌支付**:否则(未开三方或未登录)→ 仅 Android 下直接调起 Google Play 内购,不调 getPaymentMethods / createPayment。 + +--- + +## 3. 支付界面与商品展示 + +- **界面**:`recharge_screen.dart`。 +- **商品来源**:`GET /v1/payment/getGooglePayActivities`(Android)或 getApplePayActivities(iOS),列表为 data.summon(activitys)。 +- **单条商品字段(V2 映射)**: + +| 字段(映射后) | 说明 | +|----------------|------| +| helm | 产品代码,即 **Google Pay 商品 ID**(与 Play 后台「产品 ID」必须一致) | +| warrior | 活动 ID(activityId),getPaymentMethods / createPayment 必传 | +| guardian | 实际金额,界面带 **$** 显示 | +| curriculum | 原价,界面**中划线**显示 | +| forge | 赠送积分,界面展示 | +| glossary | 标题;greaves=积分数,familiar=货币等 | + +- **界面规则**:价格 / 原价 / 赠送积分同一行展示;仅当前点击的 item 的 Buy 按钮显示 loading(_loadingProductId)。 + +--- + +## 4. 第三方支付流程(enable_third_party_payment === true) + +### 4.1 步骤顺序 + +| 步骤 | 说明 | +|------|------| +| 1 | 用户点击某商品的 Buy,得到该商品的 **activityId**(warrior)、**productId**(helm/code)。 | +| 2 | **POST /v1/payment/get-payment-methods**,body:warrior=activityId,vambrace=国家(可选)。 | +| 3 | 弹窗 **支付方式列表**(renew),用户选择一项 → 得到 resource(paymentMethod)、ceremony(subPaymentMethod)。 | +| 4 | **POST /v1/payment/createPayment**,body:sentinel, asset(userId), warrior(activityId), resource, ceremony;得到 federation(订单 ID)、convert(payUrl)等。 | +| 5a | 若 **resource 或 ceremony 为 "GooglePay"**(不区分大小写):调起 Google Play 内购(productId=item.code),成功后用 serverVerificationData 作为 merchant 调用 **POST /v1/payment/googlepay**(merchant, federation, asset),不打开 payUrl。 | +| 5b | 否则:若有 **convert**(payUrl)则用 url_launcher 在外部浏览器打开;无则仅提示订单已创建。 | + +### 4.2 支付方式弹窗 + +- 在 get-payment-methods 成功后,展示 **Select payment method** 弹窗(_PaymentMethodDialog),列表项来自 **renew**(PaymentMethodItem:resource, ceremony, name, icon, recommend 等)。 +- 用户选择一项后关闭弹窗,用选中的 resource、ceremony 调用 createPayment;取消弹窗则不创建订单并清除 loading。 + +### 4.3 选中 Google Pay 时的子流程 + +- 条件:`_isGooglePay(paymentMethod, subPaymentMethod)` 为 true(即 paymentMethod 或 subPaymentMethod 转为小写后等于 `"googlepay"`)。 +- 仅 Android 执行;非 Android 提示 "Google Pay is only available on Android" 并结束。 +- 调用 **GooglePlayPurchaseService.launchPurchaseAndReturnData(productId)**: + - productId 为当前商品的 **code**(helm)。 + - 成功返回 **serverVerificationData**(merchant),失败/取消返回 null。 +- 若有 merchant 且订单 ID(federation)存在,调用 **PaymentApi.googlepay(merchant, federation, asset)**;根据返回提示成功或失败并打点(AdjustEvents)。 + +--- + +## 5. 直接谷歌支付(enable_third_party_payment !== true) + +- 仅 **Android**:调用 **GooglePlayPurchaseService.launchPurchase(item.code)**,不请求 getPaymentMethods / createPayment。 +- 成功/失败通过 SnackBar 与 AdjustEvents 打点;商品 ID 仍为 **item.code**(须与 Play 后台产品 ID 一致)。 +- 非 Android:提示 "Google Pay is only available on Android"。 + +--- + +## 6. 接口与字段速查 + +- **GET /v1/payment/getGooglePayActivities** + Query:sentinel, portal;响应 data.summon / data.cleanse,单项含 helm, warrior, guardian, curriculum, forge 等。 + +- **POST /v1/payment/get-payment-methods** + Body:warrior(activityId), vambrace(可选)。响应 data.renew:每项 resource, ceremony, brigade, greylist, deny 等。 + +- **POST /v1/payment/createPayment** + Query:sentinel, asset(userId)。Body:sentinel, asset, warrior, resource, ceremony(可选)。 + 响应 data:federation(订单ID), convert(payUrl), destroy(状态), handshake, transplant 等。 + +- **POST /v1/payment/googlepay** + Query:sentinel, asset(userId)。Body:merchant(购买凭据 serverVerificationData), federation(订单ID), asset, sample(签名可选)。 + 响应 data:federation, line(状态), awaken(是否加积分) 等。 + +- 请求/响应 V2 加解密与字段映射以 **petsHeroAI_client_guide.md** 为准。 + +--- + +## 7. 代码与文档对应 + +| 功能 | 位置 | +|------|------| +| 支付分支与 Buy 入口 | recharge_screen.dart:_onBuy → enableThirdPartyPayment ? _runThirdPartyPayment : _runGooglePay | +| 第三方:获取支付方式 + 弹窗 | _runThirdPartyPayment:PaymentApi.getPaymentMethods → _PaymentMethodDialog | +| 第三方:创建订单 + Google Pay / 打开链接 | _createOrderAndOpenUrl:createPayment → _isGooglePay ? launchPurchaseAndReturnData + googlepay : launchUrl(convert) | +| 直接谷歌支付 | _runGooglePay → _launchGooglePlayPurchase → GooglePlayPurchaseService.launchPurchase | +| 谷歌内购 + 凭据上报 | google_play_purchase_service.dart:launchPurchaseAndReturnData / launchPurchase;PaymentApi.googlepay | +| 商品未找到排查 | docs/google_pay_product_not_found.md | + +--- + +## 8. 小结 + +- **三方开**:getPaymentMethods → 弹窗选支付方式 → createPayment → 选 Google Pay 则内购 + googlepay 回调,否则打开 payUrl。 +- **三方关**:仅 Android 直接内购(item.code),无 createPayment。 +- **商品 ID**:始终使用接口返回的 **helm**(item.code),须与 Google Play 后台「产品 ID」完全一致,否则会出现「系统无法找到您要购买的商品」。 diff --git a/docs/user_login.md b/docs/user_login.md new file mode 100644 index 0000000..4ac85f8 --- /dev/null +++ b/docs/user_login.md @@ -0,0 +1,124 @@ +# 用户登录流程 + +本文档基于 `docs/petsHeroAI_client_guide.md` 中的登录态接口,整理客户端登录顺序与数据处理要求。 + +--- + +## 1. 整体顺序 + +1. **设备快速登录**:`POST /v1/user/fast_login`,拿到 `userToken` 和用户信息。 +2. **保存登录态与用户信息**:保存 token、积分、userId、头像、昵称、国家码等;**首次登录**记录为注册日期。 +3. **归因上报**:`POST /v1/user/referrer`,将归因数据(如从 Adjust 获取的 digest)上报。 +4. **获取用户通用信息**:`GET /v1/user/common_info`,拉取通用配置并保存(含 `surge` 中 `enable_third_party_payment` 等)。 + +登录成功后,后续所有请求需在 Header 中携带 `knight`(即 `User_token` / userToken)。 + +--- + +## 2. 设备快速登录 `POST /v1/user/fast_login` + +- **鉴权**:需要(Header 需带 `portal`,首次无 `knight` 可空或按文档处理)。 +- **说明**:用设备 ID + 签名做设备登录,返回用户 token 及基础信息。 + +### 请求 + +| 位置 | 字段(映射后) | 说明 | +|--------|----------------|------| +| Query | portal | 应用包名(必填) | +| Query | crest | 渠道号(可选) | +| Query | accolade | 类型(可选) | +| Body | digest | 归因信息,如 utm_source=googleplay(可与 Adjust 一致) | +| Body | resolution | 签名:**MD5(deviceId) 大写 32 位** | +| Body | origin | 设备 ID(deviceId) | + +### 响应(解密后 data / sidekick) + +| 字段(映射后) | 说明 | +|----------------|------| +| reevaluate | **userToken**,后续请求 Header 的 `knight` | +| asset | **userId**,用户 ID | +| reveal | **credit**,积分余额 | +| realm | 头像 URL | +| terminal | 用户名 | +| navigate | 国家代码 | +| surge | 客户端界面配置(extConfig),JSON 字符串,需按需解析 | +| equip | 是否首次注册(firstRegister) | +| 其他 | 见接口文档 sidekick 结构 | + +### 客户端必做 + +- 保存并设置 **userToken**(如写入请求头 `knight`)。 +- 保存并更新 **积分**(reveal)、**用户信息**(userId、头像、昵称、国家码等)。 +- **首次登录**:将该次登录日期记为**注册日期**(用于归因/统计);可本地标记“已注册”避免重复上报。 + +--- + +## 3. 归因上报 `POST /v1/user/referrer` + +- **鉴权**:需要(Header 带 `portal`、`knight`)。 +- **说明**:登录成功后调用,上报归因数据;**digest 从 Adjust 取**。 + +### 请求 + +| 位置 | 字段(映射后) | 说明 | +|--------|----------------|------| +| Query | sentinel | 应用标识(必填) | +| Query | asset | 用户 ID(userId) | +| Query | accolade | 类型(可选) | +| Query | portal | 应用包名(必填) | +| Body | digest | **归因信息,从 Adjust 获取** | +| Body | origin | 设备 ID(deviceId) | + +### 响应 + +- code=0 表示成功;可解析 msg / data 做提示或日志。 +- **调用成功后建议增加日志输出**,便于排查归因是否上报成功。 + +--- + +## 4. 获取用户通用信息 `GET /v1/user/common_info` + +- **鉴权**:需要(Header 带 `portal`、`knight`)。 +- **说明**:登录成功后调用,拉取通用配置并**保存到全局/本地**。 + +### 请求 + +| 位置 | 字段(映射后) | 说明 | +|--------|----------------|------| +| Query | sentinel | 应用标识(必填) | +| Query | asset | 用户 ID(userId) | +| Query | shield / crest / item / origin / gauntlet / portal | 按需传,见接口文档 | + +### 响应(解密后 data / sidekick) + +- 与 fast_login 的 data 结构类似,含 **surge**(extConfig)、积分、用户信息等。 +- **surge**:字符串,为 JSON;需 **先 JSON 解析再使用**。 + - 解析后的对象中,包含 **enable_third_party_payment** 等字段,用于控制第三方支付等能力。 +- 其他字段(reveal、realm、terminal、navigate 等)按需保存到全局变量或状态。 + +### 客户端必做 + +- 调用 common_info 并将结果**保存到全局/状态**。 +- 对 **surge** 做 **JSON decode**,得到对象后读取并保存 **enable_third_party_payment** 等配置。 + +--- + +## 5. 流程小结 + +``` +APP 启动 + → 获取 deviceId,计算 sign = MD5(deviceId) 大写 32 位 + → 可选:从 Adjust 获取归因 digest + → POST /v1/user/fast_login(body: digest, resolution, origin;query: portal, crest, accolade) + → 若成功: + 1. 保存 userToken(knight)、userId(asset)、积分(reveal)、头像/昵称/国家码等 + 2. 若首次登录:记录注册日期并标记已注册 + 3. POST /v1/user/referrer(body: digest 从 Adjust 取, origin;query: sentinel, asset, portal) + → 成功后打日志 + 4. GET /v1/user/common_info(query: sentinel, asset) + → 将结果保存到全局 + → 对 data.surge 做 JSON decode,保存 enable_third_party_payment 等 + → 之后所有请求 Header 带 knight = userToken +``` + +接口的 V2 请求体/响应体、加解密与字段映射以 **petsHeroAI_client_guide.md** 为准。 diff --git a/ios/Flutter/Generated.xcconfig b/ios/Flutter/Generated.xcconfig index b8a8721..bb5ba01 100644 --- a/ios/Flutter/Generated.xcconfig +++ b/ios/Flutter/Generated.xcconfig @@ -4,8 +4,8 @@ FLUTTER_APPLICATION_PATH=/Users/sven/Project/Workplace/app_client COCOAPODS_PARALLEL_CODE_SIGN=true FLUTTER_TARGET=lib/main.dart FLUTTER_BUILD_DIR=build -FLUTTER_BUILD_NAME=1.0.0 -FLUTTER_BUILD_NUMBER=1 +FLUTTER_BUILD_NAME=1.0.2 +FLUTTER_BUILD_NUMBER=3 EXCLUDED_ARCHS[sdk=iphonesimulator*]=i386 EXCLUDED_ARCHS[sdk=iphoneos*]=armv7 DART_OBFUSCATION=false diff --git a/ios/Flutter/flutter_export_environment.sh b/ios/Flutter/flutter_export_environment.sh index 811a0e2..2c8d144 100755 --- a/ios/Flutter/flutter_export_environment.sh +++ b/ios/Flutter/flutter_export_environment.sh @@ -5,8 +5,8 @@ export "FLUTTER_APPLICATION_PATH=/Users/sven/Project/Workplace/app_client" export "COCOAPODS_PARALLEL_CODE_SIGN=true" export "FLUTTER_TARGET=lib/main.dart" export "FLUTTER_BUILD_DIR=build" -export "FLUTTER_BUILD_NAME=1.0.0" -export "FLUTTER_BUILD_NUMBER=1" +export "FLUTTER_BUILD_NAME=1.0.2" +export "FLUTTER_BUILD_NUMBER=3" export "DART_OBFUSCATION=false" export "TRACK_WIDGET_CREATION=true" export "TREE_SHAKE_ICONS=false" diff --git a/ios/Runner/GeneratedPluginRegistrant.m b/ios/Runner/GeneratedPluginRegistrant.m index 20f557a..786c40c 100644 --- a/ios/Runner/GeneratedPluginRegistrant.m +++ b/ios/Runner/GeneratedPluginRegistrant.m @@ -30,6 +30,12 @@ @import image_picker_ios; #endif +#if __has_include() +#import +#else +@import in_app_purchase_storekit; +#endif + #if __has_include() #import #else @@ -42,6 +48,12 @@ @import sqflite_darwin; #endif +#if __has_include() +#import +#else +@import url_launcher_ios; +#endif + #if __has_include() #import #else @@ -54,6 +66,12 @@ @import video_thumbnail; #endif +#if __has_include() +#import +#else +@import webview_flutter_wkwebview; +#endif + @implementation GeneratedPluginRegistrant + (void)registerWithRegistry:(NSObject*)registry { @@ -61,10 +79,13 @@ [FPPDeviceInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPDeviceInfoPlusPlugin"]]; [GalPlugin registerWithRegistrar:[registry registrarForPlugin:@"GalPlugin"]]; [FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]]; + [InAppPurchasePlugin registerWithRegistrar:[registry registrarForPlugin:@"InAppPurchasePlugin"]]; [SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]]; [SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]]; + [URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]]; [FVPVideoPlayerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FVPVideoPlayerPlugin"]]; [VideoThumbnailPlugin registerWithRegistrar:[registry registrarForPlugin:@"VideoThumbnailPlugin"]]; + [WebViewFlutterPlugin registerWithRegistrar:[registry registrarForPlugin:@"WebViewFlutterPlugin"]]; } @end diff --git a/lib/app.dart b/lib/app.dart index 52faccc..34c070f 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -12,6 +12,7 @@ import 'features/home/models/task_item.dart'; import 'features/profile/profile_screen.dart'; import 'features/recharge/recharge_screen.dart'; import 'shared/widgets/bottom_nav_bar.dart'; +import 'shared/tab_selector_scope.dart'; /// Root app widget with navigation class App extends StatefulWidget { @@ -27,33 +28,40 @@ class _AppState extends State { @override Widget build(BuildContext context) { return UserCreditsScope( - child: MaterialApp( - title: 'AI Video App', - theme: AppTheme.light, - debugShowCheckedModeBanner: false, - initialRoute: '/', - builder: (context, child) { - return SafeArea( - top: true, - left: false, - right: false, - bottom: false, - child: child ?? const SizedBox.shrink(), - ); - }, - routes: { - '/': (_) => _MainScaffold( - currentTab: _currentTab, - onTabSelected: (tab) => setState(() => _currentTab = tab), - ), + child: TabSelectorScope( + selectTab: (tab) => setState(() => _currentTab = tab), + child: MaterialApp( + title: 'AI Video App', + theme: AppTheme.light, + debugShowCheckedModeBanner: false, + initialRoute: '/', + builder: (context, child) { + return SafeArea( + top: true, + left: false, + right: false, + bottom: false, + child: child ?? const SizedBox.shrink(), + ); + }, + routes: { + '/': (_) => _MainScaffold( + currentTab: _currentTab, + onTabSelected: (tab) => setState(() => _currentTab = tab), + ), '/recharge': (_) => const RechargeScreen(), '/generate': (ctx) { final task = ModalRoute.of(ctx)?.settings.arguments as TaskItem?; return GenerateVideoScreen(task: task); }, '/progress': (ctx) { - final taskId = ModalRoute.of(ctx)?.settings.arguments; - return GenerateProgressScreen(taskId: taskId); + final args = ModalRoute.of(ctx)?.settings.arguments; + final taskId = args is Map ? args['taskId'] : args; + final imagePath = args is Map ? args['imagePath'] as String? : null; + return GenerateProgressScreen( + taskId: taskId, + imagePath: imagePath, + ); }, '/result': (ctx) { final mediaItem = @@ -61,6 +69,7 @@ class _AppState extends State { return GenerationResultScreen(mediaItem: mediaItem); }, }, + ), ), ); } diff --git a/lib/core/api/api_config.dart b/lib/core/api/api_config.dart index c218f87..0626a09 100644 --- a/lib/core/api/api_config.dart +++ b/lib/core/api/api_config.dart @@ -15,7 +15,8 @@ abstract final class ApiConfig { static const String preBaseUrl = 'https://pre-ai.petsheroai.xyz'; /// 生产环境域名 - static const String prodBaseUrl = 'https://ai.petsheroai.xyz'; + static const String prodBaseUrl = + 'https://pre-ai.petsheroai.xyz'; //https://ai.petsheroai.xyz /// 代理入口路径 static const String proxyPath = '/quester/defender/summoner'; diff --git a/lib/core/api/proxy_client.dart b/lib/core/api/proxy_client.dart index 9089fe2..c473d26 100644 --- a/lib/core/api/proxy_client.dart +++ b/lib/core/api/proxy_client.dart @@ -3,13 +3,165 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; +import '../log/app_logger.dart'; import 'api_config.dart'; import 'api_crypto.dart'; -const _logTag = '[ProxyClient]'; +final _proxyLog = AppLogger('ProxyClient'); + +/// 单条日志最大长度;同一 JSON 分多条时按行切分,保持每行完整从而对齐不变 +const int _maxLogChunk = 1000; + +/// 在 [str] 中从 [start] 起找与 [open] 匹配的括号位置,尊重字符串内不配对。返回匹配的 ] 或 } 的下标,未找到返回 -1。 +int _findMatchingBracket(String str, int start, String open) { + final close = open == '{' ? '}' : ']'; + int depth = 1; + int i = start + 1; + while (i < str.length) { + final c = str[i]; + if (c == '"') { + i = _skipJsonString(str, i); + if (i < 0) return -1; + continue; + } + if (c == open) { + depth++; + } else if (c == close) { + depth--; + if (depth == 0) return i; + } + i++; + } + return -1; +} + +/// 从 [str] 中 [start] 位置的 " 开始,跳过整个 JSON 字符串(处理 \"),返回结束 " 的下一个下标,失败返回 -1。 +int _skipJsonString(String str, int start) { + if (start >= str.length || str[start] != '"') return -1; + int i = start + 1; + while (i < str.length) { + final c = str[i++]; + if (c == '\\') { + if (i < str.length) i++; + continue; + } + if (c == '"') return i; + } + return -1; +} + +/// 长文本按行分块输出,每块不超过 [_maxLogChunk];按行切分保证同一 JSON 分多条时缩进对齐。 +void _logLong(String text) { + if (text.isEmpty) return; + final lines = text.split('\n'); + if (lines.length == 1 && text.length <= _maxLogChunk) { + _proxyLog.d(text); + return; + } + final buffer = StringBuffer(); + int chunkIndex = 0; + for (final line in lines) { + final lineWithNewline = buffer.isEmpty ? line : '\n$line'; + if (buffer.length + lineWithNewline.length > _maxLogChunk && buffer.isNotEmpty) { + chunkIndex++; + _proxyLog.d('(part $chunkIndex)\n$buffer'); + buffer.clear(); + buffer.write(line); + } else { + if (buffer.isNotEmpty) buffer.write('\n'); + buffer.write(line); + } + } + if (buffer.isNotEmpty) { + chunkIndex++; + _proxyLog.d(chunkIndex > 1 ? '(part $chunkIndex)\n$buffer' : buffer.toString()); + } +} + +/// 遇到 { 或 [ 视为 JSON 开始,直到与之匹配的 } 或 ] 结束;格式化后单条不超过 1000 字,分条时按行切分保持对齐。 +void logWithEmbeddedJson(Object? msg) { + if (!kDebugMode) return; + + if (msg is! String) { + _proxyLog.d(msg); + return; + } + + final String str = msg.trim(); + final sb = StringBuffer(); + + void write(String line) { + sb.writeln(line); + } + + void printJson(dynamic value, [int indent = 0]) { + final pad = ' ' * indent; + final padInner = ' ' * (indent + 1); + if (value is Map) { + write('$pad{'); + value.forEach((k, v) { + if (v is Map || v is List) { + write('$padInner"$k":'); + printJson(v, indent + 1); + } else { + write('$padInner"$k": ${json.encode(v)}'); + } + }); + write('$pad}'); + } else if (value is List) { + write('$pad['); + for (final item in value) { + printJson(item, indent + 1); + } + write('$pad]'); + } else { + write('$pad${json.encode(value)}'); + } + } + + int i = 0; + int lastEnd = 0; + + while (i < str.length) { + final c = str[i]; + if (c == '"') { + final next = _skipJsonString(str, i); + if (next > 0) { + i = next; + continue; + } + i++; + continue; + } + if (c == '{' || c == '[') { + final end = _findMatchingBracket(str, i, c); + if (end >= 0) { + final prefix = str.substring(lastEnd, i).trim(); + if (prefix.isNotEmpty) write(prefix); + final jsonStr = str.substring(i, end + 1); + try { + final parsed = json.decode(jsonStr); + printJson(parsed, 0); + } catch (e) { + write(jsonStr); + } + lastEnd = end + 1; + i = end + 1; + continue; + } + } + i++; + } + + final trailing = str.substring(lastEnd).trim(); + if (trailing.isNotEmpty) write(trailing); + + final out = sb.toString().trim(); + if (out.isNotEmpty) _logLong(out); +} void _log(String msg) { - if (kDebugMode) debugPrint('$_logTag $msg'); + logWithEmbeddedJson(msg); } /// 代理请求体字段名(统一请求参数) diff --git a/lib/core/api/services/payment_api.dart b/lib/core/api/services/payment_api.dart index 0017f4b..2c7ad06 100644 --- a/lib/core/api/services/payment_api.dart +++ b/lib/core/api/services/payment_api.dart @@ -44,12 +44,28 @@ abstract final class PaymentApi { ); } + /// 获取支付方式列表(传 activityId) + static Future getPaymentMethods({ + required String warrior, + String? vambrace, + }) async { + return _client.request( + path: '/v1/payment/get-payment-methods', + method: 'POST', + body: { + 'warrior': warrior, + if (vambrace != null && vambrace.isNotEmpty) 'vambrace': vambrace, + }, + ); + } + /// 创建支付订单 static Future createPayment({ required String sentinel, required String asset, required String warrior, required String resource, + String? ceremony, String? lineage, String? armor, }) async { @@ -65,9 +81,33 @@ abstract final class PaymentApi { 'asset': asset, 'warrior': warrior, 'resource': resource, + if (ceremony != null && ceremony.isNotEmpty) 'ceremony': ceremony, if (lineage != null) 'lineage': lineage, if (armor != null) 'armor': armor, }, ); } + + /// Google 支付结果回调(凭据、订单 ID、用户 ID) + static Future googlepay({ + required String merchant, + required String federation, + required String asset, + String? sample, + }) async { + return _client.request( + path: '/v1/payment/googlepay', + method: 'POST', + queryParams: { + 'sentinel': ApiConfig.appId, + 'asset': asset, + }, + body: { + 'merchant': merchant, + 'federation': federation, + 'asset': asset, + if (sample != null && sample.isNotEmpty) 'sample': sample, + }, + ); + } } diff --git a/lib/core/api/services/user_api.dart b/lib/core/api/services/user_api.dart index 332c851..b7e7ef8 100644 --- a/lib/core/api/services/user_api.dart +++ b/lib/core/api/services/user_api.dart @@ -31,6 +31,31 @@ abstract final class UserApi { ); } + /// 归因上报(登录成功后调用,digest 建议从 Adjust 获取,暂无则用 install referrer) + static Future referrer({ + required String sentinel, + required String asset, + required String digest, + required String origin, + String? accolade, + String? portal, + }) async { + return _client.request( + path: '/v1/user/referrer', + method: 'POST', + queryParams: { + 'sentinel': sentinel, + 'asset': asset, + if (accolade != null) 'accolade': accolade, + 'portal': portal ?? ApiConfig.packageName, + }, + body: { + 'digest': digest, + 'origin': origin, + }, + ); + } + /// 获取用户通用信息 static Future getCommonInfo({ required String sentinel, diff --git a/lib/core/auth/auth_service.dart b/lib/core/auth/auth_service.dart index 808cda6..23a6ea0 100644 --- a/lib/core/auth/auth_service.dart +++ b/lib/core/auth/auth_service.dart @@ -8,8 +8,10 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../adjust/adjust_events.dart'; import '../api/api_client.dart'; +import '../api/api_config.dart'; import '../api/proxy_client.dart'; import '../api/services/user_api.dart'; +import '../log/app_logger.dart'; import '../referrer/referrer_service.dart'; import '../user/user_state.dart'; @@ -17,16 +19,15 @@ import '../user/user_state.dart'; class AuthService { AuthService._(); - static const _tag = '[AuthService]'; + static final _log = AppLogger('AuthService'); static Future? _loginFuture; /// 登录完成后的 Future,需鉴权接口应 await 此 Future 再请求 - static Future get loginComplete => - _loginFuture ?? Future.value(); + static Future get loginComplete => _loginFuture ?? Future.value(); - static void _log(String msg) { - debugPrint('$_tag $msg'); + static void _logMsg(String msg) { + _log.d(msg); } /// 获取设备 ID(Android: androidId, iOS: identifierForVendor, Web: fallback) @@ -51,6 +52,31 @@ class AuthService { return digest.toString().toUpperCase(); } + /// 将 common_info 响应保存到全局,并解析 surge 中的 enable_third_party_payment + static void _saveCommonInfoToState(Map data) { + final reveal = data['reveal'] as int?; + if (reveal != null) UserState.setCredits(reveal); + final realm = data['realm'] as String?; + if (realm != null && realm.isNotEmpty) UserState.setAvatar(realm); + final terminal = data['terminal'] as String?; + if (terminal != null && terminal.isNotEmpty) UserState.setUserName(terminal); + final navigate = data['navigate'] as String?; + if (navigate != null) UserState.setNavigate(navigate); + + final surgeStr = data['surge'] as String?; + if (surgeStr != null && surgeStr.isNotEmpty) { + try { + final surge = json.decode(surgeStr) as Map?; + if (surge != null) { + final enable = surge['enable_third_party_payment'] as bool?; + UserState.setEnableThirdPartyPayment(enable); + } + } catch (e) { + _logMsg('surge JSON 解析失败: $e'); + } + } + } + /// APP 启动时调用快速登录 /// 启动时网络可能未就绪,会延迟后重试 static Future init() async { @@ -58,7 +84,7 @@ class AuthService { final completer = Completer(); _loginFuture = completer.future; - _log('init: 开始快速登录'); + _logMsg('init: 开始快速登录'); const maxRetries = 3; const retryDelay = Duration(seconds: 2); @@ -67,20 +93,20 @@ class AuthService { await Future.delayed(const Duration(seconds: 2)); final deviceId = await _getDeviceId(); - _log('init: deviceId=$deviceId'); + _logMsg('init: deviceId=$deviceId'); final sign = _computeSign(deviceId); - _log('init: sign=$sign'); + _logMsg('init: sign=$sign'); final crest = await ReferrerService.getReferrer(); if (crest != null && crest.isNotEmpty) { - _log('init: crest(referrer)=$crest'); + _logMsg('init: crest(referrer)=$crest'); } ApiResponse? res; for (var i = 0; i < maxRetries; i++) { if (i > 0) { - _log('init: 第 ${i + 1} 次重试,等待 ${retryDelay.inSeconds}s...'); + _logMsg('init: 第 ${i + 1} 次重试,等待 ${retryDelay.inSeconds}s...'); await Future.delayed(retryDelay); } try { @@ -92,23 +118,23 @@ class AuthService { ); break; } catch (e) { - _log('init: 第 ${i + 1} 次请求失败: $e'); + _logMsg('init: 第 ${i + 1} 次请求失败: $e'); if (i == maxRetries - 1) rethrow; } } if (res == null) return; - _log('init: 登录结果 code=${res.code} msg=${res.msg}'); + _logMsg('init: 登录结果 code=${res.code} msg=${res.msg}'); if (res.isSuccess && res.data != null) { final data = res.data as Map?; final token = data?['reevaluate'] as String?; if (token != null && token.isNotEmpty) { ApiClient.instance.setUserToken(token); - _log('init: 已设置 userToken'); + _logMsg('init: 已设置 userToken'); } else { - _log('init: 响应中无 reevaluate (userToken)'); + _logMsg('init: 响应中无 reevaluate (userToken)'); } final prefs = await SharedPreferences.getInstance(); final hadLoggedIn = prefs.getBool('adjust_has_logged_in') ?? false; @@ -119,17 +145,17 @@ class AuthService { 'adjust_register_date', DateTime.now().toIso8601String().substring(0, 10), ); - _log('init: 首次登录,已上报 register'); + _logMsg('init: 首次登录,已上报 register'); } final credits = data?['reveal'] as int?; if (credits != null) { UserState.setCredits(credits); - _log('init: 已同步积分 $credits'); + _logMsg('init: 已同步积分 $credits'); } final uid = data?['asset'] as String?; if (uid != null && uid.isNotEmpty) { UserState.setUserId(uid); - _log('init: 已设置 userId'); + _logMsg('init: 已设置 userId'); } final avatarUrl = data?['realm'] as String?; if (avatarUrl != null && avatarUrl.isNotEmpty) { @@ -143,12 +169,52 @@ class AuthService { if (countryCode != null) { UserState.setNavigate(countryCode); } + + // 3. 归因上报(digest 从 Adjust 取,暂无则用 install referrer) + try { + final referrerRes = await UserApi.referrer( + sentinel: ApiConfig.appId, + asset: uid!, + digest: crest ?? '', + origin: deviceId, + ); + if (referrerRes.isSuccess) { + _logMsg('referrer 上报成功'); + } else { + _logMsg( + 'referrer 上报失败: code=${referrerRes.code} msg=${referrerRes.msg}'); + } + } catch (e) { + _logMsg('referrer 请求异常: $e'); + } + + // 4. 获取用户通用信息,保存到全局并解析 surge + try { + final commonRes = await UserApi.getCommonInfo( + sentinel: ApiConfig.appId, + asset: uid, + ); + if (commonRes.isSuccess && commonRes.data != null) { + final commonData = commonRes.data as Map?; + if (commonData != null) { + _saveCommonInfoToState(commonData); + _logMsg('common_info 已保存到全局'); + } + _logMsg('common_info 响应:'); + logWithEmbeddedJson(json.encode(commonRes.data)); + } else { + _logMsg( + 'common_info 失败: code=${commonRes.code} msg=${commonRes.msg}'); + } + } catch (e) { + _logMsg('common_info 请求异常: $e'); + } } else { - _log('init: 登录失败'); + _logMsg('init: 登录失败'); } } catch (e, st) { - _log('init: 异常 $e'); - _log('init: 堆栈 $st'); + _logMsg('init: 异常 $e'); + _logMsg('init: 堆栈 $st'); } finally { if (!completer.isCompleted) completer.complete(); } diff --git a/lib/core/log/app_logger.dart b/lib/core/log/app_logger.dart new file mode 100644 index 0000000..c29d58d --- /dev/null +++ b/lib/core/log/app_logger.dart @@ -0,0 +1,40 @@ +import 'package:flutter/foundation.dart'; +import 'package:logger/logger.dart'; + +/// 统一应用日志,提升可读性:时间戳、级别、标签、格式化输出。 +/// 在 release 下仅输出 warning/error,避免泄露信息。 +/// +/// 使用示例: +/// final _log = AppLogger('GenerateVideo'); +/// _log.d('task: $task'); +/// _log.e('Generate failed', e, st); +class AppLogger { + AppLogger([this.tag = 'App']); + + final String tag; + + static Logger? _logger; + + static Logger get _instance { + _logger ??= Logger( + printer: PrettyPrinter( + methodCount: 0, + errorMethodCount: 6, + lineLength: 80, + colors: true, + printEmojis: true, + dateTimeFormat: DateTimeFormat.onlyTimeAndSinceStart, + ), + level: kReleaseMode ? Level.warning : Level.trace, + ); + return _logger!; + } + + String _msg(Object? message) => '[$tag] $message'; + + void d(Object? message) => _instance.d(_msg(message)); + void i(Object? message) => _instance.i(_msg(message)); + void w(Object? message) => _instance.w(_msg(message)); + void e(Object? message, [Object? error, StackTrace? stackTrace]) => + _instance.e(_msg(message), error: error, stackTrace: stackTrace); +} diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index f554d1d..96e2ae1 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -27,4 +27,10 @@ abstract final class AppColors { static const Color shadowLight = Color(0x14000000); // #00000008 static const Color shadowMedium = Color(0x0D000000); // #0000000D static const Color shadowSoft = Color(0x0A000000); // #0000000A + + // Card overlays (from Pencil) + static const Color playButtonFill = Color(0xE6FFFFFF); // #FFFFFFE6 + static const Color playButtonShadow = Color(0x20000000); // #00000020 + static const Color primaryButtonFill = Color(0xCC8B5CF6); // #8B5CF6CC + static const Color primaryButtonShadow = Color(0x408B5CF6); // #8B5CF640 } diff --git a/lib/core/user/user_state.dart b/lib/core/user/user_state.dart index bcc9a81..d2680f2 100644 --- a/lib/core/user/user_state.dart +++ b/lib/core/user/user_state.dart @@ -10,6 +10,9 @@ class UserState { static final ValueNotifier userName = ValueNotifier(null); /// 国家码 (navigate / countryCode) static final ValueNotifier navigate = ValueNotifier(null); + /// 是否启用第三方支付(来自 common_info surge.enable_third_party_payment) + static final ValueNotifier enableThirdPartyPayment = + ValueNotifier(null); static void setCredits(int? value) { credits.value = value; @@ -31,6 +34,10 @@ class UserState { navigate.value = value; } + static void setEnableThirdPartyPayment(bool? value) { + enableThirdPartyPayment.value = value; + } + static String formatCredits(int? value) { if (value == null) return '--'; return value.toString().replaceAllMapped( diff --git a/lib/features/generate_video/generate_progress_screen.dart b/lib/features/generate_video/generate_progress_screen.dart index 507a920..0a60d57 100644 --- a/lib/features/generate_video/generate_progress_screen.dart +++ b/lib/features/generate_video/generate_progress_screen.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_lucide/flutter_lucide.dart'; @@ -12,28 +13,88 @@ import '../../core/user/user_state.dart'; import '../../shared/widgets/top_nav_bar.dart'; import '../../core/api/services/image_api.dart'; +import '../../core/api/services/user_api.dart'; +import '../../features/gallery/models/gallery_task_item.dart'; +import '../../shared/tab_selector_scope.dart'; +import '../../shared/widgets/bottom_nav_bar.dart'; -/// Generate Video Progress screen - matches Pencil qGs6n +/// Progress states: 1=队列中 2=处理中 3=完成 4=超时 5=错误 6=中止 +/// Progress bar has 3 stages; states 3–6 are stage 3. +const _stateLabels = { + 1: 'Queued', + 2: 'Processing', + 3: 'Completed', + 4: 'Timeout', + 5: 'Error', + 6: 'Aborted', +}; + +/// Stage progress: 1 -> 1/3, 2 -> 2/3, 3..6 -> 1.0 +double _progressForState(int? state) { + if (state == null) return 0; + if (state == 1) return 1 / 3; + if (state == 2) return 2 / 3; + return 1.0; // 3, 4, 5, 6 +} + +/// Build GalleryMediaItem from /v1/image/progress response (data = sidekick). +/// curate[].reconfigure = result URL; for img2video use as videoUrl. +GalleryMediaItem? _mediaItemFromProgressData(Map data) { + final curate = data['curate'] as List?; + if (curate == null || curate.isEmpty) return null; + final first = curate.first; + if (first is! Map) return null; + final reconfigure = first['reconfigure'] as String?; + final digitize = first['digitize'] as String?; + final videoUrl = reconfigure?.isNotEmpty == true + ? reconfigure + : digitize?.isNotEmpty == true + ? digitize + : null; + final imageUrl = digitize?.isNotEmpty == true ? digitize : null; + if (videoUrl != null) { + return GalleryMediaItem(videoUrl: videoUrl, imageUrl: imageUrl); + } + if (imageUrl != null) { + return GalleryMediaItem(imageUrl: imageUrl); + } + return null; +} + +/// Generate Video Progress screen - matches Pencil rSN3T (video), Eghqc (label) class GenerateProgressScreen extends StatefulWidget { - const GenerateProgressScreen({super.key, this.taskId}); + const GenerateProgressScreen({super.key, this.taskId, this.imagePath}); final dynamic taskId; + final String? imagePath; @override State createState() => _GenerateProgressScreenState(); } class _GenerateProgressScreenState extends State { - double _progress = 0; + int? _state; Timer? _pollTimer; + double get _progress => _progressForState(_state); + + void _onBack(BuildContext context) { + final completed = _state == 3; + if (!completed) { + TabSelectorScope.maybeOf(context)?.selectTab(NavTab.gallery); + Navigator.of(context).popUntil(ModalRoute.withName('/')); + } else { + Navigator.of(context).pop(); + } + } + @override void initState() { super.initState(); if (widget.taskId != null) { _startPolling(); } else { - _progress = 0.45; + _state = 2; } } @@ -45,8 +106,10 @@ class _GenerateProgressScreenState extends State { Future _startPolling() async { await AuthService.loginComplete; - - _pollTimer = Timer.periodic(const Duration(seconds: 2), (_) => _fetchProgress()); + _pollTimer = Timer.periodic( + const Duration(seconds: 1), + (_) => _fetchProgress(), + ); _fetchProgress(); } @@ -63,32 +126,53 @@ class _GenerateProgressScreenState extends State { if (!res.isSuccess || res.data == null) return; final data = res.data as Map; - final progress = (data['dice'] as num?)?.toInt() ?? 0; final state = (data['listing'] as num?)?.toInt(); if (!mounted) return; - setState(() { - _progress = progress / 100.0; - }); + setState(() => _state = state); - if (progress >= 100 || state == 2) { - _pollTimer?.cancel(); - Navigator.of(context).pushReplacementNamed('/result', arguments: widget.taskId); - } else if (state == 3) { - _pollTimer?.cancel(); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Generation failed'), - behavior: SnackBarBehavior.floating, - ), - ); + switch (state) { + case 3: // 完成 + _pollTimer?.cancel(); + // 生成成功后同步更新积分 + final userId = UserState.userId.value; + if (userId != null && userId.isNotEmpty) { + try { + final accountRes = await UserApi.getAccount( + sentinel: ApiConfig.appId, + asset: userId, + ); + if (accountRes.isSuccess && accountRes.data != null) { + final accountData = + accountRes.data as Map?; + final credits = accountData?['reveal'] as int?; + if (credits != null) { + UserState.setCredits(credits); + } + } + } catch (_) {} + } + if (!mounted) return; + final mediaItem = _mediaItemFromProgressData(data); + Navigator.of(context).pushReplacementNamed( + '/result', + arguments: mediaItem, + ); + break; + case 4: + case 5: + case 6: // 超时 / 错误 / 中止 + _pollTimer?.cancel(); + break; } } catch (_) {} } @override Widget build(BuildContext context) { + final labelText = _stateLabels[_state] ?? '队列中'; + return Scaffold( backgroundColor: AppColors.background, appBar: PreferredSize( @@ -96,7 +180,7 @@ class _GenerateProgressScreenState extends State { child: TopNavBar( title: 'Generating', showBackButton: true, - onBack: () => Navigator.of(context).pop(), + onBack: () => _onBack(context), ), ), body: SingleChildScrollView( @@ -104,7 +188,7 @@ class _GenerateProgressScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _VideoPreview(), + _VideoPreview(imagePath: widget.imagePath), const SizedBox(height: AppSpacing.xxl), Text( 'Video generation may take some time. Please wait patiently.', @@ -114,7 +198,7 @@ class _GenerateProgressScreenState extends State { ), ), const SizedBox(height: AppSpacing.xxl), - _ProgressSection(progress: _progress), + _ProgressSection(progress: _progress, label: labelText), ], ), ), @@ -122,51 +206,70 @@ class _GenerateProgressScreenState extends State { } } +/// Video area rSN3T: show uploaded image with crop (BoxFit.cover) class _VideoPreview extends StatelessWidget { + const _VideoPreview({this.imagePath}); + + final String? imagePath; + @override Widget build(BuildContext context) { + final hasImage = + imagePath != null && imagePath!.isNotEmpty && File(imagePath!).existsSync(); + return Container( height: 360, decoration: BoxDecoration( - color: AppColors.textPrimary, + color: AppColors.surfaceAlt, borderRadius: BorderRadius.circular(16), border: Border.all(color: AppColors.border, width: 1), ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - LucideIcons.film, - size: 64, - color: AppColors.textSecondary, - ), - const SizedBox(height: AppSpacing.lg), - Text( - 'Video Preview', - style: AppTypography.bodyRegular.copyWith( - color: AppColors.textSecondary, + clipBehavior: Clip.antiAlias, + child: hasImage + ? Image.file( + File(imagePath!), + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + ) + : Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + LucideIcons.film, + size: 64, + color: AppColors.textSecondary, + ), + const SizedBox(height: AppSpacing.lg), + Text( + 'Video Preview', + style: AppTypography.bodyRegular.copyWith( + color: AppColors.textSecondary, + ), + ), + ], ), - ), - ], - ), ); } } +/// Progress bar: 3 stages. Label Eghqc shows state (队列中|处理中|完成|超时|错误|中止) class _ProgressSection extends StatelessWidget { - const _ProgressSection({required this.progress}); + const _ProgressSection({required this.progress, required this.label}); final double progress; + final String label; @override Widget build(BuildContext context) { - final percentage = (progress * 100).round(); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( - 'Generating... $percentage%', + label, style: AppTypography.bodyMedium.copyWith( + fontSize: 16, + fontWeight: FontWeight.w600, color: AppColors.textPrimary, ), ), diff --git a/lib/features/generate_video/generate_video_screen.dart b/lib/features/generate_video/generate_video_screen.dart index 7555804..5917979 100644 --- a/lib/features/generate_video/generate_video_screen.dart +++ b/lib/features/generate_video/generate_video_screen.dart @@ -1,13 +1,15 @@ -import 'dart:developer' as developer; - import 'dart:io'; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_lucide/flutter_lucide.dart'; import 'package:http/http.dart' as http; import 'package:image_picker/image_picker.dart'; +import 'package:video_player/video_player.dart'; import '../../core/auth/auth_service.dart'; +import '../../core/log/app_logger.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_spacing.dart'; import '../../core/theme/app_typography.dart'; @@ -23,6 +25,8 @@ import '../../core/api/services/user_api.dart'; class GenerateVideoScreen extends StatefulWidget { const GenerateVideoScreen({super.key, this.task}); + static final _log = AppLogger('GenerateVideo'); + final TaskItem? task; @override @@ -32,7 +36,6 @@ class GenerateVideoScreen extends StatefulWidget { enum _Resolution { p480, p720 } class _GenerateVideoScreenState extends State { - File? _selectedImage; _Resolution _selectedResolution = _Resolution.p480; bool _isGenerating = false; @@ -54,15 +57,15 @@ class _GenerateVideoScreenState extends State { void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { - developer.log( - 'GenerateVideoScreen opened with task: ${widget.task}', - name: 'GenerateVideoScreen', - ); - debugPrint('[GenerateVideoScreen] task: ${widget.task}'); + GenerateVideoScreen._log.d('opened with task: ${widget.task}'); }); } - Future _showImageSourcePicker() async { + /// Click flow per docs/generate_video.md: tap Generate Video -> image picker + /// (camera or gallery) -> after image selected -> proceed to API. + Future _onGenerateButtonTap() async { + if (_isGenerating) return; + final source = await showModalBottomSheet( context: context, builder: (context) => SafeArea( @@ -83,43 +86,25 @@ class _GenerateVideoScreenState extends State { ), ), ); - if (source != null && mounted) { - _pickImage(source); - } - } + if (source == null || !mounted) return; - Future _pickImage(ImageSource source) async { final picker = ImagePicker(); final picked = await picker.pickImage( source: source, imageQuality: 85, ); - if (picked != null && mounted) { - setState(() { - _selectedImage = File(picked.path); - }); - } + if (picked == null || !mounted) return; + + final file = File(picked.path); + await _runGenerationApi(file); } - Future _onGenerate() async { - if (_selectedImage == null) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Please select an image first'), - behavior: SnackBarBehavior.floating, - ), - ); - } - return; - } - + Future _runGenerationApi(File file) async { setState(() => _isGenerating = true); try { await AuthService.loginComplete; - final file = _selectedImage!; final size = await file.length(); final ext = file.path.split('.').last.toLowerCase(); final contentType = ext == 'png' @@ -212,11 +197,12 @@ class _GenerateVideoScreenState extends State { } if (!mounted) return; - Navigator.of(context) - .pushReplacementNamed('/progress', arguments: taskId); + Navigator.of(context).pushReplacementNamed( + '/progress', + arguments: {'taskId': taskId, 'imagePath': file.path}, + ); } catch (e, st) { - developer.log('Generate failed: $e', name: 'GenerateVideoScreen'); - debugPrint('[GenerateVideoScreen] error: $e\n$st'); + GenerateVideoScreen._log.e('Generate failed', e, st); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -247,32 +233,61 @@ class _GenerateVideoScreenState extends State { onCreditsTap: () => Navigator.of(context).pushNamed('/recharge'), ), ), - body: SingleChildScrollView( - padding: const EdgeInsets.all(AppSpacing.screenPaddingLarge), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _CreditsCard( - credits: UserCreditsData.of(context)?.creditsDisplay ?? '--', + body: Column( + children: [ + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB( + AppSpacing.screenPaddingLarge, + AppSpacing.screenPaddingLarge, + AppSpacing.screenPaddingLarge, + AppSpacing.lg, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _CreditsCard( + credits: + UserCreditsData.of(context)?.creditsDisplay ?? '--', + ), + const SizedBox(height: AppSpacing.xxl), + _VideoPreviewArea( + videoUrl: widget.task?.previewVideoUrl, + imageUrl: widget.task?.previewImageUrl, + ), + ], + ), ), - const SizedBox(height: AppSpacing.xxl), - _UploadArea( - selectedImage: _selectedImage, - onUpload: _showImageSourcePicker, + ), + SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.screenPaddingLarge, + AppSpacing.lg, + AppSpacing.screenPaddingLarge, + AppSpacing.screenPaddingLarge, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _ResolutionToggle( + selected: _selectedResolution, + onChanged: (r) => + setState(() => _selectedResolution = r), + ), + const SizedBox(height: AppSpacing.xxl), + _GenerateButton( + onGenerate: _onGenerateButtonTap, + isLoading: _isGenerating, + credits: _currentCredits.toString(), + ), + ], + ), ), - const SizedBox(height: AppSpacing.xxl), - _ResolutionToggle( - selected: _selectedResolution, - onChanged: (r) => setState(() => _selectedResolution = r), - ), - const SizedBox(height: AppSpacing.xxl), - _GenerateButton( - onGenerate: _onGenerate, - isLoading: _isGenerating, - credits: _currentCredits.toString(), - ), - ], - ), + ), + ], ), ); } @@ -333,63 +348,176 @@ class _CreditsCard extends StatelessWidget { } } -class _UploadArea extends StatelessWidget { - const _UploadArea({ - required this.selectedImage, - required this.onUpload, +/// Video preview area - video URL from card click. Auto-load and play on init. +/// Video fit: contain (no crop). Loading animation until ready. +class _VideoPreviewArea extends StatefulWidget { + const _VideoPreviewArea({ + this.videoUrl, + this.imageUrl, }); - final File? selectedImage; - final VoidCallback onUpload; + final String? videoUrl; + final String? imageUrl; + + @override + State<_VideoPreviewArea> createState() => _VideoPreviewAreaState(); +} + +class _VideoPreviewAreaState extends State<_VideoPreviewArea> { + VideoPlayerController? _controller; + + @override + void initState() { + super.initState(); + if (widget.videoUrl != null && widget.videoUrl!.isNotEmpty) { + _loadAndPlay(); + } + } + + @override + void dispose() { + _controller?.dispose(); + _controller = null; + super.dispose(); + } + + @override + void didUpdateWidget(covariant _VideoPreviewArea oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.videoUrl != widget.videoUrl) { + _controller?.dispose(); + _controller = null; + if (widget.videoUrl != null && widget.videoUrl!.isNotEmpty) { + _loadAndPlay(); + } + } + } + + Future _loadAndPlay() async { + final url = widget.videoUrl; + if (url == null || url.isEmpty) return; + + setState(() {}); + + try { + final file = await DefaultCacheManager().getSingleFile(url); + if (!mounted) return; + final controller = VideoPlayerController.file(file); + await controller.initialize(); + if (!mounted) return; + await controller.play(); + controller.setLooping(true); + if (mounted) { + setState(() { + _controller = controller; + }); + } + } catch (e) { + GenerateVideoScreen._log.e('Video load failed', e); + if (mounted) setState(() {}); + } + } @override Widget build(BuildContext context) { - return GestureDetector( - onTap: onUpload, - child: Container( - height: 280, - decoration: BoxDecoration( - color: AppColors.surfaceAlt, - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: AppColors.border, - width: 2, + final isReady = + _controller != null && _controller!.value.isInitialized; + final hasVideo = widget.videoUrl != null && widget.videoUrl!.isNotEmpty; + final hasImage = + widget.imageUrl != null && widget.imageUrl!.isNotEmpty; + + // Aspect ratio: from video when ready, else 16:9 placeholder + final aspectRatio = isReady && + _controller!.value.size.width > 0 && + _controller!.value.size.height > 0 + ? _controller!.value.size.width / _controller!.value.size.height + : 16 / 9; + + return LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + final height = width / aspectRatio; + + return Container( + width: width, + height: height, + decoration: BoxDecoration( + color: AppColors.surfaceAlt, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: AppColors.border, + width: 1, + ), ), - ), - clipBehavior: Clip.antiAlias, - child: selectedImage != null - ? Image.file( - selectedImage!, - fit: BoxFit.cover, - width: double.infinity, - height: double.infinity, - ) - : Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - LucideIcons.image_plus, - size: 48, - color: AppColors.textMuted, - ), - const SizedBox(height: AppSpacing.lg), - SizedBox( - width: 280, - child: Text( - 'Please upload an image as the base for generation', - textAlign: TextAlign.center, - style: AppTypography.bodyRegular.copyWith( - color: AppColors.textSecondary, - ), + clipBehavior: Clip.antiAlias, + child: Stack( + fit: StackFit.expand, + alignment: Alignment.center, + children: [ + if (isReady) + SizedBox.expand( + child: FittedBox( + fit: BoxFit.contain, + child: SizedBox( + width: _controller!.value.size.width, + height: _controller!.value.size.height, + child: VideoPlayer(_controller!), ), ), - ], - ), - ), + ) + else if (hasImage) + CachedNetworkImage( + imageUrl: widget.imageUrl!, + fit: BoxFit.contain, + width: double.infinity, + height: double.infinity, + placeholder: (_, __) => _LoadingOverlay(isLoading: true), + errorWidget: (_, __, ___) => _LoadingOverlay(isLoading: false), + ) + else + _LoadingOverlay(isLoading: false), + if (hasVideo && !isReady) + Positioned.fill( + child: _LoadingOverlay(isLoading: true), + ), + ], + ), + ); + }, ); } } +class _LoadingOverlay extends StatelessWidget { + const _LoadingOverlay({this.isLoading = true}); + + final bool isLoading; + + @override + Widget build(BuildContext context) { + return Container( + color: AppColors.surfaceAlt, + alignment: Alignment.center, + child: isLoading + ? const SizedBox( + width: 40, + height: 40, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.primary, + ), + ) + : Icon( + LucideIcons.video, + size: 48, + color: AppColors.textMuted, + ), + ); + } +} + +/// Resolution row - 1:1 match Pencil oYwsm: label + 480P/720P on same row. +/// Button: height 36, cornerRadius 18, padding 8x16, gap 12 label-to-btns, gap 8 between btns. class _ResolutionToggle extends StatelessWidget { const _ResolutionToggle({ required this.selected, @@ -401,70 +529,82 @@ class _ResolutionToggle extends StatelessWidget { @override Widget build(BuildContext context) { - return Row( - children: [ - Expanded( - child: GestureDetector( + return SizedBox( + height: 44, + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + 'Resolution', + style: AppTypography.bodyMedium.copyWith( + fontSize: 14, + color: AppColors.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 12), + _ResolutionOption( + label: '480P', + isSelected: selected == _Resolution.p480, onTap: () => onChanged(_Resolution.p480), - child: Container( - height: 48, - decoration: BoxDecoration( - color: selected == _Resolution.p480 - ? AppColors.primary - : AppColors.surfaceAlt, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: selected == _Resolution.p480 - ? AppColors.primary.withValues(alpha: 0.5) - : AppColors.border, - width: selected == _Resolution.p480 ? 1 : 2, - ), - ), - alignment: Alignment.center, - child: Text( - '480P', - style: AppTypography.bodyMedium.copyWith( - color: selected == _Resolution.p480 - ? AppColors.surface - : AppColors.textSecondary, - fontWeight: FontWeight.w600, - ), - ), - ), ), - ), - const SizedBox(width: 12), - Expanded( - child: GestureDetector( + const SizedBox(width: 8), + _ResolutionOption( + label: '720P', + isSelected: selected == _Resolution.p720, onTap: () => onChanged(_Resolution.p720), - child: Container( - height: 48, - decoration: BoxDecoration( - color: selected == _Resolution.p720 - ? AppColors.primary - : AppColors.surfaceAlt, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: selected == _Resolution.p720 - ? AppColors.primary.withValues(alpha: 0.5) - : AppColors.border, - width: selected == _Resolution.p720 ? 1 : 2, - ), - ), - alignment: Alignment.center, - child: Text( - '720P', - style: AppTypography.bodyMedium.copyWith( - color: selected == _Resolution.p720 - ? AppColors.surface - : AppColors.textSecondary, - fontWeight: FontWeight.w600, - ), - ), - ), + ), + ], + ), + ); + } +} + +class _ResolutionOption extends StatelessWidget { + const _ResolutionOption({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + height: 36, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: isSelected ? AppColors.primary : AppColors.surfaceAlt, + borderRadius: BorderRadius.circular(18), + border: isSelected + ? null + : Border.all(color: AppColors.border, width: 1), + boxShadow: isSelected + ? [ + BoxShadow( + color: AppColors.primaryShadow.withValues(alpha: 0.19), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ] + : null, + ), + alignment: Alignment.center, + child: Text( + label, + style: AppTypography.bodyMedium.copyWith( + fontSize: 13, + color: isSelected ? AppColors.surface : AppColors.textSecondary, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, ), ), - ], + ), ); } } diff --git a/lib/features/generate_video/generation_result_screen.dart b/lib/features/generate_video/generation_result_screen.dart index 4fdc74f..ac866df 100644 --- a/lib/features/generate_video/generation_result_screen.dart +++ b/lib/features/generate_video/generation_result_screen.dart @@ -85,7 +85,8 @@ class _GenerationResultScreenState extends State { if (_videoUrl != null) { final tempDir = await getTemporaryDirectory(); - final file = File('${tempDir.path}/gallery_video_${DateTime.now().millisecondsSinceEpoch}.mp4'); + final file = File( + '${tempDir.path}/gallery_video_${DateTime.now().millisecondsSinceEpoch}.mp4'); final response = await http.get(Uri.parse(_videoUrl!)); if (response.statusCode != 200) { throw Exception('Failed to download video'); @@ -95,7 +96,8 @@ class _GenerationResultScreenState extends State { await file.delete(); } else if (_imageUrl != null) { final tempDir = await getTemporaryDirectory(); - final file = File('${tempDir.path}/gallery_image_${DateTime.now().millisecondsSinceEpoch}.jpg'); + final file = File( + '${tempDir.path}/gallery_image_${DateTime.now().millisecondsSinceEpoch}.jpg'); final response = await http.get(Uri.parse(_imageUrl!)); if (response.statusCode != 200) { throw Exception('Failed to download image'); @@ -156,9 +158,7 @@ class _GenerationResultScreenState extends State { _DownloadButton( onDownload: _saving ? null : _saveToAlbum, saving: _saving, - ), - const SizedBox(height: AppSpacing.lg), - _ShareButton(onShare: () {}), + ) ], ), ), diff --git a/lib/features/home/widgets/video_card.dart b/lib/features/home/widgets/video_card.dart index f30852d..96656b6 100644 --- a/lib/features/home/widgets/video_card.dart +++ b/lib/features/home/widgets/video_card.dart @@ -5,7 +5,6 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:video_player/video_player.dart'; import '../../../core/theme/app_colors.dart'; -import '../../../core/theme/app_spacing.dart'; /// Video card for home grid - 点击播放按钮可在卡片上播放视频 /// 同时只能一个卡片处于播放状态 @@ -35,7 +34,6 @@ class VideoCard extends StatefulWidget { class _VideoCardState extends State { VideoPlayerController? _controller; - bool _isLoading = false; @override void initState() { @@ -64,9 +62,7 @@ class _VideoCardState extends State { void _stop() { _controller?.removeListener(_onVideoUpdate); _controller?.pause(); - if (mounted) { - setState(() => _isLoading = false); - } + if (mounted) setState(() {}); } void _disposeController() { @@ -85,34 +81,30 @@ class _VideoCardState extends State { final needSeek = _controller!.value.position >= _controller!.value.duration && _controller!.value.duration.inMilliseconds > 0; if (needSeek) { - setState(() => _isLoading = true); await _controller!.seekTo(Duration.zero); } _controller!.addListener(_onVideoUpdate); await _controller!.play(); - if (mounted) setState(() => _isLoading = false); + if (mounted) setState(() {}); return; } - setState(() => _isLoading = true); + setState(() {}); try { final file = await DefaultCacheManager().getSingleFile(widget.videoUrl!); - if (!mounted || !widget.isActive) { - setState(() => _isLoading = false); - return; - } + if (!mounted || !widget.isActive) return; _controller = VideoPlayerController.file(file); await _controller!.initialize(); _controller!.addListener(_onVideoUpdate); if (mounted && widget.isActive) { await _controller!.play(); - setState(() => _isLoading = false); + setState(() {}); } } catch (e) { if (mounted) { _disposeController(); - setState(() => _isLoading = false); + setState(() {}); widget.onStopRequested(); } } @@ -143,7 +135,6 @@ class _VideoCardState extends State { @override Widget build(BuildContext context) { final showVideo = widget.isActive && _controller != null && _controller!.value.isInitialized; - final showLoading = widget.isActive && _isLoading; return LayoutBuilder( builder: (context, constraints) { @@ -151,9 +142,8 @@ class _VideoCardState extends State { width: constraints.maxWidth, height: constraints.maxHeight, decoration: BoxDecoration( - color: AppColors.surfaceAlt, + color: Colors.transparent, borderRadius: BorderRadius.circular(24), - border: Border.all(color: AppColors.border, width: 1), boxShadow: [ BoxShadow( color: AppColors.shadowMedium, @@ -169,37 +159,51 @@ class _VideoCardState extends State { children: [ if (showVideo) Positioned.fill( - child: FittedBox( - fit: BoxFit.cover, - child: SizedBox( - width: _controller!.value.size.width > 0 - ? _controller!.value.size.width - : 16, - height: _controller!.value.size.height > 0 - ? _controller!.value.size.height - : 9, - child: VideoPlayer(_controller!), + child: GestureDetector( + onTap: _onPlayButtonTap, + behavior: HitTestBehavior.opaque, + child: FittedBox( + fit: BoxFit.cover, + child: SizedBox( + width: _controller!.value.size.width > 0 + ? _controller!.value.size.width + : 16, + height: _controller!.value.size.height > 0 + ? _controller!.value.size.height + : 9, + child: VideoPlayer(_controller!), + ), ), ), ) else - CachedNetworkImage( - imageUrl: widget.imageUrl, - fit: BoxFit.cover, - placeholder: (_, __) => Container( - color: AppColors.surfaceAlt, - ), - errorWidget: (_, __, ___) => Container( - color: AppColors.surfaceAlt, + Positioned.fill( + child: GestureDetector( + onTap: widget.videoUrl != null && + widget.videoUrl!.isNotEmpty + ? _onPlayButtonTap + : null, + behavior: HitTestBehavior.opaque, + child: CachedNetworkImage( + imageUrl: widget.imageUrl, + fit: BoxFit.cover, + placeholder: (_, __) => Container( + color: AppColors.surfaceAlt, + ), + errorWidget: (_, __, ___) => Container( + color: AppColors.surfaceAlt, + ), + ), ), ), Positioned( top: 12, right: 12, child: Container( + height: 24, padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: AppSpacing.xs, + horizontal: 8, + vertical: 4, ), decoration: BoxDecoration( color: AppColors.overlayDark, @@ -207,13 +211,14 @@ class _VideoCardState extends State { ), child: Row( mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( LucideIcons.sparkles, size: 12, color: AppColors.surface, ), - const SizedBox(width: AppSpacing.sm), + const SizedBox(width: 4), Text( widget.credits, style: const TextStyle( @@ -227,43 +232,6 @@ class _VideoCardState extends State { ), ), ), - Positioned.fill( - child: Center( - child: GestureDetector( - onTap: widget.videoUrl != null && widget.videoUrl!.isNotEmpty - ? _onPlayButtonTap - : null, - child: Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: AppColors.surface.withValues(alpha: 0.9), - borderRadius: BorderRadius.circular(24), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.13), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], - ), - child: showLoading - ? const Padding( - padding: EdgeInsets.all(12), - child: CircularProgressIndicator( - color: AppColors.textPrimary, - strokeWidth: 2, - ), - ) - : Icon( - _isPlaying ? LucideIcons.pause : LucideIcons.play, - size: 24, - color: AppColors.textPrimary, - ), - ), - ), - ), - ), if (_isPlaying) Positioned( top: 8, @@ -288,33 +256,41 @@ class _VideoCardState extends State { ), ), Positioned( - bottom: 12, + bottom: 16, left: 12, right: 12, - child: GestureDetector( - onTap: widget.onGenerateSimilar, - child: Container( - height: 44, - padding: const EdgeInsets.symmetric(horizontal: 12), - decoration: BoxDecoration( - color: AppColors.primary, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: AppColors.primaryShadow.withValues(alpha: 0.25), - blurRadius: 6, - offset: const Offset(0, 2), + child: Center( + child: IntrinsicWidth( + child: GestureDetector( + onTap: widget.onGenerateSimilar, + child: Container( + height: 24, + padding: const EdgeInsets.symmetric(horizontal: 10), + decoration: BoxDecoration( + color: AppColors.primaryButtonFill, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: AppColors.primary, + width: 1, + ), + boxShadow: [ + BoxShadow( + color: AppColors.primaryButtonShadow, + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + alignment: Alignment.center, + child: const Text( + 'Generate Similar', + style: TextStyle( + color: AppColors.surface, + fontSize: 12, + fontWeight: FontWeight.w600, + fontFamily: 'Inter', + ), ), - ], - ), - alignment: Alignment.center, - child: const Text( - 'Generate Similar', - style: TextStyle( - color: AppColors.surface, - fontSize: 12, - fontWeight: FontWeight.w600, - fontFamily: 'Inter', ), ), ), diff --git a/lib/features/recharge/google_play_purchase_service.dart b/lib/features/recharge/google_play_purchase_service.dart new file mode 100644 index 0000000..a1a8014 --- /dev/null +++ b/lib/features/recharge/google_play_purchase_service.dart @@ -0,0 +1,147 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:in_app_purchase/in_app_purchase.dart'; + +import '../../core/log/app_logger.dart'; + +/// 调起 Google Play 内购(三方为 false 时使用;三方为 true 且 ceremony==GooglePay 时用 launchPurchaseAndReturnData) +abstract final class GooglePlayPurchaseService { + static final _log = AppLogger('GooglePlayPurchase'); + + /// 发起购买并返回服务器凭据(用于三方支付选 GooglePay 时上报 /v1/payment/googlepay)。 + /// 成功返回 purchaseData(serverVerificationData),取消/失败返回 null。 + static Future launchPurchaseAndReturnData(String productId) async { + _log.d('谷歌支付请求商品 ID(helm): "$productId"'); + if (defaultTargetPlatform != TargetPlatform.android) { + _log.d('非 Android,跳过内购'); + return null; + } + final iap = InAppPurchase.instance; + if (!await iap.isAvailable()) { + _log.w('Billing 不可用'); + return null; + } + final response = await iap.queryProductDetails({productId}); + if (response.notFoundIDs.isNotEmpty || response.productDetails.isEmpty) { + _log.w('商品未找到: 请求的 productId="$productId", notFoundIDs=${response.notFoundIDs}, 请核对与 Play 后台配置的「产品 ID」是否完全一致(区分大小写)'); + return null; + } + final product = response.productDetails.first; + final completer = Completer(); + StreamSubscription>? sub; + sub = iap.purchaseStream.listen( + (purchases) { + for (final p in purchases) { + if (p.productID != productId) continue; + if (p.status == PurchaseStatus.purchased || + p.status == PurchaseStatus.restored) { + if (!completer.isCompleted) { + final data = p.verificationData.serverVerificationData; + iap.completePurchase(p); + completer.complete(data); + } + sub?.cancel(); + return; + } + if (p.status == PurchaseStatus.error || + p.status == PurchaseStatus.canceled) { + if (!completer.isCompleted) completer.complete(null); + sub?.cancel(); + return; + } + } + }, + onError: (_) { + if (!completer.isCompleted) completer.complete(null); + sub?.cancel(); + }, + ); + final success = await iap.buyConsumable( + purchaseParam: PurchaseParam(productDetails: product), + ); + if (!success) { + sub.cancel(); + return null; + } + return completer.future.timeout( + const Duration(seconds: 120), + onTimeout: () { + sub?.cancel(); + return null; + }, + ); + } + + /// 发起购买,商品 ID 为 [productId](即 ActivityItem.code / helm)。 + /// 返回 true 表示购买完成,false 表示取消或失败。 + static Future launchPurchase(String productId) async { + if (defaultTargetPlatform != TargetPlatform.android) { + return false; + } + + final iap = InAppPurchase.instance; + final available = await iap.isAvailable(); + if (!available) { + return false; + } + + final response = await iap.queryProductDetails({productId}); + if (response.notFoundIDs.isNotEmpty || response.productDetails.isEmpty) { + _log.w('商品未找到: productId="$productId", notFoundIDs=${response.notFoundIDs}'); + return false; + } + + final product = response.productDetails.first; + final completer = Completer(); + StreamSubscription>? sub; + + sub = iap.purchaseStream.listen( + (purchases) { + for (final p in purchases) { + if (p.productID != productId) continue; + if (p.status == PurchaseStatus.purchased || + p.status == PurchaseStatus.restored) { + if (!completer.isCompleted) { + iap.completePurchase(p); + completer.complete(true); + } + sub?.cancel(); + return; + } + if (p.status == PurchaseStatus.error) { + if (!completer.isCompleted) { + completer.complete(false); + } + sub?.cancel(); + return; + } + if (p.status == PurchaseStatus.canceled) { + if (!completer.isCompleted) { + completer.complete(false); + } + sub?.cancel(); + return; + } + } + }, + onError: (Object e) { + if (!completer.isCompleted) completer.complete(false); + sub?.cancel(); + }, + ); + + final purchaseParam = PurchaseParam(productDetails: product); + final success = await iap.buyConsumable(purchaseParam: purchaseParam); + if (!success) { + sub.cancel(); + return false; + } + + return completer.future.timeout(const Duration(seconds: 120), + onTimeout: () { + sub?.cancel(); + return false; + }); + } +} diff --git a/lib/features/recharge/models/activity_item.dart b/lib/features/recharge/models/activity_item.dart index f98c8a1..eba969e 100644 --- a/lib/features/recharge/models/activity_item.dart +++ b/lib/features/recharge/models/activity_item.dart @@ -40,6 +40,14 @@ class ActivityItem { String get creditsDisplay => credits > 0 ? '${credits.toString().replaceAllMapped(RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (m) => '${m[1]},')} Credits' : title; + /// 显示价格文案,带 $ 符号(支付流程文档要求 guardian 需显示 $) + String get priceDisplayWithDollar { + if (actualAmount.isEmpty) return ''; + final amount = actualAmount.trimLeft(); + if (amount.startsWith(r'$')) return actualAmount; + return '\$$actualAmount'; + } + /// 显示价格文案,如 "¥6" 或 "¥25 Save ¥5" String get priceDisplay { if (actualAmount.isEmpty) return ''; diff --git a/lib/features/recharge/models/payment_method_item.dart b/lib/features/recharge/models/payment_method_item.dart new file mode 100644 index 0000000..dc1489b --- /dev/null +++ b/lib/features/recharge/models/payment_method_item.dart @@ -0,0 +1,28 @@ +/// 支付方式项(get-payment-methods 返回的 renew 单项) +class PaymentMethodItem { + const PaymentMethodItem({ + required this.paymentMethod, + this.subPaymentMethod, + this.name, + this.icon, + this.recommend = false, + }); + + final String paymentMethod; // resource,如 GOOGLEPAY/APPLEPAY + final String? subPaymentMethod; // ceremony + final String? name; // brigade 展示名称 + final String? icon; // greylist 图标 URL + final bool recommend; // deny 取反为推荐 + + factory PaymentMethodItem.fromJson(Map json) { + return PaymentMethodItem( + paymentMethod: json['resource']?.toString() ?? '', + subPaymentMethod: json['ceremony']?.toString(), + name: json['brigade']?.toString(), + icon: json['greylist']?.toString(), + recommend: json['deny'] != true, + ); + } + + String get displayName => name?.isNotEmpty == true ? name! : paymentMethod; +} diff --git a/lib/features/recharge/payment_webview_screen.dart b/lib/features/recharge/payment_webview_screen.dart new file mode 100644 index 0000000..34d5dab --- /dev/null +++ b/lib/features/recharge/payment_webview_screen.dart @@ -0,0 +1,66 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:webview_flutter/webview_flutter.dart'; +import 'package:webview_flutter_android/webview_flutter_android.dart'; + +import '../../core/theme/app_colors.dart'; +import '../../shared/widgets/top_nav_bar.dart'; + +/// 三方支付成功后在应用内打开 convert 链接的 WebView 页面 +class PaymentWebViewScreen extends StatefulWidget { + const PaymentWebViewScreen({super.key, required this.paymentUrl}); + + final String paymentUrl; + + @override + State createState() => _PaymentWebViewScreenState(); +} + +class _PaymentWebViewScreenState extends State { + late final WebViewController _controller; + + @override + void initState() { + super.initState(); + _controller = WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..setNavigationDelegate( + NavigationDelegate( + onPageStarted: (_) {}, + onPageFinished: (_) {}, + onWebResourceError: (e) {}, + ), + ) + ..loadRequest(Uri.parse(widget.paymentUrl)); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.surface, + appBar: PreferredSize( + preferredSize: const Size.fromHeight(56), + child: TopNavBar( + title: 'Payment', + showBackButton: true, + onBack: () => Navigator.of(context).pop(), + ), + ), + body: SafeArea( + child: _buildWebView(), + ), + ); + } + + Widget _buildWebView() { + if (defaultTargetPlatform == TargetPlatform.android) { + return WebViewWidget.fromPlatformCreationParams( + params: AndroidWebViewWidgetCreationParams( + controller: _controller.platform as AndroidWebViewController, + displayWithHybridComposition: true, + ), + ); + } + return WebViewWidget(controller: _controller); + } +} diff --git a/lib/features/recharge/recharge_screen.dart b/lib/features/recharge/recharge_screen.dart index 9cb4813..81be29e 100644 --- a/lib/features/recharge/recharge_screen.dart +++ b/lib/features/recharge/recharge_screen.dart @@ -3,36 +3,58 @@ import 'package:flutter/material.dart'; import 'package:flutter_lucide/flutter_lucide.dart'; import '../../core/adjust/adjust_events.dart'; +import '../../core/api/api_config.dart'; import '../../core/api/services/payment_api.dart'; import '../../core/auth/auth_service.dart'; +import '../../core/log/app_logger.dart'; import '../../core/theme/app_colors.dart'; import '../../core/user/user_state.dart'; import '../../core/theme/app_spacing.dart'; import '../../core/theme/app_typography.dart'; import '../../shared/widgets/top_nav_bar.dart'; +import 'google_play_purchase_service.dart'; import 'models/activity_item.dart'; +import 'models/payment_method_item.dart'; +import 'payment_webview_screen.dart'; /// Recharge screen - matches Pencil tPjdN /// Tier cards (DC6NS, YRaOG, QlNz6) 对接 getGooglePayActivities / getApplePayActivities class RechargeScreen extends StatefulWidget { + static final _log = AppLogger('Recharge'); const RechargeScreen({super.key}); @override State createState() => _RechargeScreenState(); } -class _RechargeScreenState extends State { +class _RechargeScreenState extends State with WidgetsBindingObserver { List _activities = []; bool _loadingTiers = true; String? _tierError; + /// 当前正在支付的商品 code,仅该 item 的 Buy 显示 loading + String? _loadingProductId; @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); _fetchActivities(); } + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed && _loadingProductId != null && mounted) { + setState(() => _loadingProductId = null); + } + } + Future _fetchActivities() async { setState(() { _loadingTiers = true; @@ -80,7 +102,10 @@ class _RechargeScreenState extends State { } } - void _onBuy(ActivityItem item) { + Future _onBuy(ActivityItem item) async { + if (_loadingProductId != null) return; + setState(() => _loadingProductId = item.code); + final price = AdjustEvents.parsePrice(item.actualAmount); if (price != null) { final tierToken = AdjustEvents.tierTokenFromPrice(price); @@ -94,7 +119,245 @@ class _RechargeScreenState extends State { } else if (titleLower.contains('weekly vip')) { AdjustEvents.trackWeeklyVip(); } - // TODO: 创建支付订单;成功时调用 AdjustEvents.trackPurchaseSuccess(),失败时调用 AdjustEvents.trackPaymentFailed() + + final useThirdParty = + UserState.enableThirdPartyPayment.value == true; + final uid = UserState.userId.value; + + if (useThirdParty && uid != null && uid.isNotEmpty) { + await _runThirdPartyPayment(item, uid); + } else { + await _runGooglePay(item); + } + } + + /// 第三方支付流程:获取支付方式 -> 弹窗选择 -> 创建订单 -> 打开支付页 + Future _runThirdPartyPayment(ActivityItem item, String userId) async { + final activityId = item.activityId; + if (activityId == null || activityId.isEmpty) { + if (mounted) { + _showSnackBar(context, 'Invalid product', isError: true); + } + AdjustEvents.trackPaymentFailed(); + return; + } + + if (!mounted) return; + + try { + final country = UserState.navigate.value; + + final methodsRes = await PaymentApi.getPaymentMethods( + warrior: activityId, + vambrace: country?.isEmpty == true ? null : country, + ); + if (!mounted) return; + if (!methodsRes.isSuccess || methodsRes.data == null) { + _showSnackBar( + context, + methodsRes.msg.isNotEmpty ? methodsRes.msg : 'Failed to load payment methods', + isError: true, + ); + AdjustEvents.trackPaymentFailed(); + return; + } + + final methodsList = methodsRes.data is Map + ? (methodsRes.data! as Map)['renew'] as List? + : null; + if (methodsList == null || methodsList.isEmpty) { + _showSnackBar(context, 'No payment methods available', isError: true); + AdjustEvents.trackPaymentFailed(); + return; + } + + final methods = methodsList + .whereType>() + .map((e) => PaymentMethodItem.fromJson(e)) + .where((m) => m.paymentMethod.isNotEmpty) + .toList(); + if (methods.isEmpty) { + _showSnackBar(context, 'No payment methods available', isError: true); + AdjustEvents.trackPaymentFailed(); + return; + } + + if (!mounted) return; + final selected = await showDialog( + context: context, + builder: (ctx) => _PaymentMethodDialog(methods: methods), + ); + + if (!mounted) return; + if (selected == null) { + setState(() => _loadingProductId = null); + return; + } + + await _createOrderAndOpenUrl( + userId: userId, + activityId: activityId, + productId: item.code, + paymentMethod: selected.paymentMethod, + subPaymentMethod: selected.subPaymentMethod?.isEmpty == true ? null : selected.subPaymentMethod, + ); + } catch (e) { + if (mounted) { + _showSnackBar(context, 'Payment error: ${e.toString()}', isError: true); + } + AdjustEvents.trackPaymentFailed(); + } finally { + if (mounted) { + setState(() => _loadingProductId = null); + } + } + } + + /// 创建订单;若为 Google Pay 则调起内购并上报凭据,否则打开支付链接 + Future _createOrderAndOpenUrl({ + required String userId, + required String activityId, + required String productId, + required String paymentMethod, + String? subPaymentMethod, + }) async { + if (!mounted) return; + + try { + final createRes = await PaymentApi.createPayment( + sentinel: ApiConfig.appId, + asset: userId, + warrior: activityId, + resource: paymentMethod, + ceremony: subPaymentMethod, + ); + if (!mounted) return; + if (!createRes.isSuccess) { + _showSnackBar( + context, + createRes.msg.isNotEmpty ? createRes.msg : 'Failed to create order', + isError: true, + ); + AdjustEvents.trackPaymentFailed(); + return; + } + + final data = createRes.data is Map + ? createRes.data as Map + : null; + final orderId = data?['federation']?.toString(); + + if (_isGooglePay(paymentMethod, subPaymentMethod)) { + if (defaultTargetPlatform != TargetPlatform.android) { + _showSnackBar(context, 'Google Pay is only available on Android.', isError: true); + AdjustEvents.trackPaymentFailed(); + return; + } + final purchaseData = await GooglePlayPurchaseService.launchPurchaseAndReturnData(productId); + if (!mounted) return; + if (purchaseData != null && purchaseData.isNotEmpty && orderId != null && orderId.isNotEmpty) { + RechargeScreen._log.d('googlepay 入参: federation=$orderId, asset=$userId'); + RechargeScreen._log.d('googlepay 入参: merchant(length=${purchaseData.length}) ${purchaseData.length > 400 ? "${purchaseData.substring(0, 400)}..." : purchaseData}'); + final googlepayRes = await PaymentApi.googlepay( + merchant: purchaseData, + federation: orderId, + asset: userId, + ); + if (!mounted) return; + if (googlepayRes.isSuccess) { + _showSnackBar(context, 'Purchase completed.'); + AdjustEvents.trackPurchaseSuccess(); + } else { + _showSnackBar(context, googlepayRes.msg.isNotEmpty ? googlepayRes.msg : 'Payment verification failed.', isError: true); + AdjustEvents.trackPaymentFailed(); + } + } else { + _showSnackBar(context, 'Purchase was cancelled or failed.', isError: true); + AdjustEvents.trackPaymentFailed(); + } + return; + } + + final payUrl = data?['convert']?.toString(); + if (payUrl != null && payUrl.isNotEmpty) { + if (mounted) { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => PaymentWebViewScreen(paymentUrl: payUrl), + ), + ); + _showSnackBar(context, 'Order created. Complete payment in the page.'); + AdjustEvents.trackPurchaseSuccess(); + } + } else { + if (mounted) { + _showSnackBar(context, 'Order created. Awaiting payment confirmation.'); + } + AdjustEvents.trackPurchaseSuccess(); + } + } catch (e) { + if (mounted) { + _showSnackBar(context, 'Payment error: ${e.toString()}', isError: true); + } + AdjustEvents.trackPaymentFailed(); + } finally { + if (mounted) { + setState(() => _loadingProductId = null); + } + } + } + + /// ceremony==GooglePay 或 resource==GOOGLEPAY 时走谷歌内购并上报 + static bool _isGooglePay(String paymentMethod, String? subPaymentMethod) { + final r = paymentMethod.trim().toLowerCase(); + final c = (subPaymentMethod ?? '').trim().toLowerCase(); + return r == 'googlepay' || c == 'googlepay'; + } + + /// 三方为 false 时走谷歌应用内支付 + Future _runGooglePay(ActivityItem item) async { + if (defaultTargetPlatform != TargetPlatform.android) { + _showSnackBar(context, 'Google Pay is only available on Android.', isError: true); + AdjustEvents.trackPaymentFailed(); + return; + } + await _launchGooglePlayPurchase(item); + } + + /// 调起 Google Play 内购(商品 ID = item.code / helm) + Future _launchGooglePlayPurchase(ActivityItem item) async { + if (!mounted) return; + + try { + final success = await GooglePlayPurchaseService.launchPurchase(item.code); + if (!mounted) return; + if (success) { + _showSnackBar(context, 'Purchase completed.'); + AdjustEvents.trackPurchaseSuccess(); + } else { + _showSnackBar(context, 'Purchase was cancelled or failed.', isError: true); + AdjustEvents.trackPaymentFailed(); + } + } catch (e) { + if (mounted) { + _showSnackBar(context, 'Google Pay error: ${e.toString()}', isError: true); + } + AdjustEvents.trackPaymentFailed(); + } finally { + if (mounted) { + setState(() => _loadingProductId = null); + } + } + } + + void _showSnackBar(BuildContext context, String message, {bool isError = false}) { + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: isError ? Colors.red.shade700 : null, + ), + ); } @override @@ -192,6 +455,7 @@ class _RechargeScreenState extends State { : isPopular ? _TierBadge.popular : _TierBadge.none, + loading: _loadingProductId == item.code, onBuy: () => _onBuy(item), ), ); @@ -203,17 +467,58 @@ class _RechargeScreenState extends State { } } +/// 支付方式选择弹窗(三方支付列表) +class _PaymentMethodDialog extends StatelessWidget { + const _PaymentMethodDialog({required this.methods}); + + final List methods; + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Select payment method'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: methods + .map( + (m) => ListTile( + title: Text(m.displayName), + subtitle: m.subPaymentMethod != null && m.subPaymentMethod!.isNotEmpty + ? Text(m.subPaymentMethod!, style: AppTypography.caption) + : null, + trailing: m.recommend + ? Text('Recommended', style: AppTypography.caption.copyWith(color: AppColors.primary)) + : null, + onTap: () => Navigator.of(context).pop(m), + ), + ) + .toList(), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ], + ); + } +} + enum _TierBadge { none, recommended, popular } class _TierCardFromActivity extends StatelessWidget { const _TierCardFromActivity({ required this.item, required this.badge, + required this.loading, required this.onBuy, }); final ActivityItem item; final _TierBadge badge; + final bool loading; final VoidCallback onBuy; @override @@ -239,25 +544,55 @@ class _TierCardFromActivity extends StatelessWidget { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.creditsDisplay, - style: AppTypography.bodyLarge.copyWith( - color: AppColors.textPrimary, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.creditsDisplay, + style: AppTypography.bodyLarge.copyWith( + color: AppColors.textPrimary, + ), ), - ), - SizedBox(height: badge == _TierBadge.none ? AppSpacing.xs : AppSpacing.sm), - Text( - item.priceDisplay, - style: AppTypography.bodyRegular.copyWith( - color: AppColors.textSecondary, + SizedBox(height: badge == _TierBadge.none ? AppSpacing.xs : AppSpacing.sm), + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + item.priceDisplayWithDollar, + style: AppTypography.bodyRegular.copyWith( + color: AppColors.textSecondary, + ), + ), + if (item.originAmount != null && + item.originAmount!.isNotEmpty && + item.originAmount != item.actualAmount) ...[ + const SizedBox(width: AppSpacing.sm), + Text( + item.originAmount!, + style: AppTypography.bodyRegular.copyWith( + color: AppColors.textSecondary, + decoration: TextDecoration.lineThrough, + decorationColor: AppColors.textSecondary, + ), + ), + ], + if (item.bonus > 0) ...[ + const SizedBox(width: AppSpacing.sm), + Text( + 'Bonus: ${item.bonus} credits', + style: AppTypography.caption.copyWith( + color: AppColors.primary, + ), + ), + ], + ], ), - ), - ], + ], + ), ), - _BuyButton(onTap: onBuy), + _BuyButton(onTap: onBuy, loading: loading), ], ), ); @@ -358,30 +693,40 @@ class _CreditsSection extends StatelessWidget { } class _BuyButton extends StatelessWidget { - const _BuyButton({required this.onTap}); + const _BuyButton({required this.onTap, this.loading = false}); final VoidCallback onTap; + final bool loading; @override Widget build(BuildContext context) { return GestureDetector( - onTap: onTap, + onTap: loading ? null : onTap, child: Container( padding: const EdgeInsets.symmetric( horizontal: AppSpacing.xl, vertical: AppSpacing.md, ), decoration: BoxDecoration( - color: AppColors.primary, + color: loading ? AppColors.primary.withValues(alpha: 0.7) : AppColors.primary, borderRadius: BorderRadius.circular(12), ), - child: Text( - 'Buy', - style: AppTypography.bodyRegular.copyWith( - color: AppColors.surface, - fontWeight: FontWeight.w600, - ), - ), + child: loading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(AppColors.surface), + ), + ) + : Text( + 'Buy', + style: AppTypography.bodyRegular.copyWith( + color: AppColors.surface, + fontWeight: FontWeight.w600, + ), + ), ), ); } diff --git a/lib/shared/tab_selector_scope.dart b/lib/shared/tab_selector_scope.dart new file mode 100644 index 0000000..2c26235 --- /dev/null +++ b/lib/shared/tab_selector_scope.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; + +import 'widgets/bottom_nav_bar.dart'; + +/// Allows routes (e.g. progress screen) to request switching to a tab when popping. +class TabSelectorScope extends InheritedWidget { + const TabSelectorScope({ + super.key, + required this.selectTab, + required super.child, + }); + + final void Function(NavTab) selectTab; + + static TabSelectorScope? maybeOf(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType(); + } + + @override + bool updateShouldNotify(TabSelectorScope old) => selectTab != old.selectTab; +} diff --git a/pubspec.yaml b/pubspec.yaml index 7ca6eca..83c6a3c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: pets_hero_ai description: PetsHero AI Application. publish_to: 'none' -version: 1.0.1+2 +version: 1.0.3+4 environment: sdk: '>=3.0.0 <4.0.0' @@ -26,6 +26,10 @@ dependencies: path_provider: ^2.1.2 shared_preferences: ^2.2.2 flutter_cache_manager: ^3.3.1 + logger: ^2.0.2 + url_launcher: ^6.2.5 + in_app_purchase: ^3.2.0 + webview_flutter: ^4.10.0 dev_dependencies: flutter_test: