隔离:最终版本
This commit is contained in:
parent
6747e97cf9
commit
cb8f9dc085
@ -10,6 +10,12 @@
|
||||
android:maxSdkVersion="32"
|
||||
tools:replace="android:maxSdkVersion" />
|
||||
<uses-permission android:name="com.android.vending.BILLING" />
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:scheme="fb" />
|
||||
</intent>
|
||||
</queries>
|
||||
<application
|
||||
android:usesCleartextTraffic="${usesCleartextTraffic}"
|
||||
android:label="PetsHero AI"
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "2.9",
|
||||
"version": "2.11",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
@ -499,7 +499,7 @@
|
||||
"fill": {
|
||||
"type": "image",
|
||||
"enabled": true,
|
||||
"url": "./images/generated-1773156213563.png",
|
||||
"url": "images/generated-1773156213563.png",
|
||||
"mode": "fill"
|
||||
},
|
||||
"layout": "none",
|
||||
@ -667,7 +667,7 @@
|
||||
"fill": {
|
||||
"type": "image",
|
||||
"enabled": true,
|
||||
"url": "./images/generated-1773156252029.png",
|
||||
"url": "images/generated-1773156252029.png",
|
||||
"mode": "fill"
|
||||
},
|
||||
"layout": "none",
|
||||
@ -825,7 +825,7 @@
|
||||
"fill": {
|
||||
"type": "image",
|
||||
"enabled": true,
|
||||
"url": "./images/generated-1773156289353.png",
|
||||
"url": "images/generated-1773156289353.png",
|
||||
"mode": "fill"
|
||||
},
|
||||
"layout": "none",
|
||||
@ -993,7 +993,7 @@
|
||||
"fill": {
|
||||
"type": "image",
|
||||
"enabled": true,
|
||||
"url": "./images/generated-1773156316903.png",
|
||||
"url": "images/generated-1773156316903.png",
|
||||
"mode": "fill"
|
||||
},
|
||||
"layout": "none",
|
||||
@ -1151,7 +1151,7 @@
|
||||
"fill": {
|
||||
"type": "image",
|
||||
"enabled": true,
|
||||
"url": "./images/generated-1773156352910.png",
|
||||
"url": "images/generated-1773156352910.png",
|
||||
"mode": "fill"
|
||||
},
|
||||
"layout": "none",
|
||||
@ -1708,7 +1708,7 @@
|
||||
"fill": {
|
||||
"type": "image",
|
||||
"enabled": true,
|
||||
"url": "./images/generated-1773156157712.png",
|
||||
"url": "images/generated-1773156157712.png",
|
||||
"mode": "fill"
|
||||
},
|
||||
"cornerRadius": 24,
|
||||
@ -1805,7 +1805,7 @@
|
||||
"fill": {
|
||||
"type": "image",
|
||||
"enabled": true,
|
||||
"url": "./images/generated-1773156363524.png",
|
||||
"url": "images/generated-1773156363524.png",
|
||||
"mode": "fill"
|
||||
},
|
||||
"cornerRadius": 24,
|
||||
@ -1912,7 +1912,7 @@
|
||||
"fill": {
|
||||
"type": "image",
|
||||
"enabled": true,
|
||||
"url": "./images/generated-1773156393690.png",
|
||||
"url": "images/generated-1773156393690.png",
|
||||
"mode": "fill"
|
||||
},
|
||||
"cornerRadius": 24,
|
||||
@ -2009,7 +2009,7 @@
|
||||
"fill": {
|
||||
"type": "image",
|
||||
"enabled": true,
|
||||
"url": "./images/generated-1773134913667.png",
|
||||
"url": "images/generated-1773134913667.png",
|
||||
"mode": "fill"
|
||||
},
|
||||
"cornerRadius": 24,
|
||||
@ -2948,10 +2948,37 @@
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "Lt3TV",
|
||||
"name": "navSpacer",
|
||||
"width": 40,
|
||||
"height": 40
|
||||
"id": "omZCh",
|
||||
"name": "recordEntry",
|
||||
"height": 40,
|
||||
"gap": 4,
|
||||
"padding": [
|
||||
0,
|
||||
4
|
||||
],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "X2yWW",
|
||||
"name": "recordEntryIcon",
|
||||
"width": 16,
|
||||
"height": 16,
|
||||
"iconFontName": "list",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "#8B5CF6"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "fjE0S",
|
||||
"name": "recordEntryText",
|
||||
"fill": "#8B5CF6",
|
||||
"content": "History",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "600"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@ -3941,7 +3968,7 @@
|
||||
"fill": {
|
||||
"type": "image",
|
||||
"enabled": true,
|
||||
"url": "./images/pet-hero-1.png",
|
||||
"url": "images/pet-hero-1.png",
|
||||
"mode": "fill"
|
||||
},
|
||||
"cornerRadius": 16,
|
||||
@ -4281,7 +4308,7 @@
|
||||
"fill": {
|
||||
"type": "image",
|
||||
"enabled": true,
|
||||
"url": "./images/generated-1773156472555.png",
|
||||
"url": "images/generated-1773156472555.png",
|
||||
"mode": "fill"
|
||||
},
|
||||
"cornerRadius": 16,
|
||||
@ -4513,7 +4540,7 @@
|
||||
"fill": {
|
||||
"type": "image",
|
||||
"enabled": true,
|
||||
"url": "./images/generated-1773156511265.png",
|
||||
"url": "images/generated-1773156511265.png",
|
||||
"mode": "fill"
|
||||
},
|
||||
"cornerRadius": 16,
|
||||
@ -5017,6 +5044,373 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "HhcCM",
|
||||
"x": 3560,
|
||||
"y": 0,
|
||||
"name": "积分记录页面",
|
||||
"clip": true,
|
||||
"width": 390,
|
||||
"height": 844,
|
||||
"fill": "#FAFAFA",
|
||||
"layout": "vertical",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "QtoLk",
|
||||
"name": "statusBar",
|
||||
"width": "fill_container",
|
||||
"height": 40,
|
||||
"fill": "#FFFFFF",
|
||||
"padding": [
|
||||
0,
|
||||
12,
|
||||
0,
|
||||
20
|
||||
],
|
||||
"justifyContent": "space_between",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "b5Ogx",
|
||||
"name": "recordTime",
|
||||
"fill": "#18181B",
|
||||
"content": "9:41",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "600"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "Su906",
|
||||
"name": "recordStatusIcons",
|
||||
"gap": 6,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "MTrqx",
|
||||
"name": "recordSignal",
|
||||
"width": 21,
|
||||
"height": 15,
|
||||
"iconFontName": "signal",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "#18181B"
|
||||
},
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "fnm42",
|
||||
"name": "recordWifi",
|
||||
"width": 21,
|
||||
"height": 15,
|
||||
"iconFontName": "wifi",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "#18181B"
|
||||
},
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "nx60Z",
|
||||
"name": "recordBattery",
|
||||
"width": 30,
|
||||
"height": 15,
|
||||
"iconFontName": "battery-medium",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "#18181B"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "YfTGP",
|
||||
"name": "topNav",
|
||||
"width": "fill_container",
|
||||
"height": 56,
|
||||
"fill": "#FFFFFF",
|
||||
"effect": {
|
||||
"type": "shadow",
|
||||
"shadowType": "outer",
|
||||
"color": "#00000008",
|
||||
"offset": {
|
||||
"x": 0,
|
||||
"y": 2
|
||||
},
|
||||
"blur": 8
|
||||
},
|
||||
"padding": [
|
||||
0,
|
||||
20
|
||||
],
|
||||
"justifyContent": "space_between",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "Napgn",
|
||||
"name": "backBtn",
|
||||
"width": 40,
|
||||
"height": 40,
|
||||
"justifyContent": "center",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "JQ8oi",
|
||||
"name": "recordBackIcon",
|
||||
"width": 24,
|
||||
"height": 24,
|
||||
"iconFontName": "arrow-left",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "#18181B"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "vRkK0",
|
||||
"name": "recordTitle",
|
||||
"fill": "#18181B",
|
||||
"content": "Points History",
|
||||
"fontFamily": "Plus Jakarta Sans",
|
||||
"fontSize": 20,
|
||||
"fontWeight": "700"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "hyBiG",
|
||||
"name": "recordSpacer",
|
||||
"width": 40,
|
||||
"height": 40
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "yc0Rz",
|
||||
"name": "content",
|
||||
"width": "fill_container",
|
||||
"height": "fill_container",
|
||||
"fill": "#FAFAFA",
|
||||
"layout": "vertical",
|
||||
"gap": 16,
|
||||
"padding": [
|
||||
20,
|
||||
20,
|
||||
24,
|
||||
20
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "pBfPr",
|
||||
"name": "summaryCard",
|
||||
"width": "fill_container",
|
||||
"fill": "#FFFFFF",
|
||||
"cornerRadius": 16,
|
||||
"stroke": {
|
||||
"thickness": 1,
|
||||
"fill": "#E4E4E7"
|
||||
},
|
||||
"effect": {
|
||||
"type": "shadow",
|
||||
"shadowType": "outer",
|
||||
"color": "#00000008",
|
||||
"offset": {
|
||||
"x": 0,
|
||||
"y": 2
|
||||
},
|
||||
"blur": 6
|
||||
},
|
||||
"layout": "vertical",
|
||||
"gap": 8,
|
||||
"padding": [
|
||||
16,
|
||||
18
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "aNlTU",
|
||||
"name": "recordSummaryTitle",
|
||||
"fill": "#71717A",
|
||||
"content": "Current Points",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "Od6F1",
|
||||
"name": "recordSummaryValue",
|
||||
"fill": "#18181B",
|
||||
"content": "1,280",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 30,
|
||||
"fontWeight": "700"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "v25H1",
|
||||
"name": "recordList",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 8,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "BzXpQ",
|
||||
"name": "recordItem1",
|
||||
"width": "fill_container",
|
||||
"fill": "#FFFFFF",
|
||||
"cornerRadius": 12,
|
||||
"stroke": {
|
||||
"thickness": 1,
|
||||
"fill": "#E4E4E7"
|
||||
},
|
||||
"padding": [
|
||||
14,
|
||||
16
|
||||
],
|
||||
"justifyContent": "space_between",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "r3dpe",
|
||||
"name": "recordItem1Left",
|
||||
"layout": "vertical",
|
||||
"gap": 4,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "py0Ct",
|
||||
"name": "recordItem1Time",
|
||||
"fill": "#71717A",
|
||||
"content": "2026-04-28 09:12",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "tWrM8",
|
||||
"name": "recordItem1Change",
|
||||
"fill": "#16A34A",
|
||||
"content": "+20",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "700"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "Jb2ZB",
|
||||
"name": "recordItem2",
|
||||
"width": "fill_container",
|
||||
"fill": "#FFFFFF",
|
||||
"cornerRadius": 12,
|
||||
"stroke": {
|
||||
"thickness": 1,
|
||||
"fill": "#E4E4E7"
|
||||
},
|
||||
"padding": [
|
||||
14,
|
||||
16
|
||||
],
|
||||
"justifyContent": "space_between",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "elr68",
|
||||
"name": "recordItem2Left",
|
||||
"layout": "vertical",
|
||||
"gap": 4,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "riFsx",
|
||||
"name": "recordItem2Time",
|
||||
"fill": "#71717A",
|
||||
"content": "2026-04-27 22:45",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "j8PFQw",
|
||||
"name": "recordItem2Change",
|
||||
"fill": "#DC2626",
|
||||
"content": "-120",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "700"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gIDh0",
|
||||
"name": "recordItem3",
|
||||
"width": "fill_container",
|
||||
"fill": "#FFFFFF",
|
||||
"cornerRadius": 12,
|
||||
"stroke": {
|
||||
"thickness": 1,
|
||||
"fill": "#E4E4E7"
|
||||
},
|
||||
"padding": [
|
||||
14,
|
||||
16
|
||||
],
|
||||
"justifyContent": "space_between",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "q8BKB8",
|
||||
"name": "recordItem3Left",
|
||||
"layout": "vertical",
|
||||
"gap": 4,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "q7lwb",
|
||||
"name": "recordItem3Time",
|
||||
"fill": "#71717A",
|
||||
"content": "2026-04-26 14:08",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "zskzV",
|
||||
"name": "recordItem3Change",
|
||||
"fill": "#16A34A",
|
||||
"content": "+500",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "700"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
679
docs/rewrite_refactor_playbook.md
Normal file
679
docs/rewrite_refactor_playbook.md
Normal file
@ -0,0 +1,679 @@
|
||||
# Flutter Project Rewrite Refactor Playbook
|
||||
|
||||
## Purpose
|
||||
|
||||
This document adapts the generic "rewrite by a different engineer" refactor template to the current `pets_hero_ai` Flutter app.
|
||||
|
||||
The goal is a structural rewrite that makes the project look and feel newly organized while preserving behavior:
|
||||
|
||||
- UI output stays visually equivalent.
|
||||
- API requests, parameters, encryption, authentication, payment, attribution, and analytics behavior stay equivalent.
|
||||
- Existing app startup, login, referrer, home loading, generation, gallery, recharge, and profile flows keep working.
|
||||
- The project compiles after each module migration.
|
||||
|
||||
This is not a feature rewrite. It is a controlled architecture migration.
|
||||
|
||||
## Current Project Shape
|
||||
|
||||
Current code is already partially feature-first:
|
||||
|
||||
- `lib/core/`
|
||||
- API client, proxy encryption, auth, token storage, referrer, Adjust/Facebook events, theme, logging, user state.
|
||||
- `lib/features/`
|
||||
- `home`, `gallery`, `generate_video`, `recharge`, `profile`.
|
||||
- `lib/shared/`
|
||||
- shared tab selector and common widgets.
|
||||
- Entrypoints:
|
||||
- `lib/main.dart`
|
||||
- `lib/app.dart`
|
||||
|
||||
Because the app already uses a `core/features/shared` layout, a plain Feature-first migration would not be different enough. The recommended target architecture is therefore a Clean Architecture-lite layout with vertical modules.
|
||||
|
||||
## Recommended Architecture
|
||||
|
||||
Choose this as the default unless there is a strong reason not to:
|
||||
|
||||
**Clean Architecture-lite + Vertical Modules**
|
||||
|
||||
Target layout:
|
||||
|
||||
```text
|
||||
lib/
|
||||
bootstrap/
|
||||
main_bootstrap.dart
|
||||
platform_startup.dart
|
||||
|
||||
app_shell/
|
||||
pets_hero_app.dart
|
||||
routes/
|
||||
route_table.dart
|
||||
route_observer.dart
|
||||
lifecycle/
|
||||
facebook_lifecycle_reporter.dart
|
||||
|
||||
foundation/
|
||||
analytics/
|
||||
adjust_event_gateway.dart
|
||||
facebook_event_gateway.dart
|
||||
auth/
|
||||
device_identity_provider.dart
|
||||
session_bootstrapper.dart
|
||||
token_vault.dart
|
||||
config/
|
||||
api_environment.dart
|
||||
facebook_environment.dart
|
||||
logging/
|
||||
tagged_log.dart
|
||||
network/
|
||||
api_response.dart
|
||||
encrypted_proxy_client.dart
|
||||
proxy_crypto.dart
|
||||
service_locator.dart
|
||||
referrer/
|
||||
install_referrer_probe.dart
|
||||
attribution_digest_builder.dart
|
||||
attribution_polling_coordinator.dart
|
||||
theme/
|
||||
colors.dart
|
||||
spacing.dart
|
||||
typography.dart
|
||||
theme_factory.dart
|
||||
user/
|
||||
account_state.dart
|
||||
account_refresh_use_case.dart
|
||||
device/
|
||||
memory_profile.dart
|
||||
|
||||
modules/
|
||||
home/
|
||||
domain/
|
||||
entities/
|
||||
policies/
|
||||
data/
|
||||
home_catalog_api.dart
|
||||
mappers/
|
||||
application/
|
||||
home_catalog_controller.dart
|
||||
playback_visibility_coordinator.dart
|
||||
presentation/
|
||||
home_view.dart
|
||||
components/
|
||||
|
||||
gallery/
|
||||
domain/
|
||||
data/
|
||||
application/
|
||||
presentation/
|
||||
|
||||
creation/
|
||||
domain/
|
||||
data/
|
||||
application/
|
||||
presentation/
|
||||
generate_view.dart
|
||||
progress_view.dart
|
||||
result_view.dart
|
||||
camera_view.dart
|
||||
components/
|
||||
|
||||
billing/
|
||||
domain/
|
||||
data/
|
||||
application/
|
||||
presentation/
|
||||
recharge_view.dart
|
||||
points_history_view.dart
|
||||
payment_webview_view.dart
|
||||
|
||||
profile/
|
||||
domain/
|
||||
data/
|
||||
application/
|
||||
presentation/
|
||||
|
||||
ui_kit/
|
||||
navigation/
|
||||
media/
|
||||
badges/
|
||||
tabs/
|
||||
```
|
||||
|
||||
Mapping from current structure:
|
||||
|
||||
| Current | Target |
|
||||
| --- | --- |
|
||||
| `core/api/*` | `foundation/network/*` and module-specific `data/*_api.dart` |
|
||||
| `core/auth/*` | `foundation/auth/*` |
|
||||
| `core/referrer/*` | `foundation/referrer/*` |
|
||||
| `core/adjust/*` | `foundation/analytics/*` |
|
||||
| `core/theme/*` | `foundation/theme/*` |
|
||||
| `core/user/*` | `foundation/user/*` |
|
||||
| `shared/widgets/*` | `ui_kit/*` or module `presentation/components/*` |
|
||||
| `features/home/*` | `modules/home/{domain,data,application,presentation}` |
|
||||
| `features/generate_video/*` | `modules/creation/{domain,data,application,presentation}` |
|
||||
| `features/recharge/*` | `modules/billing/{domain,data,application,presentation}` |
|
||||
| `features/gallery/*` | `modules/gallery/{domain,data,application,presentation}` |
|
||||
| `features/profile/*` | `modules/profile/{domain,data,application,presentation}` |
|
||||
| `app.dart` | `app_shell/pets_hero_app.dart` |
|
||||
| `main.dart` | `bootstrap/main_bootstrap.dart` plus thin `main.dart` |
|
||||
|
||||
## Architecture Choice Gate
|
||||
|
||||
Before implementation, choose one final target:
|
||||
|
||||
1. **Recommended: Clean Architecture-lite + Vertical Modules**
|
||||
- Best fit because current project is already feature-first.
|
||||
- Produces a clearly different layout while avoiding heavy dependency injection frameworks.
|
||||
2. **Strict Clean Architecture**
|
||||
- Adds explicit repository interfaces and use cases for every module.
|
||||
- More ceremony, higher migration cost.
|
||||
3. **MVVM + Repository**
|
||||
- Good if UI state should be centralized in view models.
|
||||
- Useful if future code will replace large `StatefulWidget` files with controllers.
|
||||
4. **Custom**
|
||||
- Must still define boundaries for app shell, foundation, modules, and UI kit before code is moved.
|
||||
|
||||
If uncertain, use option 1.
|
||||
|
||||
## Non-Negotiable Behavior Invariants
|
||||
|
||||
Preserve these exactly:
|
||||
|
||||
- Startup order:
|
||||
- `ensureDeviceMemoryProfileInitialized`
|
||||
- Adjust initialization
|
||||
- first frame and native splash removal
|
||||
- `AuthService` equivalent login bootstrap
|
||||
- Android Google Play pending purchase listener
|
||||
- order recovery after successful login
|
||||
- API transport:
|
||||
- encrypted proxy request wrapper
|
||||
- V2 `sanctum` payload structure
|
||||
- existing path, method, query, body semantics
|
||||
- timeout and error behavior
|
||||
- Authentication:
|
||||
- device ID selection
|
||||
- MD5 sign calculation
|
||||
- local token restore/write
|
||||
- fast login retry and failure telemetry
|
||||
- Attribution:
|
||||
- Adjust init and attribution callback
|
||||
- Play Install Referrer fallback
|
||||
- organic polling behavior
|
||||
- post-login referrer reporting
|
||||
- `common_info` refresh and home reload behavior
|
||||
- Analytics:
|
||||
- Adjust event tokens
|
||||
- Facebook App Events names and parameters
|
||||
- manual `activateApp`
|
||||
- Facebook app install check log
|
||||
- UI:
|
||||
- same routes
|
||||
- same navigation tabs
|
||||
- same home/category/task loading behavior
|
||||
- same recharge, gallery, generation, and profile behavior
|
||||
- Payments:
|
||||
- Google Play purchase stream subscription timing
|
||||
- pending order recovery
|
||||
- verification payloads
|
||||
- webview payment polling behavior
|
||||
|
||||
## Call Sequence Invariants
|
||||
|
||||
Directory moves, class renames, and file splitting must not change async ordering. Treat the following sequences as contract tests.
|
||||
|
||||
### Cold Startup Sequence
|
||||
|
||||
Current order to preserve:
|
||||
|
||||
1. `WidgetsFlutterBinding.ensureInitialized()`.
|
||||
2. Device memory profile initialization.
|
||||
3. Adjust SDK initialization and attribution callback registration.
|
||||
4. System UI overlay style setup.
|
||||
5. `runApp(...)`.
|
||||
6. Authentication bootstrap starts after `runApp`.
|
||||
7. Android-only pending Google Play purchase listener starts immediately after auth bootstrap is kicked off.
|
||||
8. Order recovery waits for login completion and only runs when startup succeeded.
|
||||
9. First frame removes native splash, logs Facebook app install state, then reports Facebook `activateApp`.
|
||||
|
||||
Do not move auth bootstrap before `runApp`, do not delay the Google Play listener until after login, and do not run order recovery before login completion.
|
||||
|
||||
### Login and Initial API Sequence
|
||||
|
||||
Current order to preserve:
|
||||
|
||||
1. Short startup delay.
|
||||
2. Resolve device ID.
|
||||
3. Compute MD5 sign from device ID.
|
||||
4. Resolve first referrer digest:
|
||||
- race Adjust attribution timeout/callback;
|
||||
- fallback to Play Install Referrer on Android.
|
||||
5. Restore local auth token into the API client.
|
||||
6. Call `POST /v1/user/fast_login` with:
|
||||
- `origin`;
|
||||
- `resolution`;
|
||||
- `digest`.
|
||||
7. On success:
|
||||
- persist returned user token if present;
|
||||
- send register events only when first-register flag is true;
|
||||
- update credits/user/country state;
|
||||
- start post-login referrer/common-info work in the background;
|
||||
- mark startup succeeded.
|
||||
8. On failure:
|
||||
- mark startup failed;
|
||||
- emit `LoginFaild` Facebook event once from the startup failure path.
|
||||
|
||||
Do not call authenticated business APIs before token restore and `fast_login` handling have reached the same point as before.
|
||||
|
||||
### Post-Login Referrer and Common Info Sequence
|
||||
|
||||
Current order to preserve:
|
||||
|
||||
1. Call `GET/SDK Adjust.getAttribution()` equivalent and build the Adjust digest.
|
||||
2. Read Play Install Referrer digest.
|
||||
3. Call `POST /v1/user/referrer` with `accolade=android_adjust`.
|
||||
4. Call `POST /v1/user/referrer` with `accolade=gg`.
|
||||
5. Call `GET /v1/user/common_info`.
|
||||
6. Apply `common_info` fields into user/global state.
|
||||
7. If home structure changed, request full home reload.
|
||||
8. Start organic attribution polling only after this first post-login sequence has completed.
|
||||
|
||||
Organic polling end behavior to preserve:
|
||||
|
||||
1. Poll every 5 seconds.
|
||||
2. Stop when attribution becomes non-organic or 5 minutes elapsed.
|
||||
3. After polling stops, always rerun:
|
||||
- `POST /v1/user/referrer` with `accolade=android_adjust`;
|
||||
- `POST /v1/user/referrer` with `accolade=gg`;
|
||||
- `GET /v1/user/common_info`.
|
||||
4. Only after the rerun finishes, trigger home full reload so `GET /v1/image/img2video/categories` is requested with the refreshed server-side state.
|
||||
|
||||
Do not make the category refresh race ahead of referrer/common-info reporting.
|
||||
|
||||
### Home Loading Sequence
|
||||
|
||||
Current order to preserve:
|
||||
|
||||
1. Home view may be created before login finishes.
|
||||
2. Category loading waits for login completion.
|
||||
3. Call `GET /v1/image/img2video/categories`.
|
||||
4. If `need_wait == true`, append the local `pets` pseudo-category.
|
||||
5. Select the first category.
|
||||
6. If the selected category is not `pets`, call `GET /v1/image/img2video/tasks`.
|
||||
7. If the selected category is `pets`, use `extConfig.items` and do not call task list for `pets`.
|
||||
8. After category/task updates, refresh playback visibility.
|
||||
|
||||
`UserState.requestHomeFullReload()` or its renamed equivalent must still re-enter this category loading sequence.
|
||||
|
||||
### Payment Sequence
|
||||
|
||||
Current order to preserve:
|
||||
|
||||
1. Android purchase stream listener starts early during app startup.
|
||||
2. Google Play order recovery runs after login completion and only after successful startup.
|
||||
3. Recharge purchase actions still emit tier/purchase events at the same user action points.
|
||||
4. Successful purchase verification updates account state before user-visible balance-dependent UI relies on it.
|
||||
5. Purchase success/failure Adjust and Facebook events keep the same event names/tokens and relative timing.
|
||||
6. Third-party payment webview polling keeps the same retry intervals and terminal status handling.
|
||||
|
||||
### Navigation and Lifecycle Sequence
|
||||
|
||||
Current order to preserve:
|
||||
|
||||
1. Route names remain stable unless every navigation caller and documentation reference is migrated together.
|
||||
2. Returning to home still resumes playback through the home playback nonce flow.
|
||||
3. Foreground resume still reports Facebook `activateApp`.
|
||||
4. Media picker flows still temporarily disable screen protection and restore it afterward.
|
||||
|
||||
## Refactor Rules
|
||||
|
||||
### Allowed
|
||||
|
||||
- Move files to new directories.
|
||||
- Split large files by concern.
|
||||
- Rename private classes, helpers, and variables when imports are updated.
|
||||
- Rename public widget classes if all route references and imports are updated in the same step.
|
||||
- Introduce barrel files only when they reduce noisy imports.
|
||||
- Extract controllers/coordinators from large `StatefulWidget` files.
|
||||
- Replace direct helper calls with small application-layer coordinator classes when behavior is unchanged.
|
||||
|
||||
### Not Allowed
|
||||
|
||||
- Changing endpoint paths, request parameters, event names, token keys, or analytics event tokens.
|
||||
- Changing local storage keys unless a migration is added.
|
||||
- Changing route names without updating all navigation callers.
|
||||
- Changing product IDs, purchase completion behavior, or order recovery timing.
|
||||
- Changing image/video playback rules while moving code.
|
||||
- Adding new state-management packages only for the refactor.
|
||||
- Reformatting or rewriting generated/platform files unrelated to the migration.
|
||||
|
||||
## File Splitting Targets
|
||||
|
||||
Prioritize these files because they currently carry several responsibilities:
|
||||
|
||||
- `lib/app.dart`
|
||||
- App widget, route table, lifecycle reporting, startup overlay, main scaffold.
|
||||
- Split into app shell, routes, lifecycle reporter, startup overlay, main scaffold.
|
||||
- `lib/core/auth/auth_service.dart`
|
||||
- Device identity, token restore, login retry, common info, referrer reporting, screen security.
|
||||
- Split into session bootstrapper, device identity provider, token vault, common info synchronizer, referrer post-login coordinator.
|
||||
- `lib/core/referrer/referrer_service.dart`
|
||||
- Adjust digest, Play referrer, organic polling.
|
||||
- Split into attribution digest builder, install referrer probe, attribution polling coordinator.
|
||||
- `lib/features/home/home_screen.dart`
|
||||
- Category loading, task loading, ext config parsing, playback visibility, UI layout.
|
||||
- Split into home controller, catalog data source, playback visibility coordinator, home view components.
|
||||
- `lib/features/generate_video/generate_video_screen.dart`
|
||||
- UI, upload, creation request, media selection, camera/album coordination.
|
||||
- Split into creation flow controller, upload coordinator, prompt/media components.
|
||||
- `lib/features/recharge/recharge_screen.dart`
|
||||
- Product rendering, purchase flow, third-party payment, event reporting.
|
||||
- Split into billing controller, purchase coordinator, payment option components.
|
||||
|
||||
## Suggested Migration Order
|
||||
|
||||
Work in small batches. After each batch, run analysis and at least one release/debug build smoke check.
|
||||
|
||||
### Phase 0: Baseline
|
||||
|
||||
Capture current behavior before moving code:
|
||||
|
||||
- Run `flutter analyze`.
|
||||
- Run the app with trace logs:
|
||||
- `flutter run --release --dart-define=APP_LOG_LEVEL=trace`
|
||||
- Verify logs for:
|
||||
- Adjust init and attribution callback.
|
||||
- Facebook install check.
|
||||
- fast login.
|
||||
- referrer reporting.
|
||||
- `common_info`.
|
||||
- categories and first task load.
|
||||
- purchase listener startup on Android.
|
||||
|
||||
### Phase 1: App Shell
|
||||
|
||||
Move without changing behavior:
|
||||
|
||||
- `main.dart` becomes a thin entrypoint.
|
||||
- App widget moves to `app_shell/pets_hero_app.dart`.
|
||||
- Route definitions move to `app_shell/routes/route_table.dart`.
|
||||
- Route observer moves to `app_shell/routes/route_observer.dart`.
|
||||
- Startup overlay moves to `app_shell/lifecycle/startup_overlay.dart`.
|
||||
- Facebook lifecycle/install-check logic moves to `app_shell/lifecycle/facebook_lifecycle_reporter.dart`.
|
||||
|
||||
Validation:
|
||||
|
||||
- App opens to the same tab.
|
||||
- Native splash is removed at the same time.
|
||||
- `activateApp` and install check logs still appear.
|
||||
|
||||
### Phase 2: Foundation Network and Config
|
||||
|
||||
Move:
|
||||
|
||||
- API config, API client, proxy client, crypto, response type.
|
||||
- Logging.
|
||||
- Facebook/Adjust config.
|
||||
|
||||
Validation:
|
||||
|
||||
- `fast_login`, `common_info`, category/task requests still show identical paths and body shapes in trace logs.
|
||||
- No endpoint path changes.
|
||||
|
||||
### Phase 3: Foundation Auth and Attribution
|
||||
|
||||
Move and split:
|
||||
|
||||
- Auth/session bootstrapper.
|
||||
- Device identity and sign generation.
|
||||
- Token vault.
|
||||
- Referrer digest and polling.
|
||||
- Adjust/Facebook event gateways.
|
||||
|
||||
Validation:
|
||||
|
||||
- New install and returning-user startup both work.
|
||||
- Natural/organic attribution polling still runs every 5s and stops after 5min or non-organic attribution.
|
||||
- After polling stops, referrer is reported, `common_info` refreshes, then categories reload.
|
||||
|
||||
### Phase 4: UI Kit
|
||||
|
||||
Move shared visual widgets:
|
||||
|
||||
- Bottom navigation.
|
||||
- Top navigation.
|
||||
- Credits badge.
|
||||
- Tab selector scope or replacement shell coordinator.
|
||||
|
||||
Validation:
|
||||
|
||||
- Bottom tabs, top bar, credits display, and route behavior are unchanged.
|
||||
|
||||
### Phase 5: Home Module
|
||||
|
||||
Move and split:
|
||||
|
||||
- Entities:
|
||||
- category item
|
||||
- task item
|
||||
- ext config item
|
||||
- Data:
|
||||
- category/task API wrappers
|
||||
- JSON mappers
|
||||
- Application:
|
||||
- category/task loading coordinator
|
||||
- ext config policy
|
||||
- playback visibility coordinator
|
||||
- Presentation:
|
||||
- home view
|
||||
- tab row
|
||||
- video card
|
||||
|
||||
Validation:
|
||||
|
||||
- `need_wait` behavior unchanged.
|
||||
- `pets` pseudo-category behavior unchanged.
|
||||
- Video autoplay visibility behavior unchanged.
|
||||
- `UserState.requestHomeFullReload()` still reloads categories and tasks.
|
||||
|
||||
### Phase 6: Creation Module
|
||||
|
||||
Move:
|
||||
|
||||
- Generate screen.
|
||||
- Progress screen.
|
||||
- Result screen.
|
||||
- In-app camera page.
|
||||
- Album picker.
|
||||
- Image compression helpers if only used by creation.
|
||||
|
||||
Validation:
|
||||
|
||||
- Album/camera selection works.
|
||||
- Upload and task creation request shapes are unchanged.
|
||||
- Progress polling and result navigation are unchanged.
|
||||
|
||||
### Phase 7: Gallery Module
|
||||
|
||||
Move:
|
||||
|
||||
- Gallery screen.
|
||||
- Gallery task item.
|
||||
- Upload cover store.
|
||||
- Thumbnail cache.
|
||||
|
||||
Validation:
|
||||
|
||||
- Gallery loads the same data.
|
||||
- Video thumbnails/cache behavior unchanged.
|
||||
- Navigation to result/generate flows unchanged.
|
||||
|
||||
### Phase 8: Billing Module
|
||||
|
||||
Move:
|
||||
|
||||
- Recharge screen.
|
||||
- Payment webview.
|
||||
- Points history.
|
||||
- Google Play purchase service.
|
||||
- Payment models.
|
||||
|
||||
Validation:
|
||||
|
||||
- Products render the same.
|
||||
- Google Play purchase and pending purchase recovery work.
|
||||
- Third-party payment webview flow works.
|
||||
- Purchase success/failure Adjust and Facebook events are unchanged.
|
||||
|
||||
### Phase 9: Profile Module
|
||||
|
||||
Move:
|
||||
|
||||
- Profile screen.
|
||||
- Account deletion / account display dependencies if any.
|
||||
|
||||
Validation:
|
||||
|
||||
- Profile data, recharge navigation, account actions are unchanged.
|
||||
|
||||
### Phase 10: Cleanup
|
||||
|
||||
- Remove old empty directories.
|
||||
- Remove transitional imports.
|
||||
- Keep barrel files minimal.
|
||||
- Update docs that reference old paths:
|
||||
- `docs/app_startup.md`
|
||||
- `docs/home.md`
|
||||
- `docs/payment_flow.md`
|
||||
- `docs/generate_video.md`
|
||||
- `docs/user_login.md`
|
||||
|
||||
## Import Strategy
|
||||
|
||||
Use package imports for cross-module references:
|
||||
|
||||
```dart
|
||||
import 'package:pets_hero_ai/foundation/network/api_response.dart';
|
||||
import 'package:pets_hero_ai/modules/home/presentation/home_view.dart';
|
||||
```
|
||||
|
||||
Avoid long chains of relative imports across modules.
|
||||
|
||||
Optional barrel files:
|
||||
|
||||
```text
|
||||
foundation/network/network.dart
|
||||
modules/home/home.dart
|
||||
modules/billing/billing.dart
|
||||
ui_kit/ui_kit.dart
|
||||
```
|
||||
|
||||
Use a barrel only when at least three external files import the same group.
|
||||
|
||||
## Naming Style
|
||||
|
||||
Use a noticeably different naming style while keeping semantics clear:
|
||||
|
||||
- `AuthService` → `SessionBootstrapper`
|
||||
- `ReferrerService` → `AttributionCoordinator`
|
||||
- `ImageApi` category/task calls → `HomeCatalogApi`
|
||||
- `UserState` → `AccountState`
|
||||
- `AdjustEvents` → `MarketingEventGateway`
|
||||
- `HomeScreen` → `HomeView`
|
||||
- `GenerateVideoScreen` → `CreationView`
|
||||
- `RechargeScreen` → `BillingView`
|
||||
|
||||
Prefer:
|
||||
|
||||
- `Coordinator` for orchestration.
|
||||
- `Gateway` for third-party SDK/event wrappers.
|
||||
- `Probe` for platform capability checks.
|
||||
- `Vault` for local secure-ish persistence.
|
||||
- `Policy` for pure decision logic.
|
||||
- `Mapper` for JSON conversion.
|
||||
|
||||
## State Management Guidance
|
||||
|
||||
Do not add Bloc/Riverpod/Provider just to make the rewrite look different.
|
||||
|
||||
For this project, prefer lightweight application-layer controllers:
|
||||
|
||||
- Keep existing `ValueNotifier`-based global state where behavior depends on it.
|
||||
- Extract complex `StatefulWidget` logic into controllers that are owned by the widget state.
|
||||
- Use `ListenableBuilder`/`ValueListenableBuilder` where the existing behavior already relies on notifiers.
|
||||
- Only replace `setState` when the extracted controller makes ownership clearer.
|
||||
|
||||
## Verification Checklist Per Phase
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
flutter analyze
|
||||
```
|
||||
|
||||
For Android smoke checks:
|
||||
|
||||
```sh
|
||||
flutter run --release --dart-define=APP_LOG_LEVEL=trace
|
||||
```
|
||||
|
||||
Check logs for changed phases:
|
||||
|
||||
- Request paths and query/body fields.
|
||||
- Auth/referrer/common_info sequence.
|
||||
- Adjust/Facebook events.
|
||||
- Home category/task reloads.
|
||||
- Purchase listener and recovery startup.
|
||||
|
||||
For every phase that touches startup, auth, referrer, home, or billing, compare the trace log against a pre-refactor baseline. The comparison must verify order, not only presence:
|
||||
|
||||
```text
|
||||
Adjust init
|
||||
runApp
|
||||
Auth bootstrap
|
||||
Google Play listener
|
||||
fast_login
|
||||
post-login referrer(android_adjust)
|
||||
post-login referrer(gg)
|
||||
common_info
|
||||
categories
|
||||
tasks, when applicable
|
||||
organic polling stop
|
||||
referrer(android_adjust)
|
||||
referrer(gg)
|
||||
common_info
|
||||
categories
|
||||
```
|
||||
|
||||
If any request appears earlier/later than the baseline, stop the migration and decide explicitly whether the order change is acceptable. The default answer is no.
|
||||
|
||||
Manual UI checks:
|
||||
|
||||
- App launch.
|
||||
- Home list and tabs.
|
||||
- Generate flow.
|
||||
- Gallery flow.
|
||||
- Recharge flow.
|
||||
- Profile flow.
|
||||
|
||||
## Recommended Work Protocol
|
||||
|
||||
1. Move one module or one foundation slice at a time.
|
||||
2. Keep old behavior tests/manual traces open while migrating.
|
||||
3. Update imports immediately after every move.
|
||||
4. Run analyzer before continuing to the next phase.
|
||||
5. Do not combine architecture moves with feature fixes.
|
||||
6. If a behavior bug is discovered, document it separately and decide whether to fix before or after migration.
|
||||
7. Commit after each compiling phase if using git commits.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
The refactor is complete only when:
|
||||
|
||||
- No imports reference the old `core/`, `features/`, or `shared/` directories unless those directories intentionally remain as compatibility shims.
|
||||
- The app compiles cleanly.
|
||||
- Startup, login, attribution, home reload, generation, gallery, recharge, and profile flows match baseline behavior.
|
||||
- Trace logs show the same API paths and event names as before.
|
||||
- Old large files have been split along the target architecture boundaries.
|
||||
- Docs and any route/import examples use the new paths.
|
||||
|
||||
@ -26,6 +26,10 @@
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>FacebookAutoLogAppEventsEnabled</key>
|
||||
<false/>
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>fb</string>
|
||||
</array>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
|
||||
18
lib/app.dart
18
lib/app.dart
@ -3,6 +3,7 @@ import 'dart:async';
|
||||
import 'package:facebook_app_events/facebook_app_events.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_native_splash/flutter_native_splash.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import 'core/auth/auth_service.dart';
|
||||
import 'core/nav/app_route_observer.dart';
|
||||
@ -46,6 +47,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// Remove native splash; Flutter login overlay takes over if startup is still running.
|
||||
FlutterNativeSplash.remove();
|
||||
unawaited(_logFacebookInstallState('first_frame'));
|
||||
_reportFacebookActivateApp('first_frame');
|
||||
});
|
||||
}
|
||||
@ -65,6 +67,22 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
||||
});
|
||||
}
|
||||
|
||||
/// Detect whether a local app can handle Facebook's URL scheme.
|
||||
///
|
||||
/// iOS requires `LSApplicationQueriesSchemes` to include `fb`; Android 11+
|
||||
/// requires a matching `<queries>` entry in AndroidManifest.
|
||||
Future<void> _logFacebookInstallState(String reason) async {
|
||||
const scheme = 'fb://';
|
||||
try {
|
||||
final installed = await canLaunchUrl(Uri.parse(scheme));
|
||||
_fbLog.d(
|
||||
'Facebook app install check ($reason): installed=$installed scheme=$scheme',
|
||||
);
|
||||
} catch (e, st) {
|
||||
_fbLog.e('Facebook app install check failed ($reason)', e, st);
|
||||
}
|
||||
}
|
||||
|
||||
/// Foreground resume; cold start also uses addPostFrameCallback in initState.
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
|
||||
@ -553,6 +553,20 @@ class AuthService {
|
||||
static Future<void> _runPostLoginReferrerWork(String uid, String deviceId) async {
|
||||
try {
|
||||
await _reportBothReferrersAndRefreshCommonInfo(uid, deviceId);
|
||||
// 首次归因可能是“延迟到的自然量”:开启 5s 轮询、最长 5min。
|
||||
// 轮询结束后,无论是否检测到非自然量都重新上报归因 + 刷新 common_info,
|
||||
// 再触发首页完整重载(_loadCategories 会重拉分类数据)。
|
||||
unawaited(ReferrerService.startOrganicPolling(
|
||||
onStopped: (detectedNonOrganic) async {
|
||||
try {
|
||||
await _reportBothReferrersAndRefreshCommonInfo(uid, deviceId);
|
||||
} catch (e, st) {
|
||||
_logMsg('organic poll: 重新上报归因/common_info 异常: $e');
|
||||
_logMsg('organic poll: 堆栈 $st');
|
||||
}
|
||||
UserState.requestHomeFullReload();
|
||||
},
|
||||
));
|
||||
} catch (e, st) {
|
||||
_logMsg('referrer/common_info 后台任务异常: $e');
|
||||
_logMsg('referrer/common_info 堆栈: $st');
|
||||
|
||||
@ -6,10 +6,14 @@ import 'package:adjust_sdk/adjust.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:play_install_referrer/play_install_referrer.dart';
|
||||
|
||||
import '../log/app_logger.dart';
|
||||
|
||||
/// 归因信息服务(优先从 Adjust 获取,fallback 使用 Play Install Referrer)
|
||||
class ReferrerService {
|
||||
ReferrerService._();
|
||||
|
||||
static final _log = AppLogger('ReferrerService');
|
||||
|
||||
static String? _cachedReferrer;
|
||||
static String _referrerSource = 'gg';
|
||||
static final Completer<String?> _completer = Completer<String?>();
|
||||
@ -27,6 +31,15 @@ class ReferrerService {
|
||||
/// Play Install Referrer 读取超时(部分机型/系统上可能极慢)
|
||||
static const Duration _playInstallReferrerTimeout = Duration(seconds: 10);
|
||||
|
||||
/// 自然量轮询:每次检查间隔
|
||||
static const Duration _organicPollInterval = Duration(seconds: 5);
|
||||
|
||||
/// 自然量轮询:最长持续时间(达到后无论是否仍是自然量都停止)
|
||||
static const Duration _organicPollMaxDuration = Duration(minutes: 5);
|
||||
|
||||
static bool _organicPollRunning = false;
|
||||
static Timer? _organicPollTimer;
|
||||
|
||||
/// 由 Adjust attributionCallback 调用,首次安装时归因往往通过此回调返回(晚于 getAttributionWithTimeout)
|
||||
static void receiveAttributionFromCallback(AdjustAttribution attribution) {
|
||||
if (!_attributionCallbackCompleter.isCompleted) {
|
||||
@ -143,4 +156,133 @@ class ReferrerService {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动“自然量→渠道”轮询:仅当 Adjust 与 Play Install Referrer 当前都判定为自然量时开始;
|
||||
/// 之后每 [_organicPollInterval] 重新判定一次,直到任一渠道变为非自然量或超过
|
||||
/// [_organicPollMaxDuration]。结束时调用 [onStopped],参数表示是否检测到非自然量。
|
||||
///
|
||||
/// 多次调用是幂等的:已在轮询时直接返回。
|
||||
static Future<void> startOrganicPolling({
|
||||
required Future<void> Function(bool detectedNonOrganic) onStopped,
|
||||
}) async {
|
||||
if (_organicPollRunning) {
|
||||
_log.d('startOrganicPolling: already running, skip');
|
||||
return;
|
||||
}
|
||||
|
||||
final initiallyOrganic = await _isCurrentlyOrganic();
|
||||
if (!initiallyOrganic) {
|
||||
_log.d('startOrganicPolling: 初始归因即为渠道,无需轮询');
|
||||
return;
|
||||
}
|
||||
|
||||
_organicPollRunning = true;
|
||||
final startedAt = DateTime.now();
|
||||
var tickCount = 0;
|
||||
_log.d(
|
||||
'startOrganicPolling: 启动自然量轮询,每 ${_organicPollInterval.inSeconds}s 一次,最长 ${_organicPollMaxDuration.inMinutes}min');
|
||||
|
||||
Future<void> finish(bool detectedNonOrganic, {required String reason}) async {
|
||||
_stopOrganicPoll();
|
||||
_log.d('startOrganicPolling: 结束,原因=$reason changed=$detectedNonOrganic');
|
||||
try {
|
||||
await onStopped(detectedNonOrganic);
|
||||
} catch (e, st) {
|
||||
_log.d('startOrganicPolling: onStopped 回调异常: $e');
|
||||
_log.d('startOrganicPolling: stack $st');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> tick() async {
|
||||
if (!_organicPollRunning) return;
|
||||
|
||||
tickCount++;
|
||||
final elapsed = DateTime.now().difference(startedAt);
|
||||
if (elapsed >= _organicPollMaxDuration) {
|
||||
await finish(false, reason: 'timeout');
|
||||
return;
|
||||
}
|
||||
|
||||
final adjustNetwork = await _readAdjustNetwork();
|
||||
final adjustOrganic = _attributionIsOrganicByNetwork(adjustNetwork);
|
||||
final pirRef = await _readPlayInstallReferrerRaw();
|
||||
final pirOrganic = _installReferrerIsOrganic(pirRef);
|
||||
final stillOrganic = adjustOrganic && pirOrganic;
|
||||
_log.d(
|
||||
'organic poll tick#$tickCount elapsed=${elapsed.inSeconds}s '
|
||||
'adjustNetwork="${adjustNetwork ?? '<null>'}" adjustOrganic=$adjustOrganic '
|
||||
'pir="$pirRef" pirOrganic=$pirOrganic stillOrganic=$stillOrganic');
|
||||
|
||||
if (!_organicPollRunning) return;
|
||||
|
||||
if (!stillOrganic) {
|
||||
await finish(true, reason: 'detectedNonOrganic');
|
||||
return;
|
||||
}
|
||||
|
||||
_organicPollTimer = Timer(_organicPollInterval, () {
|
||||
if (!_organicPollRunning) return;
|
||||
unawaited(tick());
|
||||
});
|
||||
}
|
||||
|
||||
_organicPollTimer = Timer(_organicPollInterval, () {
|
||||
if (!_organicPollRunning) return;
|
||||
unawaited(tick());
|
||||
});
|
||||
}
|
||||
|
||||
static void _stopOrganicPoll() {
|
||||
_organicPollTimer?.cancel();
|
||||
_organicPollTimer = null;
|
||||
_organicPollRunning = false;
|
||||
}
|
||||
|
||||
/// 当 Adjust 归因和 Play Install Referrer 都判定为自然量时返回 true
|
||||
static Future<bool> _isCurrentlyOrganic() async {
|
||||
final adjustNetwork = await _readAdjustNetwork();
|
||||
final adjustOrganic = _attributionIsOrganicByNetwork(adjustNetwork);
|
||||
final pirRef = await _readPlayInstallReferrerRaw();
|
||||
final pirOrganic = _installReferrerIsOrganic(pirRef);
|
||||
return adjustOrganic && pirOrganic;
|
||||
}
|
||||
|
||||
/// 读取 Adjust 当前归因的 network 字段(异常返回 null,按未归因处理)
|
||||
static Future<String?> _readAdjustNetwork() async {
|
||||
try {
|
||||
final attr = await Adjust.getAttribution();
|
||||
return attr.network;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// network 为空 / 'organic' 视为自然量(含未归因情况)
|
||||
static bool _attributionIsOrganicByNetwork(String? network) {
|
||||
final n = (network ?? '').trim().toLowerCase();
|
||||
return n.isEmpty || n == 'organic';
|
||||
}
|
||||
|
||||
/// 读取 Play Install Referrer 原始字符串(非 Android / 异常返回空串)
|
||||
static Future<String> _readPlayInstallReferrerRaw() async {
|
||||
if (defaultTargetPlatform != TargetPlatform.android) return '';
|
||||
try {
|
||||
final details = await PlayInstallReferrer.installReferrer
|
||||
.timeout(_playInstallReferrerTimeout);
|
||||
return details.installReferrer ?? '';
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/// Google Play 自然量典型 referrer:`utm_source=google-play&utm_medium=organic`;
|
||||
/// 空串/缺少 `utm_medium=` 视为尚未归因,按自然量处理。
|
||||
static bool _installReferrerIsOrganic(String ref) {
|
||||
final s = ref.trim();
|
||||
if (s.isEmpty) return true;
|
||||
final lower = s.toLowerCase();
|
||||
if (lower.contains('utm_medium=organic')) return true;
|
||||
if (!lower.contains('utm_medium=')) return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
name: pets_hero_ai
|
||||
description: PetsHero AI Application.
|
||||
publish_to: 'none'
|
||||
version: 1.2.0+120
|
||||
version: 1.2.1+121
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user