Add 'app/' from commit 'a11a66886941bba128d89c1124115e1b1c87a128'
git-subtree-dir: app git-subtree-mainline:6ebd902c6bgit-subtree-split:a11a668869
@@ -0,0 +1,9 @@
|
||||
# Flutter/Dart
|
||||
.dart_tool/
|
||||
build/
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
*.iml
|
||||
.idea/
|
||||
# 系统
|
||||
.DS_Store
|
||||
@@ -0,0 +1,45 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "058e0af2c2b57e369d905a03ac9748b0ebf543c6"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
|
||||
base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
|
||||
- platform: android
|
||||
create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
|
||||
base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
|
||||
- platform: ios
|
||||
create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
|
||||
base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
|
||||
- platform: linux
|
||||
create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
|
||||
base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
|
||||
- platform: macos
|
||||
create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
|
||||
base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
|
||||
- platform: web
|
||||
create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
|
||||
base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
|
||||
- platform: windows
|
||||
create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
|
||||
base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
|
||||
|
||||
# User provided section
|
||||
|
||||
# List of Local paths (relative to this file) that should be
|
||||
# ignored by the migrate tool.
|
||||
#
|
||||
# Files that are not part of the templates will be ignored by default.
|
||||
unmanaged_files:
|
||||
- 'lib/main.dart'
|
||||
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||
@@ -0,0 +1,17 @@
|
||||
# slogan_app
|
||||
|
||||
A new Flutter project.
|
||||
|
||||
## Getting Started
|
||||
|
||||
This project is a starting point for a Flutter application.
|
||||
|
||||
A few resources to get you started if this is your first Flutter project:
|
||||
|
||||
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
|
||||
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
||||
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
|
||||
|
||||
For help getting started with Flutter development, view the
|
||||
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
||||
samples, guidance on mobile development, and a full API reference.
|
||||
@@ -0,0 +1,28 @@
|
||||
# This file configures the analyzer, which statically analyzes Dart code to
|
||||
# check for errors, warnings, and lints.
|
||||
#
|
||||
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
|
||||
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
|
||||
# invoked from the command line by running `flutter analyze`.
|
||||
|
||||
# The following line activates a set of recommended lints for Flutter apps,
|
||||
# packages, and plugins designed to encourage good coding practices.
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
linter:
|
||||
# The lint rules applied to this project can be customized in the
|
||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||
# included above or to enable additional rules. A list of all available lints
|
||||
# and their documentation is published at https://dart.dev/lints.
|
||||
#
|
||||
# Instead of disabling a lint rule for the entire project in the
|
||||
# section below, it can also be suppressed for a single line of code
|
||||
# or a specific dart file by using the `// ignore: name_of_lint` and
|
||||
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
|
||||
# producing the lint.
|
||||
rules:
|
||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
@@ -0,0 +1,14 @@
|
||||
gradle-wrapper.jar
|
||||
/.gradle
|
||||
/captures/
|
||||
/gradlew
|
||||
/gradlew.bat
|
||||
/local.properties
|
||||
GeneratedPluginRegistrant.java
|
||||
.cxx/
|
||||
|
||||
# Remember to never publicly share your keystore.
|
||||
# See https://flutter.dev/to/reference-keystore
|
||||
key.properties
|
||||
**/*.keystore
|
||||
**/*.jks
|
||||
@@ -0,0 +1,45 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.slogan.slogan_app"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "com.slogan.slogan_app"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
@@ -0,0 +1,45 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application
|
||||
android:label="slogan_app"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
to determine the Window background behind the Flutter UI. -->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||
|
||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.slogan.slogan_app
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
|
After Width: | Height: | Size: 544 B |
|
After Width: | Height: | Size: 442 B |
|
After Width: | Height: | Size: 721 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
@@ -0,0 +1,24 @@
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
val newBuildDir: Directory =
|
||||
rootProject.layout.buildDirectory
|
||||
.dir("../../build")
|
||||
.get()
|
||||
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||
|
||||
subprojects {
|
||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||
}
|
||||
subprojects {
|
||||
project.evaluationDependsOn(":app")
|
||||
}
|
||||
|
||||
tasks.register<Delete>("clean") {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
# This newDsl flag was added by the Flutter template
|
||||
android.newDsl=false
|
||||
# This builtInKotlin flag was added by the Flutter template
|
||||
android.builtInKotlin=false
|
||||
@@ -0,0 +1,5 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip
|
||||
@@ -0,0 +1,26 @@
|
||||
pluginManagement {
|
||||
val flutterSdkPath =
|
||||
run {
|
||||
val properties = java.util.Properties()
|
||||
file("local.properties").inputStream().use { properties.load(it) }
|
||||
val flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
|
||||
flutterSdkPath
|
||||
}
|
||||
|
||||
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "9.0.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
@@ -0,0 +1,969 @@
|
||||
# 商业化 P0 实现计划(客户端)· slogan-app
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 会员中心(套餐展示 → 下单 → 系统浏览器支付 → 轮询确认)+ 广告激励入口(Mock 激励视频 → 领取加次/体验会员),后端未配置时接口报错自动隐藏充值入口。
|
||||
|
||||
**Architecture:** /commercial 页重构为会员中心(home 第 4 Tab 与路由都指向它)。新增 `lib/core/ads/`(AdsService 抽象 + Mock 实现,P1 换穿山甲)、`lib/features/member/`(provider + 会员中心页 + 支付页)。支付用 `url_launcher` 打开系统浏览器,`/pay` 页 2s 轮询订单状态(60s 上限),成功后刷新会员状态。iOS 端隐藏充值入口(App Store 政策),保留广告激励。
|
||||
|
||||
**Tech Stack:** Flutter 3.44 / Riverpod 3 / go_router 17 / dio 5 / url_launcher ^6.3.0
|
||||
|
||||
**关联 spec:** `docs/superpowers/specs/2026-07-31-commerce-monetization-design.md` 支柱 A/B 客户端部分。
|
||||
|
||||
**验证方式(沿用 MVP 惯例):** `dart analyze`(中文路径下 flutter analyze 崩溃)、`flutter test`、`flutter build web --release` 全量编译、mock 后端冒烟。
|
||||
|
||||
---
|
||||
|
||||
## 任务总览与文件映射
|
||||
|
||||
| 任务 | 文件 |
|
||||
|---|---|
|
||||
| T1 | `pubspec.yaml`、`lib/core/ads/ads_service.dart`、`test/features/member/benefits_test.dart` |
|
||||
| T2 | `lib/features/member/member_provider.dart` |
|
||||
| T3 | `lib/features/member/member_center_page.dart`(新建)、`lib/features/commercial/commercial_page.dart`(删除)、`lib/features/home/home_page.dart`、`lib/main.dart` |
|
||||
| T4 | `lib/features/member/pay_page.dart`、`lib/main.dart` 路由 |
|
||||
| T5 | analyze + test + build + 冒烟 |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: url_launcher 依赖 + 广告抽象 + 权益文案纯函数(TDD)
|
||||
|
||||
**Files:**
|
||||
- Modify: `pubspec.yaml`
|
||||
- Create: `lib/core/ads/ads_service.dart`
|
||||
- Test: `test/features/member/benefits_test.dart`
|
||||
|
||||
- [ ] **Step 1: 写失败测试**(权益 key → 中文文案;benefitTexts 尚未存在)
|
||||
|
||||
`test/features/member/benefits_test.dart`:
|
||||
```dart
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:slogan_app/features/member/member_provider.dart';
|
||||
|
||||
void main() {
|
||||
test('benefitTexts 映射已知权益文案,未知 key 原样保留', () {
|
||||
final info = MemberInfo(
|
||||
isVip: true,
|
||||
expireAt: '2026-08-30 12:00:00',
|
||||
planName: '月卡',
|
||||
benefits: const ['effect_unlimited', 'unknown_key'],
|
||||
);
|
||||
expect(benefitTexts(info), ['无限效果图', 'unknown_key']);
|
||||
});
|
||||
|
||||
test('benefitTexts 空权益返回空列表', () {
|
||||
final info = MemberInfo(
|
||||
isVip: false,
|
||||
expireAt: '',
|
||||
planName: '',
|
||||
benefits: const [],
|
||||
);
|
||||
expect(benefitTexts(info), isEmpty);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行确认失败**
|
||||
|
||||
Run: `dart analyze lib test 2>&1 | head -5` Expected: 报 `member_provider.dart` 不存在 / import 失败
|
||||
|
||||
- [ ] **Step 3: pubspec 加 url_launcher**
|
||||
|
||||
```yaml
|
||||
url_launcher: ^6.3.0
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 广告抽象 `lib/core/ads/ads_service.dart`**
|
||||
|
||||
```dart
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
/// 广告服务抽象:P0 用 Mock(保证业务链路可开发可测),P1 换穿山甲 SDK
|
||||
abstract class AdsService {
|
||||
bool get enabled;
|
||||
Future<bool> showRewarded();
|
||||
}
|
||||
|
||||
/// 本地模拟激励视频(约 1 秒"播放"后返回完整观看)
|
||||
class MockAdsService implements AdsService {
|
||||
@override
|
||||
bool get enabled => true;
|
||||
|
||||
@override
|
||||
Future<bool> showRewarded() async {
|
||||
await Future.delayed(const Duration(milliseconds: 900));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
final adsServiceProvider = Provider<AdsService>((ref) {
|
||||
// P1:AppConfig.pangleAppId 非空时替换为 PangleAdsService(穿山甲 SDK 实现)
|
||||
return MockAdsService();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add pubspec.yaml lib/core/ads test/features/member
|
||||
git commit -m "feat: 广告服务抽象(Mock 激励视频)+ url_launcher 依赖"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: member provider(会员状态/套餐/下单/轮询/领奖)
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/features/member/member_provider.dart`
|
||||
|
||||
- [ ] **Step 1: 实现 member_provider.dart**(含 Task 1 测试依赖的 `MemberInfo` 与 `benefitTexts`)
|
||||
|
||||
```dart
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/network/api_client.dart';
|
||||
|
||||
class MemberInfo {
|
||||
final bool isVip;
|
||||
final String expireAt;
|
||||
final String planName;
|
||||
final List<String> benefits;
|
||||
|
||||
const MemberInfo({
|
||||
required this.isVip,
|
||||
required this.expireAt,
|
||||
required this.planName,
|
||||
required this.benefits,
|
||||
});
|
||||
|
||||
factory MemberInfo.fromJson(Map<String, dynamic> e) => MemberInfo(
|
||||
isVip: e['is_vip'] as bool? ?? false,
|
||||
expireAt: e['expire_at'] as String? ?? '',
|
||||
planName: e['plan_name'] as String? ?? '',
|
||||
benefits: (e['benefits'] as List<dynamic>? ?? []).cast<String>(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 权益 key → 文案
|
||||
const benefitLabels = {
|
||||
'effect_unlimited': '无限效果图',
|
||||
'ai_priority': '优先 AI 方案',
|
||||
'cps_commission_x15': '返现加成 1.5x',
|
||||
'store_discount': '门店折扣',
|
||||
};
|
||||
|
||||
List<String> benefitTexts(MemberInfo m) =>
|
||||
m.benefits.map((b) => benefitLabels[b] ?? b).toList();
|
||||
|
||||
class MemberNotifier extends AsyncNotifier<MemberInfo> {
|
||||
@override
|
||||
Future<MemberInfo> build() async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final data = await api.get<Map<String, dynamic>>('/member/status');
|
||||
return MemberInfo.fromJson(data ?? {});
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
state = await AsyncValue.guard(build);
|
||||
}
|
||||
|
||||
/// 领取广告激励(服务端限频);adType: effect_extra | vip_trial
|
||||
/// 返回当日剩余次数;超出限频抛 ApiException
|
||||
Future<int> claimReward(String adType) async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final data =
|
||||
await api.post<Map<String, dynamic>>('/ad/reward/claim', {'ad_type': adType});
|
||||
await refresh(); // vip_trial 可能开通体验会员
|
||||
return (data?['reward']?['remaining_today'] as num?)?.toInt() ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
final memberProvider =
|
||||
AsyncNotifierProvider<MemberNotifier, MemberInfo>(MemberNotifier.new);
|
||||
|
||||
class MemberPlan {
|
||||
final int id;
|
||||
final String name;
|
||||
final int priceFen;
|
||||
final int durationDays;
|
||||
final List<String> features;
|
||||
|
||||
const MemberPlan({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.priceFen,
|
||||
required this.durationDays,
|
||||
required this.features,
|
||||
});
|
||||
|
||||
factory MemberPlan.fromJson(Map<String, dynamic> e) => MemberPlan(
|
||||
id: (e['id'] as num).toInt(),
|
||||
name: e['name'] as String? ?? '',
|
||||
priceFen: (e['price_fen'] as num?)?.toInt() ?? 0,
|
||||
durationDays: (e['duration_days'] as num?)?.toInt() ?? 30,
|
||||
features: _parseFeatures(e['features'] as String? ?? ''),
|
||||
);
|
||||
|
||||
static List<String> _parseFeatures(String s) {
|
||||
try {
|
||||
return (jsonDecode(s) as List<dynamic>).cast<String>();
|
||||
} catch (_) {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
String get priceText =>
|
||||
'¥${(priceFen / 100).toStringAsFixed(priceFen % 100 == 0 ? 0 : 1)}';
|
||||
}
|
||||
|
||||
final memberPlanProvider = FutureProvider<List<MemberPlan>>((ref) async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final list = await api.get<List<dynamic>>('/member/plan/list');
|
||||
return (list ?? [])
|
||||
.map((e) => MemberPlan.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
});
|
||||
|
||||
class OrderResult {
|
||||
final String orderNo;
|
||||
final String payUrl;
|
||||
|
||||
const OrderResult({required this.orderNo, required this.payUrl});
|
||||
}
|
||||
|
||||
/// 创建支付订单(后端调虎皮棋下单,返回收银台 URL)
|
||||
Future<OrderResult> createMemberOrder(WidgetRef ref, int planId) async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final data =
|
||||
await api.post<Map<String, dynamic>>('/member/order/create', {'plan_id': planId});
|
||||
return OrderResult(
|
||||
orderNo: data?['order_no'] as String? ?? '',
|
||||
payUrl: data?['pay_url'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
/// 订单状态(支付页 2s 轮询):pending | paid | closed
|
||||
Future<String> fetchOrderStatus(WidgetRef ref, String orderNo) async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final data = await api.get<Map<String, dynamic>>('/member/order/status',
|
||||
query: {'order_no': orderNo});
|
||||
return data?['status'] as String? ?? '';
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行测试 + 静态检查**
|
||||
|
||||
Run: `dart analyze lib/features/member lib/core/ads 2>&1 | tail -3` Expected: 无 issue
|
||||
Run: `flutter test test/features/member/benefits_test.dart` Expected: 2 个用例 PASS
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add lib/features/member
|
||||
git commit -m "feat: 会员 provider(状态/套餐/下单/轮询/领奖)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 会员中心页(commercial 重构)+ 入口切换
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/features/member/member_center_page.dart`
|
||||
- Delete: `lib/features/commercial/commercial_page.dart`
|
||||
- Modify: `lib/features/home/home_page.dart`
|
||||
- Modify: `lib/main.dart`
|
||||
|
||||
- [ ] **Step 1: 创建 member_center_page.dart**(完整代码,含会员卡/套餐弹层/广告激励卡/合作门店)
|
||||
|
||||
```dart
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/ads/ads_service.dart';
|
||||
import '../../core/config/app_config.dart';
|
||||
import '../../shared/widgets/error_view.dart';
|
||||
import '../../shared/widgets/loading_view.dart';
|
||||
import 'member_provider.dart';
|
||||
import 'pay_page.dart';
|
||||
|
||||
class PartnerStoreInfo {
|
||||
final int id;
|
||||
final String name;
|
||||
final int type; // 1 造型/发型店,2 服装店
|
||||
final String address;
|
||||
final String commissionPolicy;
|
||||
|
||||
const PartnerStoreInfo({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.type,
|
||||
required this.address,
|
||||
required this.commissionPolicy,
|
||||
});
|
||||
}
|
||||
|
||||
class StoreNotifier extends AsyncNotifier<List<PartnerStoreInfo>> {
|
||||
@override
|
||||
Future<List<PartnerStoreInfo>> build() async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final list = await api.get<List<dynamic>>('/partner-store/list');
|
||||
return list
|
||||
.map((e) => PartnerStoreInfo(
|
||||
id: (e['id'] as num).toInt(),
|
||||
name: e['name'] as String? ?? '',
|
||||
type: (e['type'] as num?)?.toInt() ?? 1,
|
||||
address: e['address'] as String? ?? '',
|
||||
commissionPolicy: e['commission_policy'] as String? ?? '',
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(build);
|
||||
}
|
||||
}
|
||||
|
||||
final storeProvider =
|
||||
AsyncNotifierProvider<StoreNotifier, List<PartnerStoreInfo>>(StoreNotifier.new);
|
||||
|
||||
const _storeTypeLabels = {1: '造型', 2: '服装'};
|
||||
|
||||
/// 会员中心:会员状态/套餐充值/广告激励 + 合作门店(P0;最近优惠 P1)
|
||||
class MemberCenterPage extends ConsumerStatefulWidget {
|
||||
const MemberCenterPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<MemberCenterPage> createState() => _MemberCenterPageState();
|
||||
}
|
||||
|
||||
class _MemberCenterPageState extends ConsumerState<MemberCenterPage> {
|
||||
int? _typeFilter;
|
||||
bool _rewarding = false;
|
||||
|
||||
bool get _isIOS => Platform.isIOS;
|
||||
|
||||
Future<void> _openPlans() async {
|
||||
final plans = await showModalBottomSheet<List<MemberPlan>>(
|
||||
context: context,
|
||||
builder: (ctx) => const _PlanSheet(),
|
||||
);
|
||||
if (plans == null || !mounted) return;
|
||||
try {
|
||||
final result = await createMemberOrder(ref, plans.id);
|
||||
if (!mounted || result.payUrl.isEmpty) return;
|
||||
await context.push('/pay', extra: PayArgs(orderNo: result.orderNo, payUrl: result.payUrl));
|
||||
ref.read(memberProvider.notifier).refresh();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
Fluttertoast.showToast(
|
||||
msg: e.toString().replaceFirst('Exception: ', ''));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _claimReward(String adType, String successMsg) async {
|
||||
if (_rewarding) return;
|
||||
final ads = ref.read(adsServiceProvider);
|
||||
if (!ads.enabled) {
|
||||
Fluttertoast.showToast(msg: '广告功能暂未开通');
|
||||
return;
|
||||
}
|
||||
setState(() => _rewarding = true);
|
||||
try {
|
||||
final watched = await ads.showRewarded();
|
||||
if (!watched) {
|
||||
Fluttertoast.showToast(msg: '未完整观看,无法领取');
|
||||
return;
|
||||
}
|
||||
final remaining =
|
||||
await ref.read(memberProvider.notifier).claimReward(adType);
|
||||
if (!mounted) return;
|
||||
Fluttertoast.showToast(msg: '$successMsg(今日剩余 $remaining 次)');
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
Fluttertoast.showToast(
|
||||
msg: e.toString().replaceFirst('Exception: ', ''));
|
||||
} finally {
|
||||
if (mounted) setState(() => _rewarding = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final member = ref.watch(memberProvider);
|
||||
final stores = ref.watch(storeProvider);
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_MemberCard(
|
||||
member: member,
|
||||
isIOS: _isIOS,
|
||||
onOpenPlans: _openPlans,
|
||||
),
|
||||
if (member.valueOrNull?.isVip == false) ...[
|
||||
const SizedBox(height: 12),
|
||||
_AdsRewardCard(
|
||||
rewarding: _rewarding,
|
||||
onClaim: (adType, msg) => _claimReward(adType, msg),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Text('合作门店',
|
||||
style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold)),
|
||||
const Spacer(),
|
||||
ChoiceChip(
|
||||
label: const Text('全部'),
|
||||
selected: _typeFilter == null,
|
||||
onSelected: (_) => setState(() => _typeFilter = null),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
for (final entry in _storeTypeLabels.entries) ...[
|
||||
ChoiceChip(
|
||||
label: Text(entry.value),
|
||||
selected: _typeFilter == entry.key,
|
||||
onSelected: (_) => setState(() => _typeFilter = entry.key),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
stores.when(
|
||||
loading: () => const Padding(
|
||||
padding: EdgeInsets.only(top: 32), child: LoadingView(text: '加载门店...')),
|
||||
error: (e, _) => Padding(
|
||||
padding: const EdgeInsets.only(top: 32),
|
||||
child: ErrorView(
|
||||
message: e.toString().replaceFirst('Exception: ', ''),
|
||||
onRetry: () => ref.read(storeProvider.notifier).refresh(),
|
||||
),
|
||||
),
|
||||
data: (list) {
|
||||
final shown = _typeFilter == null
|
||||
? list
|
||||
: list.where((s) => s.type == _typeFilter).toList();
|
||||
if (shown.isEmpty) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.only(top: 32),
|
||||
child: Center(
|
||||
child: Text('附近暂无可合作门店',
|
||||
style: TextStyle(color: Colors.grey))),
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
for (final s in shown) ...[
|
||||
Card(
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: scheme.primaryContainer,
|
||||
child: Icon(
|
||||
s.type == 1 ? Icons.content_cut : Icons.checkroom),
|
||||
),
|
||||
title: Text(s.name),
|
||||
subtitle: Text('${s.address}\n${s.commissionPolicy}'),
|
||||
isThreeLine: true,
|
||||
trailing:
|
||||
const Icon(Icons.chevron_right, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MemberCard extends ConsumerWidget {
|
||||
final AsyncValue<MemberInfo> member;
|
||||
final bool isIOS;
|
||||
final VoidCallback onOpenPlans;
|
||||
|
||||
const _MemberCard(
|
||||
{required this.member, required this.isIOS, required this.onOpenPlans});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Card(
|
||||
color: scheme.primaryContainer,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: member.when(
|
||||
loading: () => const Text('加载会员状态...',
|
||||
style: TextStyle(fontSize: 13)),
|
||||
error: (e, _) => Text('会员状态加载失败:$e',
|
||||
style: const TextStyle(fontSize: 12)),
|
||||
data: (m) {
|
||||
if (m.isVip) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(children: [
|
||||
Icon(Icons.workspace_premium, size: 30, color: scheme.primary),
|
||||
const SizedBox(width: 10),
|
||||
const Text('形象会员',
|
||||
style:
|
||||
TextStyle(fontSize: 17, fontWeight: FontWeight.bold)),
|
||||
const Spacer(),
|
||||
Chip(
|
||||
label: Text(m.planName),
|
||||
labelStyle:
|
||||
TextStyle(color: scheme.primary, fontSize: 12),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 6),
|
||||
Text('有效期至 ${m.expireAt}',
|
||||
style: TextStyle(fontSize: 12, color: scheme.primary)),
|
||||
if (benefitTexts(m).isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
for (final b in benefitTexts(m))
|
||||
Chip(
|
||||
label: Text(b),
|
||||
labelStyle: const TextStyle(fontSize: 11),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Row(children: [
|
||||
Icon(Icons.workspace_premium, size: 30),
|
||||
SizedBox(width: 10),
|
||||
Text('形象会员',
|
||||
style:
|
||||
TextStyle(fontSize: 17, fontWeight: FontWeight.bold)),
|
||||
]),
|
||||
const SizedBox(height: 6),
|
||||
const Text('会员专享:无限次效果图生成 · 优先 AI 方案 · 门店专属折扣',
|
||||
style: TextStyle(fontSize: 12)),
|
||||
const SizedBox(height: 10),
|
||||
if (isIOS)
|
||||
const Text('iOS 端暂不支持充值(App Store 政策),可观看广告获得体验会员',
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey))
|
||||
else
|
||||
FilledButton.icon(
|
||||
onPressed: onOpenPlans,
|
||||
icon: const Icon(Icons.payment, size: 18),
|
||||
label: const Text('开通会员'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AdsRewardCard extends ConsumerWidget {
|
||||
final bool rewarding;
|
||||
final void Function(String adType, String msg) onClaim;
|
||||
|
||||
const _AdsRewardCard({required this.rewarding, required this.onClaim});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('免费获取权益',
|
||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.ondemand_video, color: Colors.deepPurple),
|
||||
title: const Text('看视频 · 效果图 +1'),
|
||||
subtitle: const Text('每日最多 2 次,次日重置'),
|
||||
trailing: OutlinedButton(
|
||||
onPressed: rewarding
|
||||
? null
|
||||
: () => onClaim('effect_extra', '已获得 1 次效果图'),
|
||||
child: const Text('看视频'),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.ondemand_video, color: Colors.teal),
|
||||
title: const Text('看视频 · 体验会员 1 天'),
|
||||
subtitle: const Text('每日最多 1 次,含无限效果图'),
|
||||
trailing: OutlinedButton(
|
||||
onPressed: rewarding
|
||||
? null
|
||||
: () => onClaim('vip_trial', '已获得 1 天体验会员'),
|
||||
child: const Text('看视频'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PlanSheet extends ConsumerWidget {
|
||||
const _PlanSheet();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final plans = ref.watch(memberPlanProvider);
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text('选择会员套餐',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 12),
|
||||
plans.when(
|
||||
loading: () => const Padding(
|
||||
padding: EdgeInsets.all(24), child: LoadingView()),
|
||||
error: (e, _) => Text('套餐加载失败:$e',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.grey)),
|
||||
data: (list) => list.isEmpty
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Text('暂未开放套餐', textAlign: TextAlign.center),
|
||||
)
|
||||
: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final p in list)
|
||||
ListTile(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8)),
|
||||
tileColor: Theme.of(context)
|
||||
.colorScheme
|
||||
.primaryContainer
|
||||
.withValues(alpha: 0.5),
|
||||
title: Text(p.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 15, fontWeight: FontWeight.w600)),
|
||||
subtitle: Text(
|
||||
'${p.durationDays} 天 · ${p.features.map((f) => benefitLabels[f] ?? f).join(' · ')}',
|
||||
style: const TextStyle(fontSize: 12),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: Text(p.priceText,
|
||||
style: TextStyle(
|
||||
color:
|
||||
Theme.of(context).colorScheme.primary,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold)),
|
||||
onTap: () => Navigator.pop(context, p),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> 注意:`_openPlans` 里 `plans` 变量名与 `memberPlanProvider` 无关;`showModalBottomSheet` 返回选中的套餐。依赖 `AppConfig` 在 import 中(本页未用到可删,`apiClientProvider` 来自 `api_client.dart`,StoreNotifier 里用到——需 import `../../core/network/api_client.dart`)。
|
||||
|
||||
- [ ] **Step 2: 删除旧页并切换入口**
|
||||
|
||||
```bash
|
||||
rm lib/features/commercial/commercial_page.dart
|
||||
```
|
||||
|
||||
`home_page.dart` 修改:import 换 `../member/member_center_page.dart`,第 4 Tab 用 `MemberCenterPage()`,标题与 label 改为「会员中心」:
|
||||
|
||||
```dart
|
||||
import '../member/member_center_page.dart';
|
||||
// ...
|
||||
static const _titles = ['我的形象', '我的衣橱', '穿搭方案', '会员中心'];
|
||||
// ...
|
||||
MemberCenterPage(),
|
||||
// ...
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.store_outlined),
|
||||
selectedIcon: Icon(Icons.store),
|
||||
label: '会员'),
|
||||
```
|
||||
|
||||
- [ ] **Step 3: main.dart /commercial 路由指向新页**
|
||||
|
||||
`lib/main.dart`:`import '../features/member/member_center_page.dart';`,第 78-82 行 `/commercial` 的 builder 改为 `MemberCenterPage()`。
|
||||
|
||||
- [ ] **Step 4: 静态检查**
|
||||
|
||||
Run: `dart analyze lib 2>&1 | tail -5` Expected: 无 error(可能提示 unused import `app_config.dart`,删掉即可)
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add lib/features/member/member_center_page.dart lib/features/home/home_page.dart lib/main.dart
|
||||
git add -u lib/features/commercial
|
||||
git commit -m "feat: 会员中心页(会员卡/套餐弹层/广告激励/合作门店)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 支付页(轮询确认结果)
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/features/member/pay_page.dart`
|
||||
- Modify: `lib/main.dart`
|
||||
|
||||
- [ ] **Step 1: 创建 pay_page.dart**
|
||||
|
||||
```dart
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import 'member_provider.dart';
|
||||
|
||||
class PayArgs {
|
||||
final String orderNo;
|
||||
final String payUrl;
|
||||
|
||||
const PayArgs({required this.orderNo, required this.payUrl});
|
||||
}
|
||||
|
||||
enum PayPhase { launching, paying, paid, timeout, failed }
|
||||
|
||||
/// 支付页:打开系统浏览器收银台,2s 轮询订单状态(上限 60s)
|
||||
class PayPage extends ConsumerStatefulWidget {
|
||||
final PayArgs args;
|
||||
|
||||
const PayPage({super.key, required this.args});
|
||||
|
||||
@override
|
||||
ConsumerState<PayPage> createState() => _PayPageState();
|
||||
}
|
||||
|
||||
class _PayPageState extends ConsumerState<PayPage> {
|
||||
PayPhase _phase = PayPhase.launching;
|
||||
Timer? _timer;
|
||||
int _elapsed = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_start();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _start() async {
|
||||
try {
|
||||
final ok = await launchUrl(Uri.parse(widget.args.payUrl),
|
||||
mode: LaunchMode.externalApplication);
|
||||
if (!ok) {
|
||||
setState(() => _phase = PayPhase.failed);
|
||||
return;
|
||||
}
|
||||
setState(() => _phase = PayPhase.paying);
|
||||
} catch (e) {
|
||||
setState(() => _phase = PayPhase.failed);
|
||||
return;
|
||||
}
|
||||
_timer = Timer.periodic(const Duration(seconds: 2), (_) => _check());
|
||||
}
|
||||
|
||||
Future<void> _check() async {
|
||||
_elapsed += 2;
|
||||
try {
|
||||
final status = await fetchOrderStatus(ref, widget.args.orderNo);
|
||||
if (status == 'paid') {
|
||||
_timer?.cancel();
|
||||
if (!mounted) return;
|
||||
setState(() => _phase = PayPhase.paid);
|
||||
Fluttertoast.showToast(msg: '会员开通成功');
|
||||
return;
|
||||
}
|
||||
if (_elapsed >= 60) {
|
||||
_timer?.cancel();
|
||||
if (!mounted) return;
|
||||
setState(() => _phase = PayPhase.timeout);
|
||||
}
|
||||
} catch (_) {
|
||||
// 轮询失败不中断,下次再试
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('会员支付')),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
switch (_phase) {
|
||||
PayPhase.launching ||
|
||||
PayPhase.paying => Column(children: [
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
const Text('请在浏览器中完成支付,正在确认结果…'),
|
||||
const SizedBox(height: 8),
|
||||
Text('订单号 ${widget.args.orderNo}',
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
const SizedBox(height: 16),
|
||||
OutlinedButton(
|
||||
onPressed: () => _check(),
|
||||
child: const Text('我已完成支付'),
|
||||
),
|
||||
]),
|
||||
PayPhase.paid => Column(children: [
|
||||
Icon(Icons.check_circle, size: 64, color: scheme.primary),
|
||||
const SizedBox(height: 12),
|
||||
const Text('支付成功,会员已开通!',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: () => context.pop(),
|
||||
child: const Text('返回会员中心'),
|
||||
),
|
||||
]),
|
||||
PayPhase.timeout => Column(children: [
|
||||
Icon(Icons.hourglass_empty, size: 64, color: Colors.orange),
|
||||
const SizedBox(height: 12),
|
||||
const Text('支付结果确认中'),
|
||||
const SizedBox(height: 8),
|
||||
const Text('可稍后到会员中心查看开通状态,以支付结果为准',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
const SizedBox(height: 16),
|
||||
OutlinedButton(
|
||||
onPressed: () => context.pop(),
|
||||
child: const Text('返回'),
|
||||
),
|
||||
]),
|
||||
PayPhase.failed => Column(children: [
|
||||
Icon(Icons.error_outline, size: 64, color: scheme.error),
|
||||
const SizedBox(height: 12),
|
||||
const Text('无法打开支付页面'),
|
||||
const SizedBox(height: 16),
|
||||
OutlinedButton(
|
||||
onPressed: () => context.pop(),
|
||||
child: const Text('返回'),
|
||||
),
|
||||
]),
|
||||
},
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: main.dart 注册路由**
|
||||
|
||||
```dart
|
||||
GoRoute(
|
||||
path: '/pay',
|
||||
builder: (context, state) =>
|
||||
PayPage(args: state.extra! as PayArgs),
|
||||
),
|
||||
```
|
||||
|
||||
(import `../features/member/pay_page.dart`)
|
||||
|
||||
- [ ] **Step 3: 静态检查**
|
||||
|
||||
Run: `dart analyze lib 2>&1 | tail -5` Expected: 无 error
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add lib/features/member/pay_page.dart lib/main.dart
|
||||
git commit -m "feat: 支付页(系统浏览器收银台 + 2s 轮询确认)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 全量验证与冒烟
|
||||
|
||||
- [ ] **Step 1: 单元测试**
|
||||
|
||||
Run: `flutter test` Expected: 全部 PASS(含新增 benefits 2 个用例)
|
||||
|
||||
- [ ] **Step 2: 全量编译(web 兜底)**
|
||||
|
||||
Run: `cd /Users/zhangbin/Desktop/d盘/work/slogan/slogan-app && flutter build web --release` Expected: 构建成功(若提示 stale cache,先 `flutter clean && flutter pub get`)
|
||||
|
||||
- [ ] **Step 3: 冒烟(起后端,mock 支付/广告降级路径)**
|
||||
|
||||
后端按后端计划 T9 起服务(不配 mock 时):
|
||||
```bash
|
||||
# 会员状态:未登录返回 401;已登录非会员返回 is_vip=false
|
||||
# 套餐列表:返回 2 个套餐
|
||||
# 下单:报"支付未开通" → App 开通按钮 toast 提示,不崩溃
|
||||
# 广告:claim 前 2 次成功(remaining 1/0),第 3 次报"今日次数已用完" → toast 展示
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git status # 确认无残留
|
||||
git log --oneline -5
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 自检清单
|
||||
|
||||
- [ ] /commercial(home 第 4 Tab 与路由)指向会员中心,旧 commercial_page 已删除
|
||||
- [ ] iOS 隐藏充值入口(Platform.isIOS),广告激励保留
|
||||
- [ ] 支付页轮询 2s/60s 上限,paid/timeout/failed 三态完整;返回后刷新会员状态
|
||||
- [ ] 广告入口走 adsServiceProvider 抽象,Mock 可用,穿山甲 P1 替换点已注明
|
||||
- [ ] 所有后端接口错误 toast 展示 message,不崩溃;未开通时入口不渲染/隐藏
|
||||
- [ ] `dart analyze` 无 error、`flutter test` 全过、web 编译成功
|
||||
|
||||
## 后续计划(P1,不在本计划内)
|
||||
|
||||
方案页三处 CPS 入口(做同款发型/买同款/到店试穿)、/cps-product-list 商品列表、衣橱「找升级款」、最近优惠、穿山甲 SDK 替换 Mock、webview 内嵌收银台。
|
||||
@@ -0,0 +1,372 @@
|
||||
# slogan-app MVP 实现计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 实现 slogan-app Flutter MVP:登录 → 4 Tab 框架 → 拍照上传 → 衣橱管理 → 化身查看 → 穿搭生成(日期/地点 → 轮询 → 方案流 3D+2D 切换查看)。
|
||||
|
||||
**Architecture:** Flutter 单仓,Riverpod 状态管理,Dio 网络层(JWT 拦截器 + 统一响应),AvatarViewer 渲染抽象层(three_dart v1 实现,flutter_scene 后续替换),核心交互为方案 PageView 横滑 + 3D 化身拖拽旋转。
|
||||
|
||||
**Tech Stack:** Flutter (stable) / Riverpod / Dio / go_router / three_dart + three_dart_jsm / cached_network_image / camera + image_picker / shared_preferences
|
||||
|
||||
**后端对接:** slogan-agent(Go)API,见 slogan-agent 仓库 `docs/superpowers/specs/2026-07-31-slogan-agent-design.md` 第 11 节路由表。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 项目骨架
|
||||
|
||||
**Files:**
|
||||
- Run: `flutter create` 初始化(org 自定,项目名 slogan_app)
|
||||
- Modify: `pubspec.yaml`(依赖)
|
||||
- Create: `lib/main.dart`(入口 + 主题 + go_router 路由表)
|
||||
- Create: `lib/core/config/app_config.dart`
|
||||
|
||||
- [ ] **Step 1: 初始化**
|
||||
|
||||
```bash
|
||||
cd slogan-app
|
||||
flutter create --org com.slogan --project-name slogan_app .
|
||||
```
|
||||
|
||||
- [ ] **Step 2: pubspec.yaml 依赖**
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
flutter_riverpod: ^2.6.0
|
||||
dio: ^5.7.0
|
||||
go_router: ^14.0.0
|
||||
cached_network_image: ^3.4.0
|
||||
image_picker: ^1.1.0
|
||||
camera: ^0.11.0
|
||||
shared_preferences: ^2.3.0
|
||||
three_dart: ^0.2.0
|
||||
three_dart_jsm: ^0.2.0
|
||||
fluttertoast: ^8.2.0
|
||||
```
|
||||
|
||||
(版本以 pub.dev 最新稳定为准,`flutter pub add` 逐个添加)
|
||||
|
||||
- [ ] **Step 3: main.dart**:MaterialApp + Theme(Material 3,seed 主色)+ go_router 路由(login / home(4 tab) / photo-guide / plan-flow / plan-detail);启动时读取 token → 无 token 重定向登录页
|
||||
|
||||
- [ ] **Step 4: 验证** `flutter analyze` 无错误 + `flutter test` 默认通过
|
||||
|
||||
- [ ] **Step 5: Commit** `git add -A && git commit -m "feat: flutter skeleton with router and theme"`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 网络层(ApiClient + JWT 拦截器)
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/core/network/api_client.dart`
|
||||
- Create: `lib/core/network/api_exception.dart`
|
||||
- Create: `lib/core/storage/token_storage.dart`
|
||||
- Test: `test/core/network/api_client_test.dart`
|
||||
|
||||
- [ ] **Step 1: 写失败测试**(mock Dio adapter:401 触发登出回调;code!=0 抛 ApiException 带 message;成功解析 data)
|
||||
|
||||
- [ ] **Step 2: 确认失败** `flutter test`
|
||||
|
||||
- [ ] **Step 3: 实现 api_client.dart**
|
||||
|
||||
```dart
|
||||
class ApiClient {
|
||||
ApiClient({Dio? dio, required TokenStorage tokenStorage})
|
||||
: _tokenStorage = tokenStorage {
|
||||
_dio = dio ?? Dio(BaseOptions(
|
||||
baseUrl: AppConfig.baseUrl,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 30),
|
||||
));
|
||||
_dio.interceptors.add(InterceptorsWrapper(
|
||||
onRequest: (options, handler) {
|
||||
final token = _tokenStorage.token;
|
||||
if (token != null) options.headers['Authorization'] = 'Bearer $token';
|
||||
handler.next(options);
|
||||
},
|
||||
onError: (e, handler) {
|
||||
if (e.response?.statusCode == 401) onUnauthorized?.call();
|
||||
handler.next(e);
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
Future<T> post<T>(String path, Map<String, dynamic> body,
|
||||
{T Function(dynamic data)? parse}) async {
|
||||
final res = await _dio.post(path, data: body);
|
||||
return _unwrap<T>(res, parse);
|
||||
}
|
||||
Future<T> get<T>(String path, {Map<String, dynamic>? query, T Function(dynamic data)? parse}) async { ... }
|
||||
Future<T> upload<T>(String path, Map<String, dynamic> fields, String fileField, String filePath, {String Function(dynamic)? parse}) async { ... }
|
||||
|
||||
T _unwrap<T>(Response res, ...) {
|
||||
final code = res.data['code'] as int;
|
||||
final message = res.data['message'] as String? ?? '';
|
||||
if (code != 0) throw ApiException(code, message);
|
||||
return parse?.call(res.data['data']) ?? res.data['data'] as T;
|
||||
}
|
||||
|
||||
VoidCallback? onUnauthorized;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: TokenStorage**(shared_preferences 封装:token 读写 + 清空)
|
||||
|
||||
- [ ] **Step 5: 测试通过** + `flutter analyze` + **Commit**
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 认证(登录页 + 状态)
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/core/auth/auth_provider.dart`
|
||||
- Create: `lib/features/auth/login_page.dart`
|
||||
- Test: `test/core/auth/auth_provider_test.dart`
|
||||
|
||||
- [ ] **Step 1: 写失败测试**(ProviderContainer:登录成功 → token 持久化 + 状态 authenticated;失败 → 状态 error 携带 message)
|
||||
|
||||
- [ ] **Step 2: 实现 auth_provider.dart**(AsyncNotifier:login(account, password) → ApiClient.post('/user/login') → 存 token)
|
||||
|
||||
- [ ] **Step 3: login_page.dart**:账号/密码输入 + 登录按钮 + 加载态 + 错误提示(fluttertoast);登录成功 go_router push 替换到 home
|
||||
|
||||
- [ ] **Step 4: 测试通过** + **Commit**
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 4 Tab 主框架
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/features/home/home_page.dart`(BottomNavigationBar + IndexedStack 4 Tab)
|
||||
- Create: `lib/features/profile/profile_page.dart`(占位)
|
||||
- Create: `lib/features/wardrobe/wardrobe_page.dart`(占位)
|
||||
- Create: `lib/features/outfit/outfit_page.dart`(占位)
|
||||
- Create: `lib/features/commercial/commercial_page.dart`(占位)
|
||||
|
||||
- [ ] **Step 1: 实现 4 Tab 框架**:底部导航(我的形象/我的衣橱/穿搭方案/门店电商)+ 图标 + IndexedStack 保状态
|
||||
|
||||
- [ ] **Step 2: Widget 测试**:切 Tab 显示对应页面
|
||||
|
||||
- [ ] **Step 3: 测试通过** + **Commit**
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 拍照引导 + 照片上传(Tab1)
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/features/profile/photo_guide_page.dart`(引导 + 拍摄)
|
||||
- Create: `lib/features/profile/photo_guide_item.dart`(单张拍摄卡片)
|
||||
- Create: `lib/features/profile/photo_upload_provider.dart`
|
||||
- Create: `lib/features/profile/profile_page.dart`(集成:照片齐备度展示 + 上传入口)
|
||||
- Test: `test/features/profile/photo_upload_provider_test.dart`
|
||||
|
||||
- [ ] **Step 1: 写失败测试**(provider:mock ApiClient.upload 成功 → 状态更新;失败 → error)
|
||||
|
||||
- [ ] **Step 2: 实现 provider**:4 个类型照片上传(type 1-4),逐张上传成功后标记完成;本地压缩(`image_picker` 自带 maxWidth: 2048)
|
||||
|
||||
- [ ] **Step 3: photo_guide_page.dart**:4 张卡片(大头照/全身正面/侧面/背面,各含拍摄示例说明文案 + 相机按钮 image_picker 拍摄)+ 上传进度 + 完成态跳转
|
||||
|
||||
- [ ] **Step 4: profile_page.dart**:显示 4 张照片状态(已传/未传)+ "进入拍摄引导" 按钮 + 身形参数入口(Task 6)
|
||||
|
||||
- [ ] **Step 5: 测试通过** + **Commit**
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 身形参数 + 化身状态(Tab1)
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/features/profile/body_tune_page.dart`(滑杆微调)
|
||||
- Create: `lib/features/profile/body_provider.dart`
|
||||
- Create: `lib/features/profile/avatar_provider.dart`
|
||||
- Test: `test/features/profile/avatar_provider_test.dart`
|
||||
|
||||
- [ ] **Step 1: 写失败测试**(avatar provider:get → build → 状态 done + glbUrl 非空)
|
||||
|
||||
- [ ] **Step 2: body_tune_page.dart**:身高(145-200cm)/体重/肤色(1-5) 滑杆,保存 → POST /body-measurement/save
|
||||
|
||||
- [ ] **Step 3: avatar_provider.dart**:页面进入时 GET /avatar/get → 无记录则提示先传照片 → POST /avatar/build → 轮询 build_status(每 2s × 最多 30 次)→ done 后展示 glb_url
|
||||
|
||||
- [ ] **Step 4: profile_page.dart** 集成:身形参数卡片 + 化身构建按钮 + 构建状态展示
|
||||
|
||||
- [ ] **Step 5: 测试通过** + **Commit**
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 衣橱管理(Tab2)
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/features/wardrobe/wardrobe_provider.dart`
|
||||
- Create: `lib/features/wardrobe/wardrobe_page.dart`(网格)
|
||||
- Create: `lib/features/wardrobe/wardrobe_upload_page.dart`(上传表单:照片 + 分类 + 季节 + 风格标签)
|
||||
- Test: `test/features/wardrobe/wardrobe_provider_test.dart`
|
||||
|
||||
- [ ] **Step 1: 写失败测试**(provider:upload 成功追加列表;delete 移除;list 加载)
|
||||
|
||||
- [ ] **Step 2: 实现 provider** + 上传表单页(DropdownButton 分类[上衣/下装/鞋/配饰] + 季节 + 标签输入)
|
||||
|
||||
- [ ] **Step 3: 网格页**:GridView 服装照片 + 长按删除确认 + 空态引导("衣橱空空如也,去上传第一件衣服吧")
|
||||
|
||||
- [ ] **Step 4: 测试通过** + **Commit**
|
||||
|
||||
---
|
||||
|
||||
### Task 8: 生成入口(Tab3 上半)
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/features/outfit/generate_page.dart`
|
||||
- Create: `lib/features/outfit/outfit_generate_provider.dart`
|
||||
- Test: `test/features/outfit/outfit_generate_provider_test.dart`
|
||||
|
||||
- [ ] **Step 1: 写失败测试**(provider:generate 成功返回 taskId;开始轮询状态)
|
||||
|
||||
- [ ] **Step 2: 生成入口页**:日期范围(showDateRangePicker)+ 地点(TextField + 定位按钮[geolocator 可选,v1 手动输入])+ "生成穿搭" 按钮(校验:日期非空/地点非空/衣橱非空提示)
|
||||
|
||||
- [ ] **Step 3: 生成确认后** → 跳转任务状态页(Task 9)
|
||||
|
||||
- [ ] **Step 4: 测试通过** + **Commit**
|
||||
|
||||
---
|
||||
|
||||
### Task 9: 任务轮询 + 方案流(Tab3 核心)
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/features/outfit/task_status_page.dart`
|
||||
- Create: `lib/features/outfit/plan_flow_page.dart`(PageView 横滑)
|
||||
- Create: `lib/features/outfit/plan_provider.dart`
|
||||
- Test: `test/features/outfit/plan_provider_test.dart`
|
||||
|
||||
- [ ] **Step 1: 写失败测试**(provider:task 轮询 done → 加载 plan list;failed → error)
|
||||
|
||||
- [ ] **Step 2: task_status_page.dart**:轮询 GET /outfit/task/status(Timer.periodic 3s),状态文案映射(pending=准备中/planning=方案规划中/scoring=方案评分中/rendering=效果图生成中/done=完成/failed=失败+error 展示);done → 跳转方案流;失败显示重试
|
||||
|
||||
- [ ] **Step 3: plan_provider.dart**:GET /outfit/plan/list + detail(含 items/images/hairstyle)
|
||||
|
||||
- [ ] **Step 4: plan_flow_page.dart**:PageView.builder 每页一张方案卡片:
|
||||
- 3D 化身区(AvatarViewer,Task 10)
|
||||
- 方案摘要(标题/评分 Chip/来源标签:衣橱组合=蓝 / AI 推荐=橙)
|
||||
- 发型切换(横排发型 chip)+ 发色取色(HSV 面板)
|
||||
- 底部条目列表(slot 图标 + 名称 + 描述;推荐条目带"查看商品"入口,v1 占位)
|
||||
- "选为主方案" 按钮 → POST select-main → 效果图页(Task 11)
|
||||
|
||||
- [ ] **Step 5: 测试通过** + **Commit**
|
||||
|
||||
---
|
||||
|
||||
### Task 10: AvatarViewer 抽象 + three_dart 实现
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/features/outfit/viewer/avatar_viewer.dart`(接口 + controller)
|
||||
- Create: `lib/features/outfit/viewer/avatar_viewer_three_dart.dart`(three_dart 实现)
|
||||
- Create: `lib/features/outfit/viewer/avatar_viewer_placeholder.dart`(降级占位:头像图 + 手势提示)
|
||||
- Create: `lib/features/outfit/viewer/viewer_factory.dart`
|
||||
- Test: `test/features/outfit/viewer/avatar_viewer_placeholder_test.dart`
|
||||
|
||||
- [ ] **Step 1: 定义抽象接口**
|
||||
|
||||
```dart
|
||||
/// 渲染层抽象:业务代码只依赖此接口,flutter_scene 进 stable 后提供第二实现
|
||||
abstract class AvatarViewerController {
|
||||
Future<void> loadAvatar(String glbUrl); // 头像+体型主体
|
||||
Future<void> loadHairstyle(String glbUrl); // 发型层
|
||||
void setHairColor(Color color); // 发色 PBR baseColor
|
||||
void rotateBy(double dx, double dy);
|
||||
void zoomBy(double scale);
|
||||
void resetView();
|
||||
}
|
||||
|
||||
class AvatarViewer extends StatefulWidget {
|
||||
final AvatarViewerController Function() controllerFactory;
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 实现 three_dart 版**:`three_dart` + `three_dart_jsm` GLTFLoader 加载 GLB → Scene 显示(DirectionalLight + AmbientLight + OrbitControls 式手动手势:onPanUpdate → rotateBy 旋转 Object3D,onScaleUpdate → zoomBy 缩放相机/模型)—— 参考 `three_js_advanced_loaders` 示例;GLB 本地缓存(Task 13)
|
||||
|
||||
- [ ] **Step 3: 降级实现**:加载失败(网络/格式)→ placeholder(用户大头照 Image + "3D 模型加载失败,显示照片效果" 文案)
|
||||
|
||||
- [ ] **Step 4: viewer_factory.dart**:`AvatarViewer createViewer()` → three_dart 实现(有 GLB url 时)/ placeholder(无 url 时)
|
||||
|
||||
- [ ] **Step 5: Widget 测试**(placeholder 渲染)+ `flutter analyze` + **Commit**
|
||||
|
||||
---
|
||||
|
||||
### Task 11: 效果图查看 + 方案详情(Tab3 下半)
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/features/outfit/effect_image_page.dart`
|
||||
- Create: `lib/features/outfit/plan_detail_provider.dart`
|
||||
- Test: `test/features/outfit/plan_detail_provider_test.dart`
|
||||
|
||||
- [ ] **Step 1: 写失败测试**(provider:select-main → 轮询 detail.images 直到 3 张完成)
|
||||
|
||||
- [ ] **Step 2: select-main 后跳转效果图页**:3 视角(正面/侧面/背面)Tab/滑块切换,cached_network_image 加载,生成中展示进度(轮询 plan/detail images status)
|
||||
|
||||
- [ ] **Step 3: 方案详情页**(从列表进入):完整 detail 渲染(items 图片/名称/描述 + 效果图 + 收藏按钮 POST review)
|
||||
|
||||
- [ ] **Step 4: 测试通过** + **Commit**
|
||||
|
||||
---
|
||||
|
||||
### Task 12: 门店/电商(Tab4,MVP 列表展示)
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/features/commercial/store_page.dart`(附近门店列表)
|
||||
- Create: `lib/features/commercial/store_provider.dart`
|
||||
- Create: `lib/features/commercial/subscription_page.dart`(订阅占位)
|
||||
- Test: `test/features/commercial/store_provider_test.dart`
|
||||
|
||||
- [ ] **Step 1: 写失败测试**(provider:GET /partner-store/list 解析列表)
|
||||
|
||||
- [ ] **Step 2: 门店列表页**:类型筛选 chip(形象设计/服装门店)+ ListView 卡片(名称/类型/地址/距离占位)
|
||||
|
||||
- [ ] **Step 3: 订阅页**:标准版/Pro 权益卡片 + "开通"按钮(v1 占位 toast"支付功能开发中")
|
||||
|
||||
- [ ] **Step 4: 测试通过** + **Commit**
|
||||
|
||||
---
|
||||
|
||||
### Task 13: 缓存与性能
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/core/storage/`(新增 glb_cache.dart)
|
||||
- Create: `lib/core/storage/glb_cache.dart`(GLB 本地 LRU)
|
||||
- Test: `test/core/storage/glb_cache_test.dart`
|
||||
|
||||
- [ ] **Step 1: 写失败测试**(缓存:put → hit;LRU 超限淘汰;过期清理)
|
||||
|
||||
- [ ] **Step 2: 实现**:文件缓存目录 `getApplicationSupportDirectory()/glb_cache/`,key=url hash,256MB 上限(超限按最后访问时间淘汰);方案流预取下一页 GLB
|
||||
|
||||
- [ ] **Step 3: 测试通过** + **Commit**
|
||||
|
||||
---
|
||||
|
||||
### Task 14: 错误处理与加载状态组件
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/shared/widgets/loading_view.dart`(骨架屏)
|
||||
- Create: `lib/shared/widgets/error_view.dart`(错误 + 重试)
|
||||
- Create: `lib/shared/widgets/empty_view.dart`(空态 + 引导动作)
|
||||
|
||||
- [ ] **Step 1: 三个组件**(loading 骨架、error 文案+重试回调、empty 图标+文案+按钮)
|
||||
|
||||
- [ ] **Step 2: 接入**:profile/wardrobe/outfit/commercial 各页加载态/空态/错误态替换
|
||||
|
||||
- [ ] **Step 3: Widget 测试** + **Commit**
|
||||
|
||||
---
|
||||
|
||||
### Task 15: 集成冒烟(联调 slogan-agent)
|
||||
|
||||
- [ ] **Step 1: 本地起 slogan-agent**(`go run main.go`,3007 端口,mock 效果图供应商)
|
||||
|
||||
- [ ] **Step 2: App 联调**(iOS 模拟器 + Android 模拟器各一遍):
|
||||
1. 登录 → 2. 拍照引导上传 4 张(相册选图代替拍摄)→ 3. 身形参数 → 4. 构建化身 → 5. 衣橱上传 3+ 件 → 6. 生成穿搭(日期+地点)→ 7. 任务轮询 → 8. 方案流(3D 查看/发型切换/横滑)→ 9. 选主方案 → 10. 效果图 3 视角查看
|
||||
|
||||
- [ ] **Step 3: 修复联调问题**(网络/字段/状态映射),`flutter analyze` 0 错误
|
||||
|
||||
- [ ] **Step 4: Commit** `git commit -m "feat: mvp complete, verified against slogan-agent"`
|
||||
|
||||
---
|
||||
|
||||
## Self-Review 备注
|
||||
|
||||
- 后端字段命名 snake_case(JSON),Dart 侧解析用 map 取值避免命名映射负担
|
||||
- 效果图生成在后端为异步任务,App 端轮询 plan/detail 的 images 状态(rendering→done)
|
||||
- three_dart 若 iOS 渲染异常(着色器兼容),降级路径 placeholder 保证 MVP 可用;GLB 渲染验证放 Task 10 明确检查
|
||||
@@ -0,0 +1,204 @@
|
||||
# 商业化四支柱设计(客户端)· slogan-app
|
||||
|
||||
> **目标:** 以「个人形象设计」为主流程,把四支柱收入入口从「方案/单品」里长出来:VIP 会员充值、穿山甲广告激励、线下门店引流(美团联盟)、线上商品(京东/淘宝 CPS)。不做泛化场景广场。
|
||||
> **核心原则:** 客户端零硬编码业务配置;所有商业化入口以「接口可用」为开关 —— 后端未配置 key 时接口报错/返回空 → App 自动隐藏对应入口,主流程(生成方案 → 查看)不受影响。
|
||||
|
||||
## 1. 信息架构改造
|
||||
|
||||
```
|
||||
改造前:/commercial 孤立 tab(会员占位卡 + 门店列表)
|
||||
改造后:
|
||||
├─ /commercial 会员中心(P0) 会员状态卡 · 套餐 · 广告激励 · 合作门店 · 最近优惠
|
||||
├─ /plan-viewer 方案页(P0) 发型卡「做同款发型」· 穿衣清单「买同款/到店试穿」· 场合「延伸优惠」
|
||||
├─ /wardrobe 衣橱(P1) 长按「找升级款」
|
||||
└─ 全局(P2) 开屏广告 / 信息流广告位(效果图页底部)
|
||||
```
|
||||
|
||||
**开关原则**:每个商业化入口包一层 `commercialGate`(统一查询后端配置/捕获接口错误),未开通 → 按钮不渲染。启动时不做额外网络调用,**入口可见性由首次打开该页面的接口结果决定**(零新增请求)。
|
||||
|
||||
## 2. 支柱 A:会员中心(/commercial 重构)
|
||||
|
||||
### 2.1 页面结构
|
||||
|
||||
```
|
||||
/ commercial(ConsumerStatefulWidget,保留现有门店列表与类型筛选)
|
||||
├─ 会员状态卡:头像/会员名 · is_vip · expire_at(倒计时)· 权益 chips(无限效果图/优先AI/返现1.5x/门店折扣)
|
||||
│ ├─ 未开通 →「立即开通」按钮 → 打开套餐 bottom sheet
|
||||
│ └─ 已开通 →「会员码」按钮(store_discount 到店出示,P1)
|
||||
├─ 广告激励卡(非会员时显示):
|
||||
│ ├─ 看视频 +1 效果图(每日 2 次,剩余次数显示)
|
||||
│ └─ 看视频 1 天体验会员(每日 1 次)
|
||||
├─ 合作门店列表(现有 /partner-store/list 品牌合作门店保留,含「会员价」角标 P1;
|
||||
│ 美团联盟到店券走 /cps/product/list,两条链路互不替换)
|
||||
└─ 最近优惠(/cps/my/recent,P1,未开通则隐藏整卡)
|
||||
```
|
||||
|
||||
### 2.2 支付时序(App 侧)
|
||||
|
||||
```
|
||||
点击套餐 → POST /member/order/create {plan_id} → 返回 {order_no, pay_url}
|
||||
→ 打开 PayWebViewPage(内嵌 webview_flutter,iOS 用 WKWebView)
|
||||
→ WebView 加载 pay_url,监听 url 变化(payment 完成跳转)
|
||||
→ 同时 Timer 每 2s GET /member/order/status?order_no=... 轮询(上限 60s)
|
||||
→ status=paid → 关闭 WebView → 刷新 memberProvider → 成功 toast
|
||||
→ 超时 → 提示「支付结果确认中,请稍后在会员中心查看」(状态以服务端为准)
|
||||
```
|
||||
|
||||
- 支付页依赖 `webview_flutter`(P0 引入,国内 App 常规做法);若平台编译受限,降级方案:`url_launcher` 唤起系统浏览器支付,返回 App 后仍走轮询(**P0 默认 url_launcher 方案,webview_flutter 留 P1**,减小依赖风险)
|
||||
|
||||
### 2.3 状态与 Provider
|
||||
|
||||
| Provider | 类型 | 数据 | 接口 |
|
||||
|---|---|---|---|
|
||||
| `memberProvider` | AsyncNotifier | isVip, expireAt, planName, benefits[] | GET /member/status |
|
||||
| `memberPlanProvider` | FutureProvider | 套餐列表 | GET /member/plan/list |
|
||||
| `orderCreateProvider` | Notifier.family(planId) | orderNo, payUrl | POST /member/order/create |
|
||||
| `orderStatusProvider` | FutureProvider.family(orderNo) | status | GET /member/order/status |
|
||||
|
||||
- `memberProvider` 缓存登录态期间;`refresh()` 在支付成功、广告领奖后调用
|
||||
- 权益 chips 文案从套餐 `features` JSON 解析 → 本地文案 map(`effect_unlimited→无限效果图` 等)
|
||||
|
||||
## 3. 支柱 B:广告激励(lib/core/ads/ 新建)
|
||||
|
||||
### 3.1 抽象(供应商隔离)
|
||||
|
||||
```dart
|
||||
// lib/core/ads/ads_provider.dart
|
||||
abstract class AdsService {
|
||||
bool get enabled; // appid 未配置 → false,App 隐藏广告入口
|
||||
Future<bool> showRewarded(); // 激励视频,返回是否完整观看
|
||||
}
|
||||
|
||||
// lib/core/ads/pangle_ads_service.dart —— 穿山甲实现(P1 接入 SDK,P0 仅接口 + mock)
|
||||
// P0:MockAdsService —— 本地模拟 3 秒「播放」返回 true,保证主链路可开发可测
|
||||
```
|
||||
|
||||
- **初始化**:`AdsConfig`(AppConfig 常量:pangleAppId 默认空)→ `adsServiceProvider` 单例
|
||||
- **降级**:appid 空 / SDK 初始化失败 → `enabled=false` → 会员中心激励卡、广告位全部不渲染
|
||||
|
||||
### 3.2 激励流程(服务端防刷,客户端只展示)
|
||||
|
||||
```
|
||||
点击「看视频」→ adsService.showRewarded()
|
||||
→ 完整观看 → POST /ad/reward/claim {ad_type: effect_extra | vip_trial}
|
||||
→ 成功 → 展示奖励弹窗(+1 效果图 / 1 天体验会员)→ refresh 会员卡
|
||||
→ 失败(限频/未配置)→ 隐藏式错误(后端返回「今日次数已用完」→ 入口变灰)
|
||||
```
|
||||
|
||||
- 剩余次数展示:`/ad/reward/claim` 响应带回 `{reward: {ad_type, remaining_today}}`,App 本地缓存当日显示;或 P0 简单化 —— 仅在领取失败时提示次数用完
|
||||
- **效果图配额联动**:后端限额 = 基础 3 + 当日额外次数;App 端文案统一显示「今日剩余 X 次」(P1 后端在生成接口响应中带 `remaining` 字段,P0 保持现状提示)
|
||||
|
||||
### 3.3 广告位(P2,本期只留占位)
|
||||
|
||||
- 开屏广告:`/home` 进入时加载(P2)
|
||||
- 信息流广告:`/plan-effect` 效果图 GridView 底部插一条(P2)
|
||||
|
||||
## 4. 支柱 C/D:方案驱动 CPS 入口
|
||||
|
||||
### 4.1 方案页(/plan-viewer)三处入口
|
||||
|
||||
| 位置 | 按钮 | scene | 请求 | 跳转 |
|
||||
|---|---|---|---|---|
|
||||
| 发型卡尾部 | 「做同款发型」 | haircut | GET /cps/plan/recommend {plan_id, scene: haircut} | /cps-product-list(美团丽人/理发券) |
|
||||
| 穿衣清单每项 trailing | 「买同款」 | item_buy | 京东搜索单品名 | /cps-product-list(电商商品) |
|
||||
| 穿衣清单每项 trailing | 「到店试穿」 | item_upgrade | 美团服装类目 | /cps-product-list(门店券) |
|
||||
| 场合卡(P1,detail 有 occasion 字段后) | 「延伸优惠」 | occasion | 映射表推荐 | /cps-product-list |
|
||||
|
||||
- **可见性**:推荐接口返回空列表 / 接口报错(CPS 未开通)→ 该按钮隐藏;发型卡无发型名(默认发型)→ 隐藏「做同款」
|
||||
- **交互**:推荐返回列表 → push `/cps-product-list?source=&category_code=&city=`(带标题「做同款发型 · 丽人」);列表点击 → POST /cps/product/link {product_id, scene, plan_id} → 打开 deeplink
|
||||
- **打开方式**:`url_launcher`(系统浏览器,携 pid 转链 URL;电商商品优先深链 App,P1 增强)
|
||||
|
||||
### 4.2 衣橱升级款(/wardrobe 长按菜单加一项)
|
||||
|
||||
```
|
||||
长按衣物卡 → 菜单:删除 / 找升级款
|
||||
「找升级款」→ GET /cps/wardrobe/upgrade {item_id} → /cps-product-list(标题「升级款 · 上衣」)
|
||||
```
|
||||
|
||||
### 4.3 商品列表页 /cps-product-list
|
||||
|
||||
```
|
||||
AppBar 标题(由入口传入)+ 分类 chips(/cps/category/list,P1)
|
||||
商品卡:封面图(AppConfig.resolveUrl)· 名称 · 价格(分→元)· 店铺 · 佣金标
|
||||
上滑加载更多(page 分页,has_more 判定)
|
||||
点击 → POST /cps/product/link → deeplink → url_launcher 打开
|
||||
```
|
||||
|
||||
### 4.4 Provider
|
||||
|
||||
| Provider | 类型 | 接口 |
|
||||
|---|---|---|
|
||||
| `cpsRecommendProvider` | FutureProvider.family((planId, scene)) | GET /cps/plan/recommend |
|
||||
| `cpsProductProvider` | AsyncNotifierProvider.family((source, categoryCode, city)) | GET /cps/product/list 分页 |
|
||||
| `cpsUpgradeProvider` | FutureProvider.family(itemId) | GET /cps/wardrobe/upgrade |
|
||||
| `cpsLinkAction` | Notifier | POST /cps/product/link |
|
||||
|
||||
## 5. 会员权益在客户端的呈现
|
||||
|
||||
| 权益 | 客户端表现 |
|
||||
|---|---|
|
||||
| effect_unlimited | 效果图页配额提示隐藏(「每日限 3 次」文案在 isVip 时不显示) |
|
||||
| ai_priority | 生成页状态文案「VIP 优先排队」(MVP 仅文案) |
|
||||
| cps_commission_x15 | 商品卡佣金标签「返现加成 1.5x」(VIP 用户) |
|
||||
| store_discount | 门店列表「会员价」角标 + 会员码页(P1) |
|
||||
|
||||
## 6. 配置(AppConfig 常量,均为本地编译期配置)
|
||||
|
||||
```dart
|
||||
// lib/core/config/app_config.dart 追加
|
||||
static const String pangleAppId = ''; // 穿山甲 AppId,空 = 广告功能关闭
|
||||
static const bool cpsEnabled = true; // 兜底开关;最终以接口结果为准
|
||||
```
|
||||
|
||||
- 商业化开关**最终以接口为准**(后端 config 未配置 → 接口错误 → App 隐藏入口),本地常量只控制「广告 SDK 是否初始化」
|
||||
|
||||
## 7. API 映射与错误处理
|
||||
|
||||
| 后端接口 | 客户端方法 | 错误处理 |
|
||||
|---|---|---|
|
||||
| GET /member/status | memberProvider.build | 401 → 重登;其他 → 默认非会员 |
|
||||
| GET /member/plan/list | memberPlanProvider | 错误 → 套餐区显示「暂未开通」 |
|
||||
| POST /member/order/create | 下单动作 | 错误 → toast「支付未开通」 |
|
||||
| GET /member/order/status | 轮询 | 404/错误 → 结束轮询提示稍后查看 |
|
||||
| POST /member/order/notify | -(后端回调,客户端不参与) | - |
|
||||
| POST /ad/reward/claim | 领奖动作 | 错误 → toast 后端 message(限频等) |
|
||||
| GET /cps/plan/recommend | cpsRecommendProvider | 空/错误 → 入口隐藏 |
|
||||
| GET /cps/product/list | cpsProductProvider | 空/错误 → 列表空态「暂未开放」 |
|
||||
| POST /cps/product/link | cpsLinkAction | 错误 → toast「跳转失败」 |
|
||||
| GET /cps/my/recent | recentProvider | 错误 → 整卡隐藏 |
|
||||
|
||||
- 所有新接口走现有 `apiClientProvider`(Dio 封装,自动带 token),无需改动网络层
|
||||
|
||||
## 8. 路由与依赖变更
|
||||
|
||||
```
|
||||
/lib/main.dart 新增 route:
|
||||
/cps-product-list(extra: CpsListArgs{title, source, categoryCode, city})
|
||||
/pay-webview(extra: PayWebviewArgs{orderNo, payUrl},P1 webview_flutter)
|
||||
依赖(P0):url_launcher(打开 deeplink / 系统浏览器支付)
|
||||
依赖(P1):webview_flutter(内嵌收银台)、穿山甲 SDK(pangle 插件)
|
||||
```
|
||||
|
||||
- 穿山甲 SDK Flutter 插件社区维护不稳定 → **P1 先验证 iOS/Android 编译,若插件不可用则改为原生 module 接入(P2)**;P0 用 MockAdsService 保证业务链路先闭环
|
||||
|
||||
## 9. 分期与对齐
|
||||
|
||||
| 分期 | 客户端内容 | 依赖 |
|
||||
|---|---|---|
|
||||
| **P0** | /commercial 重构会员中心(状态/套餐/下单/轮询)+ MockAds + 广告激励入口 + 方案页「做同款发型/买同款/到店试穿」+ /cps-product-list + url_launcher + cps 入口隐藏逻辑 | 后端 P0(会员+广告接口) |
|
||||
| **P1** | 场合卡「延伸优惠」+ 衣橱「找升级款」+ 最近优惠 + 会员码/门店折扣角标 + webview_flutter 内嵌收银台 + 穿山甲 SDK 接入(替换 Mock) | 后端 P1(CPS 引擎) |
|
||||
| **P2** | 开屏/信息流广告位 + 穿山甲插件不可用时的原生 module 兜底 + 收益/返现展示 | 后端 P2 |
|
||||
|
||||
## 10. 合规(客户端侧)
|
||||
|
||||
- **iOS 充值**:App Store 虚拟商品政策风险 → iOS 端隐藏会员套餐充值入口(`Platform.isIOS` 判断),保留广告激励 + 门店引流;「会员价」到店核销不受影响
|
||||
- **广告**:隐私政策文案补充穿山甲 SDK 信息收集披露;提供「个性化广告关闭」设置项(穿山甲 SDK 提供,P1)
|
||||
- **跳转**:CPS 转链一律走联盟 deeplink,不在 App 内二次改链
|
||||
|
||||
## 11. 开发规范约束(沿用 slogan-app 现有规范)
|
||||
|
||||
- Riverpod 3:AsyncNotifier/Notifier/FutureProvider.family;provider 文件放 `lib/features/<feature>/<feature>_provider.dart`
|
||||
- 新页面组件放 `lib/features/commercial/`(会员中心)、`lib/features/cps/`(商品列表);广告抽象放 `lib/core/ads/`
|
||||
- 图片 URL 一律 `AppConfig.resolveUrl()`;价格字段分 → 元转换写死规则(`(fen / 100).toStringAsFixed(0)`)
|
||||
- 所有「隐藏入口」逻辑集中在入口组件内一行判定,不扩散到业务逻辑
|
||||
- 新页面必配 empty/error/loading 三态(复用 shared/widgets)
|
||||
@@ -0,0 +1,174 @@
|
||||
# slogan-app 客户端设计方案
|
||||
|
||||
> 日期:2026-07-31
|
||||
> 关联:slogan-agent 服务端方案(Go + GoFrame)见 slogan-agent 仓库对应文档
|
||||
|
||||
## 1. 项目概述
|
||||
|
||||
slogan 是一个"人形象设计"应用:用户上传大头照和全身多角度照片、维护个人服装资产(衣橱),指定日期范围和地点后一键生成最适合的穿搭方案(含发型、发色、服装穿搭),方案以 3D 化身 + 2D 效果图双形态呈现,手指滑动切换方案与查看角度。
|
||||
|
||||
本仓库为客户端(slogan-app),Flutter 实现,一次编写双端(iOS/Android)运行,追求类原生用户操作体验。
|
||||
|
||||
## 2. 技术选型
|
||||
|
||||
| 决策点 | 选择 | 理由 |
|
||||
|--------|------|------|
|
||||
| 语言/框架 | Flutter (Dart) | 一次编写双端;自绘引擎保证体验一致;动画/手势流畅 |
|
||||
| 状态管理 | Riverpod | 类型安全、可测试,适合表单/异步任务/缓存类状态 |
|
||||
| 网络层 | Dio + 拦截器 | JWT 自动注入、统一响应解析 `{code,message,data}`、超时重试 |
|
||||
| 3D 渲染 | three_dart v1 + 渲染抽象层 | 稳定发布版(v0.3.0,GLTF/GLB loader,纯 Dart 双端);flutter_scene 进 stable 后经抽象层无缝替换 |
|
||||
| 图片缓存 | cached_network_image | 效果图/服装照片懒加载缓存 |
|
||||
| 拍照/相册 | camera + image_picker | 大头照/全身多角度拍摄引导 |
|
||||
| 本地存储 | shared_preferences | token/偏好;身形微调参数本地实时生效 |
|
||||
| 手势 | 原生 GestureDetector 组合 | 方案横滑切换(PageView)+ 化身旋转拖拽/捏合缩放 + 惯性滚动 |
|
||||
|
||||
**3D 渲染层风险控制**:`AvatarViewer` 接口抽象(loadGLB / setHairstyle / setHairColor / rotate / zoom / switchOutfit),v1 实现为 three_dart;flutter_scene(官方,基于 Flutter GPU)进入 stable 后提供第二实现,业务代码零改动。
|
||||
|
||||
## 3. 项目结构
|
||||
|
||||
```
|
||||
slogan-app/
|
||||
├── lib/
|
||||
│ ├── main.dart # 入口 + 路由 + 主题
|
||||
│ ├── core/
|
||||
│ │ ├── network/ # Dio 封装:JWT 拦截器/统一响应/错误码映射
|
||||
│ │ ├── auth/ # 登录页 + token 管理(账号密码,复用服务端 /user/login)
|
||||
│ │ ├── config/ # API 地址/环境
|
||||
│ │ ├── storage/ # shared_preferences 封装(token/偏好)
|
||||
│ │ └── router/ # go_router(未登录 → 登录页)
|
||||
│ ├── features/
|
||||
│ │ ├── profile/ # Tab1 我的形象
|
||||
│ │ │ ├── photo_guide/ # 拍照引导页(大头照/全身多角度拍摄指引)
|
||||
│ │ │ ├── avatar_viewer/ # 3D 化身查看(AvatarViewer 抽象层实现)
|
||||
│ │ │ └── body_tune/ # 滑杆微调(身高/胖瘦/肤色,本地实时)
|
||||
│ │ ├── wardrobe/ # Tab2 我的衣橱
|
||||
│ │ │ ├── upload/ # 服装照片上传 + 分类标签
|
||||
│ │ │ └── item_grid/ # 服装资产网格/详情
|
||||
│ │ ├── outfit/ # Tab3 穿搭方案
|
||||
│ │ │ ├── generate/ # 生成入口(日期范围选择器/地点选择)
|
||||
│ │ │ ├── task_status/ # 生成任务进度(轮询)
|
||||
│ │ │ ├── plan_flow/ # 方案流(PageView 横滑切换方案)
|
||||
│ │ │ ├── viewer_3d/ # 3D 方案查看(发型切换/发色取色/旋转/捏合)
|
||||
│ │ │ ├── effect_images/ # 2D 效果图(正面/侧面/背面切换)
|
||||
│ │ │ └── review/ # 收藏/反馈
|
||||
│ │ └── commercial/ # Tab4 门店/电商
|
||||
│ │ ├── stores/ # LBS 附近门店(形象设计/服装)
|
||||
│ │ ├── leads/ # 导流订单/到店核销
|
||||
│ │ ├── products/ # CPS 商品跳转
|
||||
│ │ └── subscription/ # 会员订阅
|
||||
│ └── shared/ # 组件/主题/工具(日期选择器/评分展示等)
|
||||
├── assets/
|
||||
│ ├── avatars/ # 模板/发型 GLB 缓存(与后端 assets 对应,v1 从服务端拉取)
|
||||
│ └── images/ # 图标/占位图
|
||||
└── test/ # 单元/widget 测试
|
||||
```
|
||||
|
||||
## 4. 页面与交互设计(4 Tab 主框架)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Tab 架构(底部导航,类原生体验) │
|
||||
│ ┌────────┬────────┬────────┬─────────┐ │
|
||||
│ │ 我的形象│ 我的衣橱│ 穿搭方案 │ 门店/电商│ │
|
||||
│ └────────┴────────┴────────┴─────────┘ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Tab1 我的形象
|
||||
- 首次进入:拍照引导流程(大头照 + 全身正面/侧面/背面 4 张,含姿势示例图)
|
||||
- 化身构建进度(后端任务轮询)
|
||||
- 3D 化身查看:单指拖拽旋转、双指捏合缩放
|
||||
- 滑杆微调:身高/胖瘦/肤色,调参即时反映(本地计算,GLB 缩放参数 + 肤色材质)
|
||||
|
||||
### Tab2 我的衣橱
|
||||
- 服装照片上传(多选)+ 分类(上衣/下装/鞋/配饰)+ 季节/风格标签自动识别(后端 AI 辅助,本地可手改)
|
||||
- 网格展示 + 详情编辑/删除
|
||||
|
||||
### Tab3 穿搭方案(核心)
|
||||
- 生成入口:日期范围选择器 + 地点选择(定位/搜索)
|
||||
- 生成任务进度页(规则规划 → 方案评分 → 完成)
|
||||
- 方案流:PageView 左右滑动切换 3 套方案;每套方案卡片 = 3D 化身 + 方案摘要(评分/来源标签:衣橱组合/AI 推荐)
|
||||
- 3D 查看:发型点击切换 + 发色取色盘(HSV 调色实时渲染)+ 拖拽旋转 + 捏合缩放
|
||||
- 主方案选定 → 触发 2D 效果图生成(3 视角:正面/侧面/背面,切换查看)
|
||||
- 方案条目明细:每件服装(衣橱照片 或 电商商品图)+ 单品操作(跳转商品/加入衣橱)
|
||||
- 收藏/反馈 → 回流 Agent 优化下次生成
|
||||
|
||||
### Tab4 门店/电商
|
||||
- LBS 附近合作门店(类型筛选:形象设计/服装),导航/预约/到店核销
|
||||
- CPS 商品推荐列表(跳转电商)
|
||||
- 会员订阅入口与权益展示
|
||||
|
||||
## 5. 3D 查看器设计(核心模块)
|
||||
|
||||
### AvatarViewer 抽象接口
|
||||
|
||||
```dart
|
||||
abstract class AvatarViewer extends StatelessWidget {
|
||||
// 由具体实现提供(three_dart v1 / flutter_scene v2)
|
||||
}
|
||||
|
||||
abstract class AvatarViewerController {
|
||||
Future<void> loadAvatar(AvatarSpec spec); // 头像 + 体型 + 皮肤贴图
|
||||
Future<void> loadHairstyle(String glbUrl); // 加载发型
|
||||
void setHairColor(Color color); // 发色(PBR baseColor 调色)
|
||||
void setOutfit(List<OutfitLayer> layers); // 换装(简模 GLB 层)
|
||||
void rotateBy(double dx, double dy); // 旋转
|
||||
void zoomBy(double scale); // 缩放
|
||||
void resetView();
|
||||
}
|
||||
```
|
||||
|
||||
- **v1 实现(three_dart)**:加载服务端 `avatar_model.glb`(头部/身体/发型分离分层),发型切换 = 换层 + 发色材质 HSV 调整
|
||||
- **性能**:GLB 经服务端 glTF-Transform 压缩;发型资产首次加载后本地缓存;页面级预取下一套方案
|
||||
- **降级**:GLB 加载失败 → 显示用户大头照 + 方案文本卡片(核心功能不受 3D 影响)
|
||||
|
||||
### 手势实现
|
||||
- 方案切换:`PageView`(水平滑动 + 惯性 + 阻尼边缘效果)
|
||||
- 化身旋转:`GestureDetector.onPanUpdate` → controller.rotateBy,松手无惯性(或轻量衰减)
|
||||
- 缩放:`ScaleGestureRecognizer` 双指捏合,1.0-4.0 范围限制
|
||||
- 发色:`HSVColorPicker` 自定义取色盘,onChanged 实时 setHairColor
|
||||
|
||||
## 6. 网络层与状态管理
|
||||
|
||||
- `ApiClient`(Dio):baseUrl 可配置、token 注入、`code==0` 判定、401 自动登出、超时 30s
|
||||
- 任务轮询:`outfit/task/status` 每 3s 轮询(Riverpod `StreamProvider`),任务完成自动停止
|
||||
- 上传:Multipart,进度条反馈
|
||||
- 缓存:效果图/服装照片 cached_network_image;化身 GLB 本地文件缓存(LRU,256MB 上限)
|
||||
|
||||
## 7. 拍摄引导设计
|
||||
|
||||
- 大头照:正面、面部无遮挡、光线均匀说明图;相机取景框对齐提示
|
||||
- 全身照:距镜 2-3 米、全身入框、正面/侧面/背面 三角度示例图(类原生"手势引导"UI)
|
||||
- 照片本地压缩(宽边 ≤ 2048)后上传
|
||||
|
||||
## 8. 错误处理与加载状态
|
||||
|
||||
- 统一 `ErrorView` / `LoadingView` 组件(骨架屏)
|
||||
- 生成任务失败:明确错误文案("衣橱为空,请先添加服装"等)+ 重试按钮
|
||||
- 网络离线:离线提示 + 本地缓存优先展示(方案历史本地快照)
|
||||
- 轮询超时(>10 分钟):提示"生成时间较长"并提供结果通知路径(v2 推送)
|
||||
|
||||
## 9. 测试策略
|
||||
|
||||
- 单元测试:手势计算/发色 HSV 调色/方案缓存 key 逻辑
|
||||
- Widget 测试:方案流滑动切换、3D 查看器骨架降级、拍照引导流程
|
||||
- 集成(v1 手工 + 冒烟脚本):登录 → 上传 → 生成 → 查看全链路
|
||||
- 渲染层测试:AvatarViewer 接口 mock,业务测试不依赖具体 3D 实现
|
||||
|
||||
## 10. 与后端 API 对接清单
|
||||
|
||||
见 slogan-agent 方案第 11 节 API 路由表。App 端关键时序:
|
||||
|
||||
```
|
||||
登录 → 上传照片(4张) → 填写身形 → [build 化身(异步)] → 上传衣橱服装
|
||||
→ 生成穿搭 {日期范围, 地点} → 轮询任务 → 方案流(3D 即时查看)
|
||||
→ 选主方案 → 效果图生成(异步) → 3 视角查看 → 收藏/跳转商品/门店预约
|
||||
```
|
||||
|
||||
## 11. 开发规范约束(App 端)
|
||||
|
||||
- 状态管理统一 Riverpod,禁止 setState 在页面间传递业务状态
|
||||
- 所有网络请求必须走 `ApiClient`,禁止散落 Dio 实例
|
||||
- 3D 渲染只允许通过 `AvatarViewer` 抽象层,禁止业务代码直接依赖 three_dart 类型
|
||||
- 命名:目录 `features/<domain>/`,组件 `shared/`;文件 snake_case,类 PascalCase
|
||||
- 测试随功能同步编写(TDD:先写失败测试再实现)
|
||||
@@ -0,0 +1,34 @@
|
||||
**/dgph
|
||||
*.mode1v3
|
||||
*.mode2v3
|
||||
*.moved-aside
|
||||
*.pbxuser
|
||||
*.perspectivev3
|
||||
**/*sync/
|
||||
.sconsign.dblite
|
||||
.tags*
|
||||
**/.vagrant/
|
||||
**/DerivedData/
|
||||
Icon?
|
||||
**/Pods/
|
||||
**/.symlinks/
|
||||
profile
|
||||
xcuserdata
|
||||
**/.generated/
|
||||
Flutter/App.framework
|
||||
Flutter/Flutter.framework
|
||||
Flutter/Flutter.podspec
|
||||
Flutter/Generated.xcconfig
|
||||
Flutter/ephemeral/
|
||||
Flutter/app.flx
|
||||
Flutter/app.zip
|
||||
Flutter/flutter_assets/
|
||||
Flutter/flutter_export_environment.sh
|
||||
ServiceDefinitions.json
|
||||
Runner/GeneratedPluginRegistrant.*
|
||||
|
||||
# Exceptions to above rules.
|
||||
!default.mode1v3
|
||||
!default.mode2v3
|
||||
!default.pbxuser
|
||||
!default.perspectivev3
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>App</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>io.flutter.flutter.app</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>App</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>FMWK</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1 @@
|
||||
#include "Generated.xcconfig"
|
||||
@@ -0,0 +1 @@
|
||||
#include "Generated.xcconfig"
|
||||
@@ -0,0 +1,644 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 54;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
|
||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
|
||||
remoteInfo = Runner;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 10;
|
||||
files = (
|
||||
);
|
||||
name = "Embed Frameworks";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
97C146EB1CF9000F007C117D /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
331C8082294A63A400263BE5 /* RunnerTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
331C807B294A618700263BE5 /* RunnerTests.swift */,
|
||||
);
|
||||
path = RunnerTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
||||
9740EEB31CF90195004384FC /* Generated.xcconfig */,
|
||||
);
|
||||
name = Flutter;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
97C146E51CF9000F007C117D = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9740EEB11CF90186004384FC /* Flutter */,
|
||||
97C146F01CF9000F007C117D /* Runner */,
|
||||
97C146EF1CF9000F007C117D /* Products */,
|
||||
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
97C146EF1CF9000F007C117D /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
97C146EE1CF9000F007C117D /* Runner.app */,
|
||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
97C146F01CF9000F007C117D /* Runner */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
97C146FA1CF9000F007C117D /* Main.storyboard */,
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */,
|
||||
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
|
||||
97C147021CF9000F007C117D /* Info.plist */,
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||
);
|
||||
path = Runner;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
331C8080294A63A400263BE5 /* RunnerTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
||||
buildPhases = (
|
||||
331C807D294A63A400263BE5 /* Sources */,
|
||||
331C807F294A63A400263BE5 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
331C8086294A63A400263BE5 /* PBXTargetDependency */,
|
||||
);
|
||||
name = RunnerTests;
|
||||
productName = RunnerTests;
|
||||
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
97C146ED1CF9000F007C117D /* Runner */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||
buildPhases = (
|
||||
9740EEB61CF901F6004384FC /* Run Script */,
|
||||
97C146EA1CF9000F007C117D /* Sources */,
|
||||
97C146EB1CF9000F007C117D /* Frameworks */,
|
||||
97C146EC1CF9000F007C117D /* Resources */,
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = Runner;
|
||||
packageProductDependencies = (
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
|
||||
);
|
||||
productName = Runner;
|
||||
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
97C146E61CF9000F007C117D /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastUpgradeCheck = 1510;
|
||||
ORGANIZATIONNAME = "";
|
||||
TargetAttributes = {
|
||||
331C8080294A63A400263BE5 = {
|
||||
CreatedOnToolsVersion = 14.0;
|
||||
TestTargetID = 97C146ED1CF9000F007C117D;
|
||||
};
|
||||
97C146ED1CF9000F007C117D = {
|
||||
CreatedOnToolsVersion = 7.3.1;
|
||||
LastSwiftMigration = 1100;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
|
||||
compatibilityVersion = "Xcode 9.3";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 97C146E51CF9000F007C117D;
|
||||
packageReferences = (
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
|
||||
);
|
||||
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
97C146ED1CF9000F007C117D /* Runner */,
|
||||
331C8080294A63A400263BE5 /* RunnerTests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
331C807F294A63A400263BE5 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
97C146EC1CF9000F007C117D /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
|
||||
);
|
||||
name = "Thin Binary";
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
|
||||
};
|
||||
9740EEB61CF901F6004384FC /* Run Script */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "Run Script";
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
331C807D294A63A400263BE5 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
97C146EA1CF9000F007C117D /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 97C146ED1CF9000F007C117D /* Runner */;
|
||||
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin PBXVariantGroup section */
|
||||
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
97C146FB1CF9000F007C117D /* Base */,
|
||||
);
|
||||
name = Main.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
97C147001CF9000F007C117D /* Base */,
|
||||
);
|
||||
name = LaunchScreen.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXVariantGroup section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
249021D3217E4FDB00AE95B9 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
249021D4217E4FDB00AE95B9 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.slogan.sloganApp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
331C8088294A63A400263BE5 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.slogan.sloganApp.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
331C8089294A63A400263BE5 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.slogan.sloganApp.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
331C808A294A63A400263BE5 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.slogan.sloganApp.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
97C147031CF9000F007C117D /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
97C147041CF9000F007C117D /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
97C147061CF9000F007C117D /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.slogan.sloganApp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
97C147071CF9000F007C117D /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.slogan.sloganApp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
331C8088294A63A400263BE5 /* Debug */,
|
||||
331C8089294A63A400263BE5 /* Release */,
|
||||
331C808A294A63A400263BE5 /* Profile */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
97C147031CF9000F007C117D /* Debug */,
|
||||
97C147041CF9000F007C117D /* Release */,
|
||||
249021D3217E4FDB00AE95B9 /* Profile */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
97C147061CF9000F007C117D /* Debug */,
|
||||
97C147071CF9000F007C117D /* Release */,
|
||||
249021D4217E4FDB00AE95B9 /* Profile */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = FlutterGeneratedPluginSwiftPackage;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 97C146E61CF9000F007C117D /* Project object */;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PreviewsEnabled</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,119 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1510"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<PreActions>
|
||||
<ExecutionAction
|
||||
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
|
||||
<ActionContent
|
||||
title = "Run Prepare Flutter Framework Script"
|
||||
scriptText = "/bin/sh "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" prepare ">
|
||||
<EnvironmentBuildable>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</EnvironmentBuildable>
|
||||
</ActionContent>
|
||||
</ExecutionAction>
|
||||
</PreActions>
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "331C8080294A63A400263BE5"
|
||||
BuildableName = "RunnerTests.xctest"
|
||||
BlueprintName = "RunnerTests"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
enableGPUValidationMode = "1"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Profile"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "group:Runner.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PreviewsEnabled</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,16 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
|
||||
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-20x20@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-20x20@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-40x40@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-40x40@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-60x60@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-60x60@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-20x20@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-20x20@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-29x29@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-29x29@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-40x40@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-40x40@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-76x76@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-76x76@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "83.5x83.5",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-83.5x83.5@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "1024x1024",
|
||||
"idiom" : "ios-marketing",
|
||||
"filename" : "Icon-App-1024x1024@1x.png",
|
||||
"scale" : "1x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 295 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 450 B |
|
After Width: | Height: | Size: 282 B |
|
After Width: | Height: | Size: 462 B |
|
After Width: | Height: | Size: 704 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 586 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 762 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "LaunchImage.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "LaunchImage@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "LaunchImage@3x.png",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 68 B |
|
After Width: | Height: | Size: 68 B |
|
After Width: | Height: | Size: 68 B |
@@ -0,0 +1,5 @@
|
||||
# Launch Screen Assets
|
||||
|
||||
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
|
||||
|
||||
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
|
||||
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--View Controller-->
|
||||
<scene sceneID="EHf-IW-A2E">
|
||||
<objects>
|
||||
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||
<layoutGuides>
|
||||
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
|
||||
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
|
||||
</layoutGuides>
|
||||
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
|
||||
</imageView>
|
||||
</subviews>
|
||||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="53" y="375"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="LaunchImage" width="168" height="185"/>
|
||||
</resources>
|
||||
</document>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--Flutter View Controller-->
|
||||
<scene sceneID="tne-QT-ifu">
|
||||
<objects>
|
||||
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
|
||||
<layoutGuides>
|
||||
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
|
||||
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
|
||||
</layoutGuides>
|
||||
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
|
||||
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>
|
||||
@@ -0,0 +1,70 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Slogan App</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>slogan_app</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
<key>UISceneConfigurations</key>
|
||||
<dict>
|
||||
<key>UIWindowSceneSessionRoleApplication</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneClassName</key>
|
||||
<string>UIWindowScene</string>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>flutter</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
|
||||
<key>UISceneStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1 @@
|
||||
#import "GeneratedPluginRegistrant.h"
|
||||
@@ -0,0 +1,6 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
|
||||
class SceneDelegate: FlutterSceneDelegate {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
import XCTest
|
||||
|
||||
class RunnerTests: XCTestCase {
|
||||
|
||||
func testExample() {
|
||||
// If you add code to the Runner application, consider adding tests here.
|
||||
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../config/app_config.dart';
|
||||
|
||||
/// 广告服务抽象:P0 用 Mock(保证业务链路可开发可测),P1 换穿山甲 SDK
|
||||
abstract class AdsService {
|
||||
/// 广告是否开通(AppConfig.pangleAppId 非空);未开通时广告位与激励均不渲染
|
||||
bool get enabled;
|
||||
Future<bool> showRewarded();
|
||||
|
||||
/// 横幅/信息流广告位(Mock 返回占位卡,P1 换原生广告组件)
|
||||
Widget showBanner(BuildContext context);
|
||||
}
|
||||
|
||||
/// 本地模拟广告(约 1 秒"播放"后返回完整观看)
|
||||
class MockAdsService implements AdsService {
|
||||
@override
|
||||
bool get enabled => AppConfig.pangleAppId.isNotEmpty;
|
||||
|
||||
@override
|
||||
Future<bool> showRewarded() async {
|
||||
await Future.delayed(const Duration(milliseconds: 900));
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget showBanner(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
height: 90,
|
||||
margin: const EdgeInsets.fromLTRB(12, 0, 12, 12),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.primaryContainer.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.campaign_outlined, size: 20),
|
||||
SizedBox(width: 8),
|
||||
Text('广告位(P1 接入穿山甲)', style: TextStyle(fontSize: 12)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final adsServiceProvider = Provider<AdsService>((ref) {
|
||||
// P1:AppConfig.pangleAppId 非空时替换为 PangleAdsService(穿山甲 SDK 实现)
|
||||
return MockAdsService();
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../network/api_client.dart';
|
||||
import '../storage/token_storage.dart';
|
||||
|
||||
class AuthState {
|
||||
final bool authenticated;
|
||||
final int? userId;
|
||||
final String? name;
|
||||
|
||||
const AuthState({
|
||||
this.authenticated = false,
|
||||
this.userId,
|
||||
this.name,
|
||||
});
|
||||
}
|
||||
|
||||
final tokenStorageProvider = Provider<TokenStorage>((ref) => TokenStorage());
|
||||
|
||||
final apiClientProvider = Provider<ApiClient>((ref) {
|
||||
final client = ApiClient(tokenStorage: ref.watch(tokenStorageProvider));
|
||||
client.onUnauthorized = () {
|
||||
ref.read(authProvider.notifier).logout();
|
||||
};
|
||||
return client;
|
||||
});
|
||||
|
||||
class AuthNotifier extends AsyncNotifier<AuthState> {
|
||||
@override
|
||||
Future<AuthState> build() async {
|
||||
final storage = ref.watch(tokenStorageProvider);
|
||||
await storage.load();
|
||||
return AuthState(authenticated: storage.hasToken);
|
||||
}
|
||||
|
||||
Future<void> login(String account, String password) async {
|
||||
state = const AsyncLoading();
|
||||
try {
|
||||
final client = ref.read(apiClientProvider);
|
||||
final data = await client.post<Map<String, dynamic>>('/user/login', {
|
||||
'account': account,
|
||||
'password': password,
|
||||
});
|
||||
final token = data['token'] as String? ?? '';
|
||||
final user = data['user'] as Map<String, dynamic>? ?? {};
|
||||
await ref.read(tokenStorageProvider).save(token);
|
||||
state = AsyncData(AuthState(
|
||||
authenticated: true,
|
||||
userId: (user['id'] as num?)?.toInt(),
|
||||
name: user['name'] as String?,
|
||||
));
|
||||
} catch (e) {
|
||||
state = AsyncError(e, StackTrace.current);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
await ref.read(tokenStorageProvider).clear();
|
||||
state = const AsyncData(AuthState());
|
||||
}
|
||||
}
|
||||
|
||||
final authProvider =
|
||||
AsyncNotifierProvider<AuthNotifier, AuthState>(AuthNotifier.new);
|
||||
@@ -0,0 +1,14 @@
|
||||
/// 全局配置
|
||||
class AppConfig {
|
||||
/// 后端地址:iOS 模拟器用 127.0.0.1;Android 模拟器用 10.0.2.2;真机填局域网 IP
|
||||
static const String baseUrl = 'http://127.0.0.1:3007';
|
||||
|
||||
/// 后端返回的相对路径(/workspace/...)拼成完整 URL
|
||||
static String resolveUrl(String path) {
|
||||
if (path.startsWith('http')) return path;
|
||||
return '$baseUrl$path';
|
||||
}
|
||||
|
||||
/// 穿山甲 App ID(空 = 广告未开通,广告位不渲染)
|
||||
static const String pangleAppId = '';
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../config/app_config.dart';
|
||||
import '../storage/token_storage.dart';
|
||||
import 'api_exception.dart';
|
||||
|
||||
/// 统一网络客户端:JWT 拦截器 + 统一响应解包(code != 0 抛 ApiException)
|
||||
class ApiClient {
|
||||
final TokenStorage _tokenStorage;
|
||||
late final Dio _dio;
|
||||
|
||||
/// 401 时回调(登出)
|
||||
void Function()? onUnauthorized;
|
||||
|
||||
// ignore: prefer_initializing_formals
|
||||
ApiClient({required TokenStorage tokenStorage, Dio? dio})
|
||||
: _tokenStorage = tokenStorage {
|
||||
_dio = dio ??
|
||||
Dio(BaseOptions(
|
||||
baseUrl: AppConfig.baseUrl,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 60),
|
||||
));
|
||||
_dio.interceptors.add(InterceptorsWrapper(
|
||||
onRequest: (options, handler) {
|
||||
final token = _tokenStorage.token;
|
||||
if (token != null && token.isNotEmpty) {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
handler.next(options);
|
||||
},
|
||||
onError: (e, handler) {
|
||||
if (e.response?.statusCode == 401) onUnauthorized?.call();
|
||||
handler.next(e);
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
Future<T> post<T>(String path, Map<String, dynamic> body,
|
||||
{T Function(dynamic data)? parse}) async {
|
||||
final res = await _dio.post(path, data: body);
|
||||
return _unwrap(res, parse);
|
||||
}
|
||||
|
||||
Future<T> get<T>(String path,
|
||||
{Map<String, dynamic>? query, T Function(dynamic data)? parse}) async {
|
||||
final res = await _dio.get(path, queryParameters: query);
|
||||
return _unwrap(res, parse);
|
||||
}
|
||||
|
||||
/// multipart 上传:fields 为文本字段,fileField 为文件字段名
|
||||
Future<T> upload<T>(String path, Map<String, dynamic> fields,
|
||||
String fileField, String filePath,
|
||||
{T Function(dynamic data)? parse}) async {
|
||||
final form = FormData.fromMap({
|
||||
...fields,
|
||||
fileField: await MultipartFile.fromFile(filePath),
|
||||
});
|
||||
final res = await _dio.post(path, data: form);
|
||||
return _unwrap(res, parse);
|
||||
}
|
||||
|
||||
T _unwrap<T>(Response res, T Function(dynamic data)? parse) {
|
||||
final data = res.data;
|
||||
if (data is! Map<String, dynamic>) {
|
||||
throw ApiException(-1, '响应格式错误');
|
||||
}
|
||||
final code = data['code'] as int? ?? -1;
|
||||
final message = data['message'] as String? ?? '';
|
||||
if (code != 0) {
|
||||
throw ApiException(code, message);
|
||||
}
|
||||
final payload = data['data'];
|
||||
if (parse != null) return parse(payload);
|
||||
return payload as T;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/// 业务异常(后端统一响应 code != 0)
|
||||
class ApiException implements Exception {
|
||||
final int code;
|
||||
final String message;
|
||||
|
||||
ApiException(this.code, this.message);
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// JWT token 本地存储
|
||||
class TokenStorage {
|
||||
static const _key = 'auth_token';
|
||||
String? _token;
|
||||
|
||||
String? get token => _token;
|
||||
|
||||
bool get hasToken => _token != null && _token!.isNotEmpty;
|
||||
|
||||
Future<void> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_token = prefs.getString(_key);
|
||||
}
|
||||
|
||||
Future<void> save(String token) async {
|
||||
_token = token;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_key, token);
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
_token = null;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/auth/auth_provider.dart';
|
||||
|
||||
class LoginPage extends ConsumerStatefulWidget {
|
||||
const LoginPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
final _accountCtrl = TextEditingController();
|
||||
final _passwordCtrl = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_accountCtrl.dispose();
|
||||
_passwordCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final account = _accountCtrl.text.trim();
|
||||
final password = _passwordCtrl.text;
|
||||
if (account.isEmpty || password.isEmpty) {
|
||||
Fluttertoast.showToast(msg: '请输入账号和密码');
|
||||
return;
|
||||
}
|
||||
await ref.read(authProvider.notifier).login(account, password);
|
||||
final state = ref.read(authProvider);
|
||||
if (state.hasError) {
|
||||
Fluttertoast.showToast(
|
||||
msg: state.error.toString().replaceFirst('Exception: ', ''));
|
||||
return;
|
||||
}
|
||||
if (state.value?.authenticated == true && mounted) {
|
||||
context.go('/home');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showRegisterDialog() async {
|
||||
final account = TextEditingController();
|
||||
final password = TextEditingController();
|
||||
final name = TextEditingController();
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('注册新账号'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: account,
|
||||
decoration: const InputDecoration(labelText: '账号')),
|
||||
TextField(
|
||||
controller: password,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(labelText: '密码(至少6位)')),
|
||||
TextField(
|
||||
controller: name,
|
||||
decoration: const InputDecoration(labelText: '昵称')),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('取消')),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('注册')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (result != true) return;
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
await api.post<Map<String, dynamic>>('/user/register', {
|
||||
'account': account.text.trim(),
|
||||
'password': password.text,
|
||||
'name': name.text.trim(),
|
||||
});
|
||||
Fluttertoast.showToast(msg: '注册成功,正在登录...');
|
||||
_accountCtrl.text = account.text.trim();
|
||||
_passwordCtrl.text = password.text;
|
||||
await _submit();
|
||||
} catch (e) {
|
||||
Fluttertoast.showToast(
|
||||
msg: '注册失败:${e.toString().replaceFirst('Exception: ', '')}');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final auth = ref.watch(authProvider);
|
||||
final loading = auth.isLoading;
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Icon(Icons.face_retouching_natural,
|
||||
size: 72, color: Theme.of(context).colorScheme.primary),
|
||||
const SizedBox(height: 12),
|
||||
const Text('我的形象穿搭',
|
||||
textAlign: TextAlign.center,
|
||||
style:
|
||||
TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
|
||||
const Text('拍出你的个人形象,生成专属穿搭方案',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey, fontSize: 13)),
|
||||
const SizedBox(height: 40),
|
||||
TextField(
|
||||
controller: _accountCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '账号',
|
||||
prefixIcon: Icon(Icons.person_outline),
|
||||
border: OutlineInputBorder()),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _passwordCtrl,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '密码',
|
||||
prefixIcon: Icon(Icons.lock_outline),
|
||||
border: OutlineInputBorder()),
|
||||
onSubmitted: (_) => _submit(),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
FilledButton(
|
||||
onPressed: loading ? null : _submit,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14)),
|
||||
child: loading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Text('登录'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: loading ? null : _showRegisterDialog,
|
||||
child: const Text('还没有账号?注册新账号'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../core/config/app_config.dart';
|
||||
import '../../features/member/member_provider.dart';
|
||||
import '../../shared/widgets/empty_view.dart';
|
||||
import '../../shared/widgets/error_view.dart';
|
||||
import '../../shared/widgets/loading_view.dart';
|
||||
import 'cps_provider.dart';
|
||||
|
||||
/// 联盟选品池:分类 chips + 上滑分页 + 转链跳转
|
||||
class CpsProductListPage extends ConsumerStatefulWidget {
|
||||
final CpsListArgs args;
|
||||
|
||||
const CpsProductListPage({required this.args, super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<CpsProductListPage> createState() =>
|
||||
_CpsProductListPageState();
|
||||
}
|
||||
|
||||
class _CpsProductListPageState extends ConsumerState<CpsProductListPage> {
|
||||
final _scrollController = ScrollController();
|
||||
String? _catCode; // null = 全部
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(() {
|
||||
if (_scrollController.position.pixels >=
|
||||
_scrollController.position.maxScrollExtent - 200) {
|
||||
ref
|
||||
.read(cpsProductProvider(widget.args).notifier)
|
||||
.loadMore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _openLink(CpsProductInfo product, String scene, int planId) async {
|
||||
try {
|
||||
final link = await ref.read(cpsLinkProvider).link(ref,
|
||||
productId: product.id, scene: scene, planId: planId);
|
||||
if (link.isEmpty) {
|
||||
Fluttertoast.showToast(msg: '暂无可跳转链接');
|
||||
return;
|
||||
}
|
||||
final uri = Uri.parse(link);
|
||||
if (uri.scheme.startsWith('http') &&
|
||||
await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
} else {
|
||||
// 淘口令等非 URL 内容:复制到剪贴板
|
||||
await _copy(link);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
Fluttertoast.showToast(
|
||||
msg: '跳转失败:${e.toString().replaceFirst('Exception: ', '')}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _copy(String text) async {
|
||||
final data = ClipboardData(text: text);
|
||||
await Clipboard.setData(data);
|
||||
if (mounted) Fluttertoast.showToast(msg: '已复制,打开淘宝即可购买');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final categories = ref.watch(cpsCategoryProvider);
|
||||
final products = ref.watch(cpsProductProvider(widget.args));
|
||||
final member = ref.watch(memberProvider).value;
|
||||
final isVip = member?.isVip ?? false;
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(widget.args.title)),
|
||||
body: Column(
|
||||
children: [
|
||||
categories.when(
|
||||
loading: () => const SizedBox(height: 48),
|
||||
error: (_, _) => const SizedBox(height: 48),
|
||||
data: (cats) {
|
||||
final shown = widget.args.categoryCode.isNotEmpty
|
||||
? cats.where((c) => c.code == widget.args.categoryCode).toList()
|
||||
: cats;
|
||||
if (shown.isEmpty) return const SizedBox(height: 48);
|
||||
return SizedBox(
|
||||
height: 48,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ChoiceChip(
|
||||
label: const Text('全部'),
|
||||
selected: _catCode == null,
|
||||
onSelected: (_) => setState(() => _catCode = null),
|
||||
),
|
||||
),
|
||||
for (final c in shown)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ChoiceChip(
|
||||
label: Text(c.name),
|
||||
selected: _catCode == c.code,
|
||||
onSelected: (_) => setState(() => _catCode = c.code),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: products.when(
|
||||
loading: () => const LoadingView(text: '加载商品...'),
|
||||
error: (e, _) => ErrorView(
|
||||
message: e.toString().replaceFirst('Exception: ', ''),
|
||||
onRetry: () =>
|
||||
ref.refresh(cpsProductProvider(widget.args)),
|
||||
),
|
||||
data: (list) {
|
||||
final shown = _catCode == null
|
||||
? list
|
||||
: list.where((p) => p.categoryCode == _catCode).toList();
|
||||
if (shown.isEmpty) {
|
||||
return const EmptyView(message: '暂无联盟商品');
|
||||
}
|
||||
return GridView.builder(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.all(12),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
childAspectRatio: 0.72,
|
||||
),
|
||||
itemCount: shown.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final p = shown[i];
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: () =>
|
||||
_openLink(p, widget.args.scene, 0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
p.coverUrl.isEmpty
|
||||
? const Icon(Icons.shopping_bag_outlined,
|
||||
size: 48, color: Colors.grey)
|
||||
: Image.network(
|
||||
AppConfig.resolveUrl(p.coverUrl),
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => const Icon(
|
||||
Icons.broken_image_outlined,
|
||||
size: 48,
|
||||
color: Colors.grey),
|
||||
),
|
||||
if (isVip)
|
||||
Positioned(
|
||||
top: 6,
|
||||
right: 6,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.amber.shade600,
|
||||
borderRadius:
|
||||
BorderRadius.circular(6),
|
||||
),
|
||||
child: const Text('VIP 返现 1.5x',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
p.name,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'¥${p.priceText}',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.red.shade600),
|
||||
),
|
||||
if (p.commissionText.isNotEmpty)
|
||||
Text(
|
||||
p.commissionText,
|
||||
style: const TextStyle(
|
||||
fontSize: 11, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/auth/auth_provider.dart';
|
||||
|
||||
/// CPS 联盟商品(后端 /cps/* 模型)
|
||||
class CpsProductInfo {
|
||||
final int id;
|
||||
final String source;
|
||||
final String outerId;
|
||||
final String categoryCode;
|
||||
final String name;
|
||||
final String coverUrl;
|
||||
final int priceFen;
|
||||
final String shopName;
|
||||
final int commissionRate; // 万分比
|
||||
final String city;
|
||||
|
||||
const CpsProductInfo({
|
||||
required this.id,
|
||||
required this.source,
|
||||
required this.outerId,
|
||||
required this.categoryCode,
|
||||
required this.name,
|
||||
required this.coverUrl,
|
||||
required this.priceFen,
|
||||
required this.shopName,
|
||||
required this.commissionRate,
|
||||
required this.city,
|
||||
});
|
||||
|
||||
String get priceText => (priceFen / 100).toStringAsFixed(0);
|
||||
|
||||
String get commissionText {
|
||||
final pct = commissionRate / 100;
|
||||
return pct > 0 ? '佣金 ${pct.toStringAsFixed(1)}%' : '';
|
||||
}
|
||||
}
|
||||
|
||||
CpsProductInfo _parseProduct(Map<String, dynamic> e) => CpsProductInfo(
|
||||
id: (e['id'] as num).toInt(),
|
||||
source: e['source'] as String? ?? '',
|
||||
outerId: e['outer_id'] as String? ?? '',
|
||||
categoryCode: e['category_code'] as String? ?? '',
|
||||
name: e['name'] as String? ?? '',
|
||||
coverUrl: e['cover_url'] as String? ?? '',
|
||||
priceFen: (e['price_fen'] as num?)?.toInt() ?? 0,
|
||||
shopName: e['shop_name'] as String? ?? '',
|
||||
commissionRate: (e['commission_rate'] as num?)?.toInt() ?? 0,
|
||||
city: e['city'] as String? ?? '',
|
||||
);
|
||||
|
||||
/// 联盟分类(chips)
|
||||
class CpsCategoryInfo {
|
||||
final String code;
|
||||
final String name;
|
||||
final String source;
|
||||
|
||||
const CpsCategoryInfo({
|
||||
required this.code,
|
||||
required this.name,
|
||||
required this.source,
|
||||
});
|
||||
}
|
||||
|
||||
final cpsCategoryProvider = FutureProvider<List<CpsCategoryInfo>>((ref) async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final data = await api.get<Map<String, dynamic>>('/cps/category/list');
|
||||
final list = data['list'] as List<dynamic>? ?? [];
|
||||
return list
|
||||
.map((e) => CpsCategoryInfo(
|
||||
code: e['code'] as String? ?? '',
|
||||
name: e['name'] as String? ?? '',
|
||||
source: e['source'] as String? ?? '',
|
||||
))
|
||||
.toList();
|
||||
});
|
||||
|
||||
/// 商品列表页参数
|
||||
class CpsListArgs {
|
||||
final String title;
|
||||
final String source;
|
||||
final String categoryCode;
|
||||
final String city;
|
||||
final String scene; // 点击日志场景标记(haircut/item_buy/...)
|
||||
|
||||
const CpsListArgs({
|
||||
required this.title,
|
||||
this.source = '',
|
||||
this.categoryCode = '',
|
||||
this.city = '',
|
||||
this.scene = '',
|
||||
});
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is CpsListArgs &&
|
||||
other.source == source &&
|
||||
other.categoryCode == categoryCode &&
|
||||
other.city == city &&
|
||||
other.scene == scene;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(source, categoryCode, city, scene);
|
||||
}
|
||||
|
||||
/// 选品池分页(上滑加载更多;Riverpod 3 family 参数经构造函数注入)
|
||||
class CpsProductListNotifier extends AsyncNotifier<List<CpsProductInfo>> {
|
||||
CpsProductListNotifier(this.arg);
|
||||
|
||||
final CpsListArgs arg;
|
||||
int _page = 1;
|
||||
bool _hasMore = true;
|
||||
|
||||
@override
|
||||
Future<List<CpsProductInfo>> build() async {
|
||||
_page = 1;
|
||||
_hasMore = true;
|
||||
return _fetch(1);
|
||||
}
|
||||
|
||||
Future<void> loadMore() async {
|
||||
if (!_hasMore || state.isLoading) return;
|
||||
_page += 1;
|
||||
try {
|
||||
final next = await _fetch(_page);
|
||||
state = AsyncData([...?state.value, ...next]);
|
||||
} catch (_) {
|
||||
_page -= 1; // 失败回退页码,允许下次重试
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<CpsProductInfo>> _fetch(int page) async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final data = await api.get<Map<String, dynamic>>('/cps/product/list',
|
||||
query: {
|
||||
if (arg.source.isNotEmpty) 'source': arg.source,
|
||||
if (arg.categoryCode.isNotEmpty) 'category_code': arg.categoryCode,
|
||||
if (arg.city.isNotEmpty) 'city': arg.city,
|
||||
'page': page,
|
||||
});
|
||||
_hasMore = data['has_more'] as bool? ?? false;
|
||||
return (data['list'] as List<dynamic>? ?? [])
|
||||
.map((e) => _parseProduct(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
final cpsProductProvider = AsyncNotifierProvider.family<
|
||||
CpsProductListNotifier, List<CpsProductInfo>, CpsListArgs>(
|
||||
CpsProductListNotifier.new);
|
||||
|
||||
/// 方案驱动推荐(plan_id + scene)
|
||||
final cpsRecommendProvider = FutureProvider.family<List<CpsProductInfo>,
|
||||
({int planId, String scene})>((ref, args) async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final data = await api.get<Map<String, dynamic>>('/cps/plan/recommend',
|
||||
query: {'plan_id': args.planId, 'scene': args.scene});
|
||||
return (data['list'] as List<dynamic>? ?? [])
|
||||
.map((e) => _parseProduct(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
});
|
||||
|
||||
/// 衣橱升级款
|
||||
final cpsUpgradeProvider =
|
||||
FutureProvider.family<List<CpsProductInfo>, int>((ref, itemId) async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final data = await api.get<Map<String, dynamic>>('/cps/wardrobe/upgrade',
|
||||
query: {'item_id': itemId});
|
||||
return (data['list'] as List<dynamic>? ?? [])
|
||||
.map((e) => _parseProduct(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
});
|
||||
|
||||
/// 转链动作(POST /cps/product/link,返回 deeplink)
|
||||
class CpsLinkAction {
|
||||
Future<String> link(
|
||||
WidgetRef ref, {
|
||||
required int productId,
|
||||
String scene = '',
|
||||
int planId = 0,
|
||||
}) async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final data = await api.post<Map<String, dynamic>>('/cps/product/link', {
|
||||
'product_id': productId,
|
||||
'scene': scene,
|
||||
'plan_id': planId,
|
||||
});
|
||||
return data['deeplink'] as String? ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
final cpsLinkProvider = Provider<CpsLinkAction>((ref) => CpsLinkAction());
|
||||
|
||||
/// 最近优惠(会员中心)
|
||||
final cpsRecentProvider = FutureProvider<List<CpsProductInfo>>((ref) async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final data = await api.get<Map<String, dynamic>>('/cps/my/recent');
|
||||
return (data['list'] as List<dynamic>? ?? [])
|
||||
.map((e) => _parseProduct(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/ads/ads_service.dart';
|
||||
import '../mine/mine_page.dart';
|
||||
import '../outfit/outfit_page.dart';
|
||||
|
||||
/// 主框架:2 Tab(穿搭 = 造型方案制作向导 / 我的 = 个人信息管理入口)
|
||||
class HomePage extends ConsumerStatefulWidget {
|
||||
const HomePage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<HomePage> createState() => _HomePageState();
|
||||
}
|
||||
|
||||
class _HomePageState extends ConsumerState<HomePage> {
|
||||
int _index = 0;
|
||||
bool _splashShown = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 开屏广告:仅广告开通时展示一次(P0 Mock 下 pangleAppId 为空 → 跳过)
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_splashShown) return;
|
||||
if (!ref.read(adsServiceProvider).enabled) return;
|
||||
_splashShown = true;
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => _SplashAdOverlay(
|
||||
onSkip: () => Navigator.of(context).pop(),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: IndexedStack(
|
||||
index: _index,
|
||||
children: const [
|
||||
OutfitPage(),
|
||||
MinePage(),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _index,
|
||||
onDestinationSelected: (i) => setState(() => _index = i),
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.auto_awesome_outlined),
|
||||
selectedIcon: Icon(Icons.auto_awesome),
|
||||
label: '穿搭'),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.person_outline),
|
||||
selectedIcon: Icon(Icons.person),
|
||||
label: '我的'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 开屏广告:3 秒倒计时后自动关闭,右上角可手动跳过
|
||||
class _SplashAdOverlay extends StatefulWidget {
|
||||
final VoidCallback onSkip;
|
||||
|
||||
const _SplashAdOverlay({required this.onSkip});
|
||||
|
||||
@override
|
||||
State<_SplashAdOverlay> createState() => _SplashAdOverlayState();
|
||||
}
|
||||
|
||||
class _SplashAdOverlayState extends State<_SplashAdOverlay> {
|
||||
Timer? _timer;
|
||||
int _seconds = 3;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (_seconds <= 1) {
|
||||
_timer?.cancel();
|
||||
if (mounted) widget.onSkip();
|
||||
return;
|
||||
}
|
||||
setState(() => _seconds -= 1);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
child: ColoredBox(
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
child: Stack(
|
||||
children: [
|
||||
const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.campaign_outlined, size: 64),
|
||||
SizedBox(height: 12),
|
||||
Text('开屏广告位',
|
||||
style:
|
||||
TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||
SizedBox(height: 4),
|
||||
Text('P1 接入穿山甲开屏广告',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey)),
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 24,
|
||||
right: 24,
|
||||
child: OutlinedButton(
|
||||
onPressed: widget.onSkip,
|
||||
child: Text('跳过($_seconds)'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/auth/auth_provider.dart';
|
||||
|
||||
/// 会员 provider:会员状态/套餐/下单/轮询/领奖
|
||||
class MemberInfo {
|
||||
final bool isVip;
|
||||
final String expireAt;
|
||||
final String planName;
|
||||
final List<String> benefits;
|
||||
|
||||
const MemberInfo({
|
||||
required this.isVip,
|
||||
required this.expireAt,
|
||||
required this.planName,
|
||||
required this.benefits,
|
||||
});
|
||||
|
||||
factory MemberInfo.fromJson(Map<String, dynamic> e) => MemberInfo(
|
||||
isVip: e['is_vip'] as bool? ?? false,
|
||||
expireAt: e['expire_at'] as String? ?? '',
|
||||
planName: e['plan_name'] as String? ?? '',
|
||||
benefits: (e['benefits'] as List<dynamic>? ?? []).cast<String>(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 权益 key → 文案
|
||||
const benefitLabels = {
|
||||
'effect_unlimited': '无限效果图',
|
||||
'ai_priority': '优先 AI 方案',
|
||||
'cps_commission_x15': '返现加成 1.5x',
|
||||
'store_discount': '门店折扣',
|
||||
};
|
||||
|
||||
List<String> benefitTexts(MemberInfo m) =>
|
||||
m.benefits.map((b) => benefitLabels[b] ?? b).toList();
|
||||
|
||||
class MemberNotifier extends AsyncNotifier<MemberInfo> {
|
||||
@override
|
||||
Future<MemberInfo> build() async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final data = await api.get<Map<String, dynamic>>('/member/status');
|
||||
return MemberInfo.fromJson(data);
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
state = await AsyncValue.guard(build);
|
||||
}
|
||||
|
||||
/// 领取广告激励(服务端限频);adType: effect_extra | vip_trial
|
||||
/// 返回当日剩余次数;超出限频抛 ApiException
|
||||
Future<int> claimReward(String adType) async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final data =
|
||||
await api.post<Map<String, dynamic>>('/ad/reward/claim', {'ad_type': adType});
|
||||
await refresh(); // vip_trial 可能开通体验会员
|
||||
return (data['reward']?['remaining_today'] as num?)?.toInt() ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
final memberProvider =
|
||||
AsyncNotifierProvider<MemberNotifier, MemberInfo>(MemberNotifier.new);
|
||||
|
||||
class MemberPlan {
|
||||
final int id;
|
||||
final String name;
|
||||
final int priceFen;
|
||||
final int durationDays;
|
||||
final List<String> features;
|
||||
|
||||
const MemberPlan({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.priceFen,
|
||||
required this.durationDays,
|
||||
required this.features,
|
||||
});
|
||||
|
||||
factory MemberPlan.fromJson(Map<String, dynamic> e) => MemberPlan(
|
||||
id: (e['id'] as num).toInt(),
|
||||
name: e['name'] as String? ?? '',
|
||||
priceFen: (e['price_fen'] as num?)?.toInt() ?? 0,
|
||||
durationDays: (e['duration_days'] as num?)?.toInt() ?? 30,
|
||||
features: _parseFeatures(e['features'] as String? ?? ''),
|
||||
);
|
||||
|
||||
static List<String> _parseFeatures(String s) {
|
||||
try {
|
||||
return (jsonDecode(s) as List<dynamic>).cast<String>();
|
||||
} catch (_) {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
String get priceText =>
|
||||
'¥${(priceFen / 100).toStringAsFixed(priceFen % 100 == 0 ? 0 : 1)}';
|
||||
}
|
||||
|
||||
final memberPlanProvider = FutureProvider<List<MemberPlan>>((ref) async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final data = await api.get<Map<String, dynamic>>('/member/plan/list');
|
||||
final list = data['list'] as List<dynamic>? ?? [];
|
||||
return list
|
||||
.map((e) => MemberPlan.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
});
|
||||
|
||||
class OrderResult {
|
||||
final String orderNo;
|
||||
final String payUrl;
|
||||
|
||||
const OrderResult({required this.orderNo, required this.payUrl});
|
||||
}
|
||||
|
||||
/// 创建支付订单(后端调虎皮棋下单,返回收银台 URL)
|
||||
Future<OrderResult> createMemberOrder(WidgetRef ref, int planId) async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final data =
|
||||
await api.post<Map<String, dynamic>>('/member/order/create', {'plan_id': planId});
|
||||
return OrderResult(
|
||||
orderNo: data['order_no'] as String? ?? '',
|
||||
payUrl: data['pay_url'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
/// 订单状态(支付页 2s 轮询):pending | paid | closed
|
||||
Future<String> fetchOrderStatus(WidgetRef ref, String orderNo) async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final data = await api.get<Map<String, dynamic>>('/member/order/status',
|
||||
query: {'order_no': orderNo});
|
||||
return data['status'] as String? ?? '';
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import 'member_provider.dart';
|
||||
|
||||
class PayArgs {
|
||||
final String orderNo;
|
||||
final String payUrl;
|
||||
|
||||
const PayArgs({required this.orderNo, required this.payUrl});
|
||||
}
|
||||
|
||||
enum PayPhase { launching, paying, paid, timeout, failed }
|
||||
|
||||
/// 支付页:打开系统浏览器收银台,2s 轮询订单状态(上限 60s)
|
||||
class PayPage extends ConsumerStatefulWidget {
|
||||
final PayArgs args;
|
||||
|
||||
const PayPage({super.key, required this.args});
|
||||
|
||||
@override
|
||||
ConsumerState<PayPage> createState() => _PayPageState();
|
||||
}
|
||||
|
||||
class _PayPageState extends ConsumerState<PayPage> {
|
||||
PayPhase _phase = PayPhase.launching;
|
||||
Timer? _timer;
|
||||
int _elapsed = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_start();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _start() async {
|
||||
try {
|
||||
final ok = await launchUrl(Uri.parse(widget.args.payUrl),
|
||||
mode: LaunchMode.externalApplication);
|
||||
if (!ok) {
|
||||
setState(() => _phase = PayPhase.failed);
|
||||
return;
|
||||
}
|
||||
setState(() => _phase = PayPhase.paying);
|
||||
} catch (e) {
|
||||
setState(() => _phase = PayPhase.failed);
|
||||
return;
|
||||
}
|
||||
_timer = Timer.periodic(const Duration(seconds: 2), (_) => _check());
|
||||
}
|
||||
|
||||
Future<void> _check() async {
|
||||
_elapsed += 2;
|
||||
try {
|
||||
final status = await fetchOrderStatus(ref, widget.args.orderNo);
|
||||
if (status == 'paid') {
|
||||
_timer?.cancel();
|
||||
if (!mounted) return;
|
||||
setState(() => _phase = PayPhase.paid);
|
||||
Fluttertoast.showToast(msg: '会员开通成功');
|
||||
return;
|
||||
}
|
||||
if (_elapsed >= 60) {
|
||||
_timer?.cancel();
|
||||
if (!mounted) return;
|
||||
setState(() => _phase = PayPhase.timeout);
|
||||
}
|
||||
} catch (_) {
|
||||
// 轮询失败不中断,下次再试
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('会员支付')),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
switch (_phase) {
|
||||
PayPhase.launching ||
|
||||
PayPhase.paying => Column(children: [
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
const Text('请在浏览器中完成支付,正在确认结果…'),
|
||||
const SizedBox(height: 8),
|
||||
Text('订单号 ${widget.args.orderNo}',
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
const SizedBox(height: 16),
|
||||
OutlinedButton(
|
||||
onPressed: () => _check(),
|
||||
child: const Text('我已完成支付'),
|
||||
),
|
||||
]),
|
||||
PayPhase.paid => Column(children: [
|
||||
Icon(Icons.check_circle, size: 64, color: scheme.primary),
|
||||
const SizedBox(height: 12),
|
||||
const Text('支付成功,会员已开通!',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: () => context.pop(),
|
||||
child: const Text('返回会员中心'),
|
||||
),
|
||||
]),
|
||||
PayPhase.timeout => Column(children: [
|
||||
Icon(Icons.hourglass_empty, size: 64, color: Colors.orange),
|
||||
const SizedBox(height: 12),
|
||||
const Text('支付结果确认中'),
|
||||
const SizedBox(height: 8),
|
||||
const Text('可稍后到会员中心查看开通状态,以支付结果为准',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
const SizedBox(height: 16),
|
||||
OutlinedButton(
|
||||
onPressed: () => context.pop(),
|
||||
child: const Text('返回'),
|
||||
),
|
||||
]),
|
||||
PayPhase.failed => Column(children: [
|
||||
Icon(Icons.error_outline, size: 64, color: scheme.error),
|
||||
const SizedBox(height: 12),
|
||||
const Text('无法打开支付页面'),
|
||||
const SizedBox(height: 16),
|
||||
OutlinedButton(
|
||||
onPressed: () => context.pop(),
|
||||
child: const Text('返回'),
|
||||
),
|
||||
]),
|
||||
},
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/ads/ads_service.dart';
|
||||
import '../../core/auth/auth_provider.dart';
|
||||
import '../../core/config/app_config.dart';
|
||||
import '../../features/cps/cps_provider.dart';
|
||||
import '../../shared/widgets/loading_view.dart';
|
||||
import '../member/member_provider.dart';
|
||||
import '../member/pay_page.dart';
|
||||
|
||||
/// 我的:个人信息管理(会员卡/免费权益/最近优惠 + 形象/衣橱入口 + 退出登录)
|
||||
class MinePage extends ConsumerStatefulWidget {
|
||||
const MinePage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<MinePage> createState() => _MinePageState();
|
||||
}
|
||||
|
||||
class _MinePageState extends ConsumerState<MinePage> {
|
||||
bool _rewarding = false;
|
||||
|
||||
// web 上无 Platform(dart:io 不可用),且 web 端默认走系统浏览器收银台
|
||||
bool get _isIOS =>
|
||||
!kIsWeb && defaultTargetPlatform == TargetPlatform.iOS;
|
||||
|
||||
Future<void> _openPlans() async {
|
||||
final plans = await showModalBottomSheet<MemberPlan>(
|
||||
context: context,
|
||||
builder: (ctx) => const _PlanSheet(),
|
||||
);
|
||||
if (plans == null || !mounted) return;
|
||||
try {
|
||||
final result = await createMemberOrder(ref, plans.id);
|
||||
if (!mounted || result.payUrl.isEmpty) return;
|
||||
await context.push(
|
||||
'/pay', extra: PayArgs(orderNo: result.orderNo, payUrl: result.payUrl));
|
||||
ref.read(memberProvider.notifier).refresh();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
Fluttertoast.showToast(
|
||||
msg: e.toString().replaceFirst('Exception: ', ''));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _claimReward(String adType, String successMsg) async {
|
||||
if (_rewarding) return;
|
||||
final ads = ref.read(adsServiceProvider);
|
||||
if (!ads.enabled) {
|
||||
Fluttertoast.showToast(msg: '广告功能暂未开通');
|
||||
return;
|
||||
}
|
||||
setState(() => _rewarding = true);
|
||||
try {
|
||||
final watched = await ads.showRewarded();
|
||||
if (!watched) {
|
||||
Fluttertoast.showToast(msg: '未完整观看,无法领取');
|
||||
return;
|
||||
}
|
||||
final remaining =
|
||||
await ref.read(memberProvider.notifier).claimReward(adType);
|
||||
if (!mounted) return;
|
||||
Fluttertoast.showToast(msg: '$successMsg(今日剩余 $remaining 次)');
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
Fluttertoast.showToast(
|
||||
msg: e.toString().replaceFirst('Exception: ', ''));
|
||||
} finally {
|
||||
if (mounted) setState(() => _rewarding = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final member = ref.watch(memberProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('我的')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_MemberCard(
|
||||
member: member,
|
||||
isIOS: _isIOS,
|
||||
onOpenPlans: _openPlans,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const _RecentDealsCard(),
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
child: Column(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.face_retouching_natural),
|
||||
title: const Text('我的形象'),
|
||||
subtitle: const Text('拍摄照片 · 身形参数 · 生成 3D 化身'),
|
||||
trailing:
|
||||
const Icon(Icons.chevron_right, color: Colors.grey),
|
||||
onTap: () => context.push('/avatar-build'),
|
||||
),
|
||||
const Divider(height: 1, indent: 56),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.checkroom),
|
||||
title: const Text('我的衣橱'),
|
||||
subtitle: const Text('管理服装单品,AI 生成时自动搭配'),
|
||||
trailing:
|
||||
const Icon(Icons.chevron_right, color: Colors.grey),
|
||||
onTap: () => context.push('/wardrobe'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (member.value?.isVip == false) ...[
|
||||
const SizedBox(height: 12),
|
||||
_AdsRewardCard(
|
||||
rewarding: _rewarding,
|
||||
onClaim: (adType, msg) => _claimReward(adType, msg),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () async {
|
||||
await ref.read(authProvider.notifier).logout();
|
||||
if (context.mounted) context.go('/login');
|
||||
},
|
||||
icon: const Icon(Icons.logout),
|
||||
label: const Text('退出登录'),
|
||||
style: OutlinedButton.styleFrom(foregroundColor: Colors.red),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MemberCard extends ConsumerWidget {
|
||||
final AsyncValue<MemberInfo> member;
|
||||
final bool isIOS;
|
||||
final VoidCallback onOpenPlans;
|
||||
|
||||
const _MemberCard(
|
||||
{required this.member, required this.isIOS, required this.onOpenPlans});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Card(
|
||||
color: scheme.primaryContainer,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: member.when(
|
||||
loading: () => const Text('加载会员状态...',
|
||||
style: TextStyle(fontSize: 13)),
|
||||
error: (e, _) => Text('会员状态加载失败:$e',
|
||||
style: const TextStyle(fontSize: 12)),
|
||||
data: (m) {
|
||||
if (m.isVip) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(children: [
|
||||
Icon(Icons.workspace_premium,
|
||||
size: 30, color: scheme.primary),
|
||||
const SizedBox(width: 10),
|
||||
const Text('形象会员',
|
||||
style: TextStyle(
|
||||
fontSize: 17, fontWeight: FontWeight.bold)),
|
||||
const Spacer(),
|
||||
Chip(
|
||||
label: Text(m.planName),
|
||||
labelStyle:
|
||||
TextStyle(color: scheme.primary, fontSize: 12),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 6),
|
||||
Text('有效期至 ${m.expireAt}',
|
||||
style:
|
||||
TextStyle(fontSize: 12, color: scheme.primary)),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: FilledButton.tonalIcon(
|
||||
onPressed: () => _showMemberCode(context, m),
|
||||
icon: const Icon(Icons.qr_code_2, size: 18),
|
||||
label: const Text('会员码'),
|
||||
),
|
||||
),
|
||||
if (benefitTexts(m).isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
for (final b in benefitTexts(m))
|
||||
Chip(
|
||||
label: Text(b),
|
||||
labelStyle: const TextStyle(fontSize: 11),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Row(children: [
|
||||
Icon(Icons.workspace_premium, size: 30),
|
||||
SizedBox(width: 10),
|
||||
Text('形象会员',
|
||||
style: TextStyle(
|
||||
fontSize: 17, fontWeight: FontWeight.bold)),
|
||||
]),
|
||||
const SizedBox(height: 6),
|
||||
const Text('会员专享:无限次效果图生成 · 优先 AI 方案 · 门店专属折扣',
|
||||
style: TextStyle(fontSize: 12)),
|
||||
const SizedBox(height: 10),
|
||||
if (isIOS)
|
||||
const Text('iOS 端暂不支持充值(App Store 政策),可观看广告获得体验会员',
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey))
|
||||
else
|
||||
FilledButton.icon(
|
||||
onPressed: onOpenPlans,
|
||||
icon: const Icon(Icons.payment, size: 18),
|
||||
label: const Text('开通会员'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 会员码(纯客户端展示,门店出示核销)
|
||||
void _showMemberCode(BuildContext context, MemberInfo m) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('会员码'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(ctx).colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(Icons.qr_code_2, size: 96),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text('${m.planName} · 有效期至 ${m.expireAt}',
|
||||
style: const TextStyle(fontSize: 12, color: Colors.grey)),
|
||||
const SizedBox(height: 4),
|
||||
const Text('到店出示即可享受会员价与专属折扣',
|
||||
style: TextStyle(fontSize: 12)),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('关闭'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 最近优惠卡:接口错误或空列表时整卡隐藏
|
||||
class _RecentDealsCard extends ConsumerWidget {
|
||||
const _RecentDealsCard();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final deals = ref.watch(cpsRecentProvider).value;
|
||||
if (deals == null || deals.isEmpty) return const SizedBox.shrink();
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('最近优惠',
|
||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
for (final p in deals.take(5))
|
||||
ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: p.coverUrl.isEmpty
|
||||
? const SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Icon(Icons.shopping_bag_outlined,
|
||||
color: Colors.grey),
|
||||
)
|
||||
: Image.network(
|
||||
AppConfig.resolveUrl(p.coverUrl),
|
||||
width: 40,
|
||||
height: 40,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => const SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Icon(Icons.broken_image_outlined,
|
||||
color: Colors.grey),
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(p.name,
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
subtitle: Text(
|
||||
'¥${p.priceText}${p.shopName.isNotEmpty ? ' · ${p.shopName}' : ''}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
trailing:
|
||||
const Icon(Icons.chevron_right, size: 18, color: Colors.grey),
|
||||
onTap: () => context.push('/cps-product-list',
|
||||
extra: CpsListArgs(
|
||||
title: '最近优惠',
|
||||
source: p.source,
|
||||
categoryCode: p.categoryCode,
|
||||
city: p.city,
|
||||
scene: 'member_benefit',
|
||||
)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AdsRewardCard extends ConsumerWidget {
|
||||
final bool rewarding;
|
||||
final void Function(String adType, String msg) onClaim;
|
||||
|
||||
const _AdsRewardCard({required this.rewarding, required this.onClaim});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('免费获取权益',
|
||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.ondemand_video, color: Colors.deepPurple),
|
||||
title: const Text('看视频 · 效果图 +1'),
|
||||
subtitle: const Text('每日最多 2 次,次日重置'),
|
||||
trailing: OutlinedButton(
|
||||
onPressed: rewarding
|
||||
? null
|
||||
: () => onClaim('effect_extra', '已获得 1 次效果图'),
|
||||
child: const Text('看视频'),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.ondemand_video, color: Colors.teal),
|
||||
title: const Text('看视频 · 体验会员 1 天'),
|
||||
subtitle: const Text('每日最多 1 次,含无限效果图'),
|
||||
trailing: OutlinedButton(
|
||||
onPressed: rewarding
|
||||
? null
|
||||
: () => onClaim('vip_trial', '已获得 1 天体验会员'),
|
||||
child: const Text('看视频'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PlanSheet extends ConsumerWidget {
|
||||
const _PlanSheet();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final plans = ref.watch(memberPlanProvider);
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text('选择会员套餐',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 12),
|
||||
plans.when(
|
||||
loading: () => const Padding(
|
||||
padding: EdgeInsets.all(24), child: LoadingView()),
|
||||
error: (e, _) => Text('套餐加载失败:$e',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.grey)),
|
||||
data: (list) => list.isEmpty
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Text('暂未开放套餐', textAlign: TextAlign.center),
|
||||
)
|
||||
: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final p in list)
|
||||
ListTile(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8)),
|
||||
tileColor: Theme.of(context)
|
||||
.colorScheme
|
||||
.primaryContainer
|
||||
.withValues(alpha: 0.5),
|
||||
title: Text(p.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 15, fontWeight: FontWeight.w600)),
|
||||
subtitle: Text(
|
||||
'${p.durationDays} 天 · ${p.features.map((f) => benefitLabels[f] ?? f).join(' · ')}',
|
||||
style: const TextStyle(fontSize: 12),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: Text(p.priceText,
|
||||
style: TextStyle(
|
||||
color:
|
||||
Theme.of(context).colorScheme.primary,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold)),
|
||||
onTap: () => Navigator.pop(context, p),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../shared/widgets/empty_view.dart';
|
||||
import '../../shared/widgets/error_view.dart';
|
||||
import '../../shared/widgets/loading_view.dart';
|
||||
import 'outfit_provider.dart';
|
||||
|
||||
/// 穿搭:任务参数(日期范围/地点)→ 生成造型方案;个人信息在「我的形象」管理
|
||||
class OutfitPage extends ConsumerStatefulWidget {
|
||||
const OutfitPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<OutfitPage> createState() => _OutfitPageState();
|
||||
}
|
||||
|
||||
class _OutfitPageState extends ConsumerState<OutfitPage> {
|
||||
DateTime? _startDate;
|
||||
DateTime? _endDate;
|
||||
final _locationCtrl = TextEditingController();
|
||||
String _occasion = '通勤';
|
||||
bool _submitting = false;
|
||||
|
||||
static const _occasions = ['通勤', '约会', '聚会', '运动'];
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_locationCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _pickStartDate() async {
|
||||
final now = DateTime.now();
|
||||
final date = await showDatePicker(
|
||||
context: context,
|
||||
firstDate: now,
|
||||
lastDate: now.add(const Duration(days: 30)),
|
||||
initialDate: _startDate ?? now,
|
||||
);
|
||||
if (date == null) return;
|
||||
setState(() {
|
||||
_startDate = date;
|
||||
if (_endDate != null && _endDate!.isBefore(date)) {
|
||||
_endDate = date;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pickEndDate() async {
|
||||
final now = DateTime.now();
|
||||
final first = _startDate ?? now;
|
||||
final date = await showDatePicker(
|
||||
context: context,
|
||||
firstDate: first,
|
||||
lastDate: first.add(const Duration(days: 30)),
|
||||
initialDate: _endDate ?? first,
|
||||
);
|
||||
if (date != null) setState(() => _endDate = date);
|
||||
}
|
||||
|
||||
String _fmt(DateTime d) =>
|
||||
'${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
|
||||
|
||||
Future<void> _generate() async {
|
||||
final start = _startDate;
|
||||
final end = _endDate;
|
||||
final location = _locationCtrl.text.trim();
|
||||
if (start == null) {
|
||||
Fluttertoast.showToast(msg: '请选择开始日期');
|
||||
return;
|
||||
}
|
||||
if (end == null) {
|
||||
Fluttertoast.showToast(msg: '请选择结束日期');
|
||||
return;
|
||||
}
|
||||
if (location.isEmpty) {
|
||||
Fluttertoast.showToast(msg: '请输入地点');
|
||||
return;
|
||||
}
|
||||
if (end.difference(start).inDays < 1) {
|
||||
Fluttertoast.showToast(msg: '日期范围至少 1 天');
|
||||
return;
|
||||
}
|
||||
setState(() => _submitting = true);
|
||||
final ok = await ref.read(generateProvider.notifier).generate(
|
||||
startDate: _fmt(start),
|
||||
endDate: _fmt(end),
|
||||
location: location,
|
||||
occasion: _occasion,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _submitting = false);
|
||||
if (ok) {
|
||||
Fluttertoast.showToast(msg: '生成完成');
|
||||
ref.read(generateProvider.notifier).reset();
|
||||
context.push('/plan-viewer');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final plans = ref.watch(outfitPlanProvider);
|
||||
final gen = ref.watch(generateProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('造型穿搭')),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () => ref.read(outfitPlanProvider.notifier).refresh(),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _submitting ? null : _pickStartDate,
|
||||
icon: const Icon(Icons.date_range_outlined),
|
||||
label: Text(_startDate == null
|
||||
? '开始日期'
|
||||
: _fmt(_startDate!)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _submitting ? null : _pickEndDate,
|
||||
icon: const Icon(Icons.date_range_outlined),
|
||||
label: Text(_endDate == null
|
||||
? '结束日期'
|
||||
: _fmt(_endDate!)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _locationCtrl,
|
||||
enabled: !_submitting,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '地点(如:上海·外滩)',
|
||||
hintText: '影响天气与穿搭建议',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('场景',
|
||||
style:
|
||||
TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
for (final o in _occasions)
|
||||
ChoiceChip(
|
||||
label: Text(o),
|
||||
selected: _occasion == o,
|
||||
onSelected: _submitting
|
||||
? null
|
||||
: (_) => setState(() => _occasion = o),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (gen.phase == GeneratePhase.running)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(gen.statusText,
|
||||
style: const TextStyle(fontSize: 13)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: _submitting ? null : _generate,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
child: const Text('生成穿搭方案'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('已有方案',
|
||||
style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
plans.when(
|
||||
loading: () => const Padding(
|
||||
padding: EdgeInsets.only(top: 48),
|
||||
child: LoadingView(text: '加载方案...')),
|
||||
error: (e, _) => Padding(
|
||||
padding: const EdgeInsets.only(top: 48),
|
||||
child: ErrorView(
|
||||
message: e.toString().replaceFirst('Exception: ', ''),
|
||||
onRetry: () => ref.read(outfitPlanProvider.notifier).refresh(),
|
||||
),
|
||||
),
|
||||
data: (list) {
|
||||
if (list.isEmpty) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.only(top: 48),
|
||||
child: EmptyView(
|
||||
message: '还没有穿搭方案,先设置日期与地点生成吧'),
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
for (final p in list) ...[
|
||||
_PlanCard(plan: p),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PlanCard extends ConsumerWidget {
|
||||
final OutfitPlanInfo plan;
|
||||
|
||||
const _PlanCard({required this.plan});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Card(
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () => context.push('/plan-viewer'),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(Icons.checkroom, color: scheme.primary),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(plan.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold, fontSize: 15)),
|
||||
),
|
||||
if (plan.isMain) ...[
|
||||
const SizedBox(width: 6),
|
||||
const Chip(
|
||||
label: Text('主方案'),
|
||||
labelStyle:
|
||||
TextStyle(color: Colors.white, fontSize: 11),
|
||||
backgroundColor: Colors.indigo,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${plan.dateRange} · ${plan.location} · 评分 ${plan.score}'
|
||||
'${plan.fromAi ? ' · AI 生成' : ' · 规则生成'}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style:
|
||||
const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right, color: Colors.grey),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/auth/auth_provider.dart';
|
||||
|
||||
class OutfitPlanInfo {
|
||||
final int id;
|
||||
final String title;
|
||||
final String source; // ai / rule
|
||||
final int score;
|
||||
final int mainFlag;
|
||||
final String dateRange;
|
||||
final String location;
|
||||
final int hairstyleId;
|
||||
final String hairColor;
|
||||
final String occasion;
|
||||
|
||||
const OutfitPlanInfo({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.source,
|
||||
required this.score,
|
||||
required this.mainFlag,
|
||||
required this.dateRange,
|
||||
required this.location,
|
||||
required this.hairstyleId,
|
||||
required this.hairColor,
|
||||
this.occasion = '',
|
||||
});
|
||||
|
||||
bool get isMain => mainFlag == 1;
|
||||
bool get fromAi => source == 'ai';
|
||||
}
|
||||
|
||||
class PlanItemInfo {
|
||||
final int id;
|
||||
final String slot; // 上衣/下装/鞋/配饰
|
||||
final String source; // wardrobe / new
|
||||
final int wardrobeItemId;
|
||||
final String name;
|
||||
final String desc;
|
||||
|
||||
const PlanItemInfo({
|
||||
required this.id,
|
||||
required this.slot,
|
||||
required this.source,
|
||||
required this.wardrobeItemId,
|
||||
required this.name,
|
||||
required this.desc,
|
||||
});
|
||||
|
||||
bool get fromWardrobe => source == 'wardrobe';
|
||||
}
|
||||
|
||||
class PlanEffectImageInfo {
|
||||
final int id;
|
||||
final String angle; // front / side / back
|
||||
final String url;
|
||||
final String status; // pending / rendering / done / failed
|
||||
|
||||
const PlanEffectImageInfo({
|
||||
required this.id,
|
||||
required this.angle,
|
||||
required this.url,
|
||||
required this.status,
|
||||
});
|
||||
|
||||
bool get done => status == 'done' && url.isNotEmpty;
|
||||
}
|
||||
|
||||
class PlanDetail {
|
||||
final OutfitPlanInfo plan;
|
||||
final List<PlanItemInfo> items;
|
||||
final List<PlanEffectImageInfo> images;
|
||||
final String hairstyleName;
|
||||
|
||||
const PlanDetail({
|
||||
required this.plan,
|
||||
required this.items,
|
||||
required this.images,
|
||||
required this.hairstyleName,
|
||||
});
|
||||
}
|
||||
|
||||
/// 方案列表
|
||||
class OutfitPlanNotifier extends AsyncNotifier<List<OutfitPlanInfo>> {
|
||||
@override
|
||||
Future<List<OutfitPlanInfo>> build() async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final data = await api.get<Map<String, dynamic>>('/outfit/plan/list');
|
||||
final list = data['list'] as List<dynamic>? ?? [];
|
||||
return list
|
||||
.map((e) => OutfitPlanInfo(
|
||||
id: (e['id'] as num).toInt(),
|
||||
title: e['title'] as String? ?? '',
|
||||
source: e['source'] as String? ?? 'rule',
|
||||
score: (e['score'] as num?)?.toInt() ?? 0,
|
||||
mainFlag: (e['main_flag'] as num?)?.toInt() ?? 0,
|
||||
dateRange: e['date_range'] as String? ?? '',
|
||||
location: e['location'] as String? ?? '',
|
||||
hairstyleId: (e['hairstyle_id'] as num?)?.toInt() ?? 0,
|
||||
hairColor: e['hair_color'] as String? ?? '',
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(build);
|
||||
}
|
||||
|
||||
Future<void> selectMain(int planId) async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
await api.post('/outfit/plan/select-main', {'plan_id': planId});
|
||||
await refresh();
|
||||
}
|
||||
|
||||
Future<void> review(int planId, String action) async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
await api.post('/outfit/plan/review', {'plan_id': planId, 'action': action});
|
||||
}
|
||||
}
|
||||
|
||||
final outfitPlanProvider =
|
||||
AsyncNotifierProvider<OutfitPlanNotifier, List<OutfitPlanInfo>>(
|
||||
OutfitPlanNotifier.new);
|
||||
|
||||
/// 生成任务状态(轮询后端任务状态机)
|
||||
enum GeneratePhase { idle, running, done, failed }
|
||||
|
||||
class GenerateState {
|
||||
final GeneratePhase phase;
|
||||
final String statusText;
|
||||
final String error;
|
||||
|
||||
const GenerateState({
|
||||
this.phase = GeneratePhase.idle,
|
||||
this.statusText = '',
|
||||
this.error = '',
|
||||
});
|
||||
}
|
||||
|
||||
class GenerateNotifier extends Notifier<GenerateState> {
|
||||
@override
|
||||
GenerateState build() => const GenerateState();
|
||||
|
||||
Future<bool> generate({
|
||||
required String startDate,
|
||||
required String endDate,
|
||||
required String location,
|
||||
required String occasion,
|
||||
}) async {
|
||||
state =
|
||||
const GenerateState(phase: GeneratePhase.running, statusText: '任务创建中...');
|
||||
final api = ref.read(apiClientProvider);
|
||||
final data = await api.post<Map<String, dynamic>>('/outfit/generate', {
|
||||
'start_date': startDate,
|
||||
'end_date': endDate,
|
||||
'location': location,
|
||||
'occasion': occasion,
|
||||
});
|
||||
final taskId = (data['task_id'] as num).toInt();
|
||||
|
||||
for (var i = 0; i < 90; i++) {
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
final st = await api.get<Map<String, dynamic>>('/outfit/task/status',
|
||||
query: {'task_id': taskId});
|
||||
final status = st['status'] as String? ?? '';
|
||||
final err = st['error'] as String? ?? '';
|
||||
state = GenerateState(
|
||||
phase: GeneratePhase.running, statusText: _statusText(status));
|
||||
if (status == 'done') {
|
||||
state = const GenerateState(phase: GeneratePhase.done, statusText: '完成');
|
||||
break;
|
||||
}
|
||||
if (status == 'failed') {
|
||||
state = GenerateState(
|
||||
phase: GeneratePhase.failed,
|
||||
error: err.isEmpty ? '生成失败,请稍后重试' : err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
await ref.read(outfitPlanProvider.notifier).refresh();
|
||||
return state.phase == GeneratePhase.done;
|
||||
}
|
||||
|
||||
String _statusText(String status) {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return '排队中...';
|
||||
case 'planning':
|
||||
return 'AI 规划穿搭中...';
|
||||
case 'scoring':
|
||||
return '方案评分中...';
|
||||
case 'rendering':
|
||||
return '生成效果图中...';
|
||||
case 'failed':
|
||||
return '生成失败';
|
||||
default:
|
||||
return '处理中...';
|
||||
}
|
||||
}
|
||||
|
||||
void reset() => state = const GenerateState();
|
||||
}
|
||||
|
||||
final generateProvider =
|
||||
NotifierProvider<GenerateNotifier, GenerateState>(GenerateNotifier.new);
|
||||
|
||||
/// 方案详情
|
||||
final planDetailProvider = FutureProvider.family<PlanDetail, int>((ref, planId) async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final data = await api.get<Map<String, dynamic>>('/outfit/plan/detail',
|
||||
query: {'plan_id': planId});
|
||||
final planData = data['plan'] as Map<String, dynamic>? ?? {};
|
||||
final items = (data['items'] as List<dynamic>? ?? [])
|
||||
.map((e) => PlanItemInfo(
|
||||
id: (e['id'] as num).toInt(),
|
||||
slot: e['slot'] as String? ?? '',
|
||||
source: e['source'] as String? ?? 'new',
|
||||
wardrobeItemId: (e['wardrobe_item_id'] as num?)?.toInt() ?? 0,
|
||||
name: e['name'] as String? ?? '',
|
||||
desc: e['desc'] as String? ?? '',
|
||||
))
|
||||
.toList();
|
||||
final images = (data['images'] as List<dynamic>? ?? [])
|
||||
.map((e) => PlanEffectImageInfo(
|
||||
id: (e['id'] as num).toInt(),
|
||||
angle: e['angle'] as String? ?? '',
|
||||
url: e['url'] as String? ?? '',
|
||||
status: e['status'] as String? ?? 'pending',
|
||||
))
|
||||
.toList();
|
||||
final hairstyle = data['hairstyle'] as Map<String, dynamic>?;
|
||||
return PlanDetail(
|
||||
plan: OutfitPlanInfo(
|
||||
id: (planData['id'] as num).toInt(),
|
||||
title: planData['title'] as String? ?? '',
|
||||
source: planData['source'] as String? ?? 'rule',
|
||||
score: (planData['score'] as num?)?.toInt() ?? 0,
|
||||
mainFlag: (planData['main_flag'] as num?)?.toInt() ?? 0,
|
||||
dateRange: planData['date_range'] as String? ?? '',
|
||||
location: planData['location'] as String? ?? '',
|
||||
hairstyleId: (planData['hairstyle_id'] as num?)?.toInt() ?? 0,
|
||||
hairColor: planData['hair_color'] as String? ?? '',
|
||||
occasion: planData['occasion'] as String? ?? '',
|
||||
),
|
||||
items: items,
|
||||
images: images,
|
||||
hairstyleName: hairstyle?['name'] as String? ?? '',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/ads/ads_service.dart';
|
||||
import '../../core/config/app_config.dart';
|
||||
import '../../shared/widgets/empty_view.dart';
|
||||
import '../../shared/widgets/loading_view.dart';
|
||||
import 'outfit_provider.dart';
|
||||
|
||||
const _angleLabels = {
|
||||
'front': '正面',
|
||||
'side': '侧面',
|
||||
'back': '背面',
|
||||
};
|
||||
|
||||
/// 效果图查看:3 视角(正面/侧面/背面)切换
|
||||
class PlanEffectPage extends ConsumerStatefulWidget {
|
||||
final List<PlanEffectImageInfo> images;
|
||||
|
||||
const PlanEffectPage({super.key, required this.images});
|
||||
|
||||
@override
|
||||
ConsumerState<PlanEffectPage> createState() => _PlanEffectPageState();
|
||||
}
|
||||
|
||||
class _PlanEffectPageState extends ConsumerState<PlanEffectPage> {
|
||||
String? _selected; // 当前角度,null = 展示全部
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final done = widget.images.where((i) => i.done).toList();
|
||||
final pending = widget.images.where((i) => !i.done).toList();
|
||||
final angles = widget.images.map((i) => i.angle).toSet();
|
||||
final ads = ref.watch(adsServiceProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('效果图')),
|
||||
body: Column(
|
||||
children: [
|
||||
if (angles.length > 1)
|
||||
SizedBox(
|
||||
height: 48,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ChoiceChip(
|
||||
label: const Text('全部'),
|
||||
selected: _selected == null,
|
||||
onSelected: (_) => setState(() => _selected = null),
|
||||
),
|
||||
),
|
||||
for (final a in angles)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ChoiceChip(
|
||||
label: Text(_angleLabels[a] ?? a),
|
||||
selected: _selected == a,
|
||||
onSelected: (_) => setState(() => _selected = a),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: done.isEmpty
|
||||
? (pending.isEmpty
|
||||
? const EmptyView(
|
||||
message: '暂无效果图,选定主方案后自动生成(每日限 3 次)')
|
||||
: const LoadingView(text: '效果图生成中,请稍后刷新...'))
|
||||
: GridView.builder(
|
||||
padding: const EdgeInsets.all(12),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
childAspectRatio: 0.8,
|
||||
),
|
||||
itemCount: _selected == null
|
||||
? done.length
|
||||
: done.where((i) => i.angle == _selected).length,
|
||||
itemBuilder: (ctx, idx) {
|
||||
final shown = _selected == null
|
||||
? done
|
||||
: done.where((i) => i.angle == _selected).toList();
|
||||
final img = shown[idx];
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Image.network(
|
||||
AppConfig.resolveUrl(img.url),
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => const Icon(
|
||||
Icons.broken_image_outlined,
|
||||
size: 48,
|
||||
color: Colors.grey),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Text(_angleLabels[img.angle] ?? img.angle,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
// 信息流广告位:广告未开通(pangleAppId 为空)不渲染
|
||||
if (ads.enabled) ads.showBanner(context),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../features/cps/cps_provider.dart';
|
||||
import '../../shared/widgets/avatar_viewer.dart';
|
||||
import '../../shared/widgets/loading_view.dart';
|
||||
import '../profile/avatar_provider.dart';
|
||||
import 'outfit_provider.dart';
|
||||
|
||||
/// 方案流:上部 3D 化身展示 + 下部方案左右滑动切换,清单含商业化入口(到店试穿/买同款)
|
||||
class PlanViewerPage extends ConsumerWidget {
|
||||
const PlanViewerPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final plans = ref.watch(outfitPlanProvider);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('穿搭方案')),
|
||||
body: Column(
|
||||
children: [
|
||||
_buildAvatarBar(context, ref),
|
||||
Expanded(
|
||||
child: plans.when(
|
||||
loading: () => const LoadingView(text: '加载方案...'),
|
||||
error: (e, _) => Center(child: Text('加载失败:$e')),
|
||||
data: (list) {
|
||||
if (list.isEmpty) {
|
||||
return const Center(child: Text('暂无方案'));
|
||||
}
|
||||
return PageView.builder(
|
||||
itemCount: list.length,
|
||||
itemBuilder: (ctx, i) => _PlanDetailView(
|
||||
planId: list[i].id, key: ValueKey(list[i].id)),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 上部:当前用户的 3D 化身展示(帧轮播,未构建时占位)
|
||||
Widget _buildAvatarBar(BuildContext context, WidgetRef ref) {
|
||||
final avatar = ref.watch(avatarProvider);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: avatar.when(
|
||||
loading: () => const SizedBox(
|
||||
height: 200,
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
error: (e, _) => const SizedBox.shrink(),
|
||||
data: (a) => AvatarViewer(
|
||||
framesUrl: a.framesUrl,
|
||||
title: '我的 3D 化身',
|
||||
subtitle: a.built ? '' : '尚未构建,可在「我的」页完成照片与身形后生成',
|
||||
height: 200,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PlanDetailView extends ConsumerWidget {
|
||||
final int planId;
|
||||
|
||||
const _PlanDetailView({required this.planId, super.key});
|
||||
|
||||
/// 商业化入口:推荐为空时也跳转(source/品类为空 → 列表页展示空态)
|
||||
void _openCpsList(
|
||||
BuildContext context, String title, String scene, List<CpsProductInfo> rec) {
|
||||
final first = rec.isEmpty ? null : rec.first;
|
||||
context.push('/cps-product-list', extra: CpsListArgs(
|
||||
title: title,
|
||||
source: first?.source ?? '',
|
||||
categoryCode: first?.categoryCode ?? '',
|
||||
city: first?.city ?? '',
|
||||
scene: scene,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final detail = ref.watch(planDetailProvider(planId));
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: detail.when(
|
||||
loading: () => const LoadingView(text: '加载方案详情...'),
|
||||
error: (e, _) => Center(child: Text('加载失败:$e')),
|
||||
data: (d) {
|
||||
final plan = d.plan;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(plan.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold)),
|
||||
),
|
||||
if (plan.isMain)
|
||||
const Chip(
|
||||
label: Text('主方案'),
|
||||
labelStyle:
|
||||
TextStyle(color: Colors.white, fontSize: 11),
|
||||
backgroundColor: Colors.indigo,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
if (!plan.isMain)
|
||||
Chip(
|
||||
label: Text(plan.fromAi ? 'AI 生成' : '规则生成'),
|
||||
labelStyle: TextStyle(
|
||||
color: scheme.primary, fontSize: 11),
|
||||
backgroundColor: scheme.primaryContainer,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text('评分 ${plan.score}',
|
||||
style: const TextStyle(color: Colors.grey)),
|
||||
const SizedBox(height: 12),
|
||||
// 穿搭清单:标题行 + 场景延伸优惠入口
|
||||
Row(
|
||||
children: [
|
||||
Text('穿搭清单',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: scheme.primary)),
|
||||
const Spacer(),
|
||||
if (plan.occasion.isNotEmpty)
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _openCpsList(context,
|
||||
'${plan.occasion}延伸优惠', 'occasion', const []),
|
||||
icon: const Icon(Icons.local_offer_outlined,
|
||||
size: 16),
|
||||
label: const Text('延伸优惠'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// 发型作为清单首项(含"做同款发型"入口)
|
||||
if (d.hairstyleName.isNotEmpty || plan.hairColor.isNotEmpty)
|
||||
_HairstyleEntryCard(
|
||||
planId: plan.id,
|
||||
hairstyleName: d.hairstyleName.isNotEmpty
|
||||
? d.hairstyleName
|
||||
: '默认',
|
||||
hairColor: plan.hairColor,
|
||||
onOpen: (title, scene, rec) =>
|
||||
_openCpsList(context, title, scene, rec),
|
||||
),
|
||||
if (d.items.isEmpty)
|
||||
const Text('暂无穿搭单品',
|
||||
style: TextStyle(color: Colors.grey)),
|
||||
for (final item in d.items)
|
||||
_ItemEntryCard(
|
||||
planId: plan.id,
|
||||
item: item,
|
||||
onOpen: (title, scene, rec) =>
|
||||
_openCpsList(context, title, scene, rec),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 穿搭清单项:发型(含"做同款发型"入口,推荐为空也跳转)
|
||||
class _HairstyleEntryCard extends ConsumerWidget {
|
||||
final int planId;
|
||||
final String hairstyleName;
|
||||
final String hairColor;
|
||||
final void Function(String title, String scene, List<CpsProductInfo> rec)
|
||||
onOpen;
|
||||
|
||||
const _HairstyleEntryCard({
|
||||
required this.planId,
|
||||
required this.hairstyleName,
|
||||
required this.hairColor,
|
||||
required this.onOpen,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final rec = ref
|
||||
.watch(cpsRecommendProvider((planId: planId, scene: 'haircut')))
|
||||
.value;
|
||||
return Card(
|
||||
color: scheme.primaryContainer.withValues(alpha: 0.4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ListTile(
|
||||
dense: true,
|
||||
leading: const Icon(Icons.content_cut, size: 20),
|
||||
title: Text(
|
||||
'发型:$hairstyleName${hairColor.isNotEmpty ? ' · 发色:$hairColor' : ''}',
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton.icon(
|
||||
onPressed: () => onOpen('做同款发型', 'haircut', rec ?? []),
|
||||
icon: const Icon(Icons.storefront_outlined, size: 16),
|
||||
label: const Text('做同款发型'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 穿搭清单项:衣橱已有单品无入口;AI 推荐新品 → 去购买(推荐为空也跳转)
|
||||
class _ItemEntryCard extends ConsumerWidget {
|
||||
final int planId;
|
||||
final PlanItemInfo item;
|
||||
final void Function(String title, String scene, List<CpsProductInfo> rec)
|
||||
onOpen;
|
||||
|
||||
const _ItemEntryCard({
|
||||
required this.planId,
|
||||
required this.item,
|
||||
required this.onOpen,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final scene = item.fromWardrobe ? 'item_upgrade' : 'item_buy';
|
||||
final rec = ref
|
||||
.watch(cpsRecommendProvider((planId: planId, scene: scene)))
|
||||
.value;
|
||||
return Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ListTile(
|
||||
dense: true,
|
||||
leading: Icon(_slotIcon(item.slot)),
|
||||
title: Text(item.name),
|
||||
subtitle: Text(item.desc),
|
||||
trailing: item.fromWardrobe
|
||||
? const Chip(
|
||||
label: Text('衣橱'),
|
||||
labelStyle: TextStyle(
|
||||
color: Colors.white, fontSize: 11),
|
||||
backgroundColor: Colors.teal,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
)
|
||||
: const Chip(
|
||||
label: Text('新品'),
|
||||
labelStyle: TextStyle(
|
||||
color: Colors.white, fontSize: 11),
|
||||
backgroundColor: Colors.orange,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
// 衣橱已有单品无需入口;仅 AI 推荐新品展示购买入口
|
||||
if (!item.fromWardrobe)
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton.icon(
|
||||
onPressed: () => onOpen('去购买', scene, rec ?? []),
|
||||
icon: const Icon(Icons.shopping_cart_outlined, size: 16),
|
||||
label: const Text('去购买'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
IconData _slotIcon(String slot) {
|
||||
switch (slot) {
|
||||
case '上衣':
|
||||
return Icons.checkroom;
|
||||
case '下装':
|
||||
return Icons.airline_seat_legroom_normal;
|
||||
case '鞋':
|
||||
return Icons.directions_walk;
|
||||
case '配饰':
|
||||
return Icons.watch_outlined;
|
||||
default:
|
||||
return Icons.style_outlined;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import '../../shared/widgets/loading_view.dart';
|
||||
import 'avatar_provider.dart';
|
||||
import 'body_provider.dart';
|
||||
import 'photo_upload_provider.dart';
|
||||
|
||||
/// 肤色选项(1-5,与后端 SkinTone 对应)
|
||||
const skinToneColors = <int, Color>{
|
||||
1: Color(0xFFF6E3D4),
|
||||
2: Color(0xFFEAC9A8),
|
||||
3: Color(0xFFD9A87C),
|
||||
4: Color(0xFFB97E55),
|
||||
5: Color(0xFF8D5B38),
|
||||
};
|
||||
|
||||
/// 我的形象三步流程:① 拍摄三视角全身照 → ② 填写身形参数 → ③ 生成 3D 化身
|
||||
class AvatarBuildFlowPage extends ConsumerStatefulWidget {
|
||||
const AvatarBuildFlowPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<AvatarBuildFlowPage> createState() =>
|
||||
_AvatarBuildFlowPageState();
|
||||
}
|
||||
|
||||
class _AvatarBuildFlowPageState extends ConsumerState<AvatarBuildFlowPage> {
|
||||
final _picker = ImagePicker();
|
||||
int _step = 1;
|
||||
int? _uploadingType;
|
||||
bool _busy = false;
|
||||
String? _buildError;
|
||||
|
||||
int _height = 170;
|
||||
int _weight = 60;
|
||||
int _skinTone = 3;
|
||||
int _bust = 88;
|
||||
int _waist = 70;
|
||||
int _hip = 92;
|
||||
int _shoulder = 42;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final cur = ref.read(bodyProvider).value;
|
||||
if (cur != null) {
|
||||
_height = cur.height;
|
||||
_weight = cur.weight;
|
||||
_skinTone = cur.skinTone;
|
||||
_bust = cur.bust;
|
||||
_waist = cur.waist;
|
||||
_hip = cur.hip;
|
||||
_shoulder = cur.shoulder;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickAndUpload(int type) async {
|
||||
final source = await showModalBottomSheet<ImageSource>(
|
||||
context: context,
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_camera_outlined),
|
||||
title: const Text('拍照'),
|
||||
onTap: () => Navigator.pop(ctx, ImageSource.camera),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_library_outlined),
|
||||
title: const Text('从相册选择'),
|
||||
onTap: () => Navigator.pop(ctx, ImageSource.gallery),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (source == null || !mounted) return;
|
||||
|
||||
final file = await _picker.pickImage(
|
||||
source: source, maxWidth: 2048, imageQuality: 85);
|
||||
if (file == null) return;
|
||||
|
||||
setState(() => _uploadingType = type);
|
||||
try {
|
||||
await ref.read(photoProvider.notifier).upload(type, file.path);
|
||||
if (!mounted) return;
|
||||
Fluttertoast.showToast(msg: '${PhotoType.labels[type]}上传成功');
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
Fluttertoast.showToast(
|
||||
msg: '上传失败:${e.toString().replaceFirst('Exception: ', '')}');
|
||||
} finally {
|
||||
if (mounted) setState(() => _uploadingType = null);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveAndBuild() async {
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_buildError = null;
|
||||
});
|
||||
try {
|
||||
await ref
|
||||
.read(bodyProvider.notifier)
|
||||
.save(_height, _weight, _skinTone, _bust, _waist, _hip, _shoulder);
|
||||
await ref.read(avatarProvider.notifier).buildAvatar();
|
||||
if (!mounted) return;
|
||||
final a = ref.read(avatarProvider).value;
|
||||
if (a?.built == true) {
|
||||
Fluttertoast.showToast(msg: '3D 化身已生成');
|
||||
context.go('/avatar-viewer');
|
||||
} else if (a?.buildStatus == 'failed') {
|
||||
setState(() => _buildError = a!.error);
|
||||
} else {
|
||||
Fluttertoast.showToast(msg: '构建超时,请稍后重试');
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(
|
||||
() => _buildError = e.toString().replaceFirst('Exception: ', ''));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final built = ref.watch(avatarProvider).value?.built ?? false;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('我的形象'),
|
||||
actions: [
|
||||
if (built)
|
||||
IconButton(
|
||||
tooltip: '查看已生成的 3D 化身',
|
||||
icon: const Icon(Icons.view_in_ar),
|
||||
onPressed: () => context.go('/avatar-viewer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
child: _StepIndicator(
|
||||
current: _step,
|
||||
onTap: (i) => setState(() => _step = i)),
|
||||
),
|
||||
Expanded(
|
||||
child: switch (_step) {
|
||||
1 => _buildStep1(),
|
||||
2 => _buildStep2(),
|
||||
_ => _buildStep3(),
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 步 1:拍摄三视角全身照 ----
|
||||
Widget _buildStep1() {
|
||||
final photos = ref.watch(photoProvider);
|
||||
return photos.when(
|
||||
loading: () => const LoadingView(text: '加载照片状态...'),
|
||||
error: (e, _) => Center(child: Text('加载失败:$e')),
|
||||
data: (list) {
|
||||
final allDone =
|
||||
PhotoType.all.every((t) => list.any((p) => p.type == t));
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
const Text('拍摄 3 张全身照(正面/侧面/背面),用于构建你的 3D 形象',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 13)),
|
||||
const SizedBox(height: 12),
|
||||
for (final type in PhotoType.all) ...[
|
||||
_PhotoCard(
|
||||
type: type,
|
||||
uploaded: list.any((p) => p.type == type),
|
||||
uploading: _uploadingType == type,
|
||||
onTap:
|
||||
_uploadingType == null ? () => _pickAndUpload(type) : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.icon(
|
||||
onPressed: allDone && _uploadingType == null
|
||||
? () => setState(() => _step = 2)
|
||||
: null,
|
||||
icon: const Icon(Icons.arrow_forward),
|
||||
label: Text(allDone ? '照片已完成,下一步' : '还需拍摄剩余照片'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 步 2:身形参数 ----
|
||||
Widget _buildStep2() {
|
||||
final body = ref.watch(bodyProvider);
|
||||
return body.when(
|
||||
loading: () => const LoadingView(text: '加载身形参数...'),
|
||||
error: (e, _) => Center(child: Text('加载失败:$e')),
|
||||
data: (_) => ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_SliderRow(
|
||||
label: '身高',
|
||||
value: '$_height cm',
|
||||
min: 145,
|
||||
max: 200,
|
||||
current: _height.toDouble(),
|
||||
onChanged: (v) => setState(() => _height = v.round()),
|
||||
),
|
||||
_SliderRow(
|
||||
label: '体重',
|
||||
value: '$_weight kg',
|
||||
min: 40,
|
||||
max: 120,
|
||||
current: _weight.toDouble(),
|
||||
onChanged: (v) => setState(() => _weight = v.round()),
|
||||
),
|
||||
_SliderRow(
|
||||
label: '胸围',
|
||||
value: '$_bust cm',
|
||||
min: 60,
|
||||
max: 130,
|
||||
current: _bust.toDouble(),
|
||||
onChanged: (v) => setState(() => _bust = v.round()),
|
||||
),
|
||||
_SliderRow(
|
||||
label: '腰围',
|
||||
value: '$_waist cm',
|
||||
min: 50,
|
||||
max: 110,
|
||||
current: _waist.toDouble(),
|
||||
onChanged: (v) => setState(() => _waist = v.round()),
|
||||
),
|
||||
_SliderRow(
|
||||
label: '臀围',
|
||||
value: '$_hip cm',
|
||||
min: 60,
|
||||
max: 130,
|
||||
current: _hip.toDouble(),
|
||||
onChanged: (v) => setState(() => _hip = v.round()),
|
||||
),
|
||||
_SliderRow(
|
||||
label: '肩宽',
|
||||
value: '$_shoulder cm',
|
||||
min: 30,
|
||||
max: 60,
|
||||
current: _shoulder.toDouble(),
|
||||
onChanged: (v) => setState(() => _shoulder = v.round()),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('肤色', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
for (var i = 1; i <= 5; i++)
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => setState(() => _skinTone = i),
|
||||
child: Container(
|
||||
height: 48,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: skinToneColors[i],
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
width: _skinTone == i ? 3 : 1,
|
||||
color: _skinTone == i
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.grey.shade300,
|
||||
),
|
||||
),
|
||||
child: _skinTone == i
|
||||
? const Icon(Icons.check, color: Colors.white)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
FilledButton.icon(
|
||||
onPressed: () => setState(() => _step = 3),
|
||||
icon: const Icon(Icons.arrow_forward),
|
||||
label: const Text('下一步'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 步 3:确认并生成 ----
|
||||
Widget _buildStep3() {
|
||||
final photos = ref.watch(photoProvider);
|
||||
final photoDone = photos.value?.isNotEmpty ?? false;
|
||||
final doneCount = photoDone
|
||||
? PhotoType.all
|
||||
.where((t) =>
|
||||
photos.value!.any((p) => p.type == t))
|
||||
.length
|
||||
: 0;
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('形象资料',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 15,
|
||||
color: Theme.of(context).colorScheme.primary)),
|
||||
const SizedBox(height: 8),
|
||||
_InfoLine(
|
||||
label: '照片',
|
||||
value: '$doneCount/${PhotoType.all.length} 已上传'),
|
||||
_InfoLine(
|
||||
label: '身高',
|
||||
value: '$_height cm'),
|
||||
_InfoLine(
|
||||
label: '体重',
|
||||
value: '$_weight kg'),
|
||||
_InfoLine(
|
||||
label: '胸围/腰围/臀围',
|
||||
value: '$_bust / $_waist / $_hip cm'),
|
||||
_InfoLine(label: '肩宽', value: '$_shoulder cm'),
|
||||
_InfoLine(label: '肤色', value: '$_skinTone 档'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_buildError != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Card(
|
||||
color: Theme.of(context).colorScheme.errorContainer,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Text('生成失败:$_buildError',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Theme.of(context).colorScheme.onErrorContainer)),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.icon(
|
||||
onPressed: _busy ? null : _saveAndBuild,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14)),
|
||||
icon: _busy
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.auto_awesome),
|
||||
label: Text(_busy ? '正在生成 3D 化身...' : '生成我的 3D 化身'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text('生成约需 1-3 分钟,请勿关闭页面;完成后可在查看页观看 3D 形象',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 顶部步骤指示器:① 拍摄照片 ② 身形参数 ③ 生成
|
||||
class _StepIndicator extends StatelessWidget {
|
||||
final int current;
|
||||
final ValueChanged<int> onTap;
|
||||
|
||||
const _StepIndicator({required this.current, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const titles = ['拍摄照片', '身形参数', '生成'];
|
||||
return Row(
|
||||
children: [
|
||||
for (var i = 0; i < 3; i++) ...[
|
||||
if (i > 0)
|
||||
const Expanded(
|
||||
child: Divider(indent: 8, endIndent: 8),
|
||||
),
|
||||
InkWell(
|
||||
onTap: () => onTap(i + 1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Column(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 14,
|
||||
backgroundColor: i + 1 <= current
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.grey.shade300,
|
||||
child: Text('${i + 1}',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: i + 1 <= current
|
||||
? Colors.white
|
||||
: Colors.grey.shade600)),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(titles[i],
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight:
|
||||
i + 1 == current ? FontWeight.bold : null,
|
||||
color: i + 1 == current
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.grey)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PhotoCard extends StatelessWidget {
|
||||
final int type;
|
||||
final bool uploaded;
|
||||
final bool uploading;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _PhotoCard({
|
||||
required this.type,
|
||||
required this.uploaded,
|
||||
required this.uploading,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: ListTile(
|
||||
onTap: onTap,
|
||||
leading: CircleAvatar(
|
||||
backgroundColor:
|
||||
uploaded ? Colors.green.shade100 : scheme.primaryContainer,
|
||||
child: uploading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: Icon(uploaded ? Icons.check : Icons.add_a_photo_outlined),
|
||||
),
|
||||
title: Text(PhotoType.labels[type]!),
|
||||
subtitle: Text(PhotoType.descs[type]!),
|
||||
trailing: uploaded
|
||||
? const Chip(
|
||||
label: Text('已上传'),
|
||||
backgroundColor: Colors.green,
|
||||
labelStyle: TextStyle(color: Colors.white, fontSize: 12),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SliderRow extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
final double min;
|
||||
final double max;
|
||||
final double current;
|
||||
final ValueChanged<double> onChanged;
|
||||
|
||||
const _SliderRow({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.min,
|
||||
required this.max,
|
||||
required this.current,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
const Spacer(),
|
||||
Text(value,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
Slider(
|
||||
value: current.clamp(min, max),
|
||||
min: min,
|
||||
max: max,
|
||||
divisions: (max - min).round(),
|
||||
label: value,
|
||||
onChanged: onChanged,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoLine extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
const _InfoLine({required this.label, required this.value});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(label, style: const TextStyle(fontSize: 13)),
|
||||
const Spacer(),
|
||||
Text(value,
|
||||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/auth/auth_provider.dart';
|
||||
|
||||
class AvatarState {
|
||||
final int faceTemplateId;
|
||||
final int bodyTemplateId;
|
||||
final int skinToneIndex;
|
||||
final String glbUrl;
|
||||
final String framesUrl;
|
||||
final String buildStatus; // pending / processing / done / failed
|
||||
final String error;
|
||||
|
||||
const AvatarState({
|
||||
this.faceTemplateId = 0,
|
||||
this.bodyTemplateId = 0,
|
||||
this.skinToneIndex = 0,
|
||||
this.glbUrl = '',
|
||||
this.framesUrl = '',
|
||||
this.buildStatus = '',
|
||||
this.error = '',
|
||||
});
|
||||
|
||||
bool get built => buildStatus == 'done' && glbUrl.isNotEmpty;
|
||||
}
|
||||
|
||||
class AvatarNotifier extends AsyncNotifier<AvatarState> {
|
||||
@override
|
||||
Future<AvatarState> build() async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final data = await api.get<Map<String, dynamic>>('/avatar/get');
|
||||
return _fromData(data);
|
||||
}
|
||||
|
||||
AvatarState _fromData(Map<String, dynamic> data) => AvatarState(
|
||||
faceTemplateId: (data['face_template_id'] as num?)?.toInt() ?? 0,
|
||||
bodyTemplateId: (data['body_template_id'] as num?)?.toInt() ?? 0,
|
||||
skinToneIndex: (data['skin_tone_index'] as num?)?.toInt() ?? 0,
|
||||
glbUrl: data['glb_url'] as String? ?? '',
|
||||
framesUrl: data['frames_url'] as String? ?? '',
|
||||
buildStatus: data['build_status'] as String? ?? '',
|
||||
error: data['error'] as String? ?? '',
|
||||
);
|
||||
|
||||
Future<void> refresh() async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(build);
|
||||
}
|
||||
|
||||
/// 触发构建;若服务端异步则轮询直到 done/failed
|
||||
Future<void> buildAvatar() async {
|
||||
state = const AsyncLoading();
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final data =
|
||||
await api.post<Map<String, dynamic>>('/avatar/build', {});
|
||||
final status = data['status'] as String? ?? '';
|
||||
if (status == 'pending' || status == 'processing') {
|
||||
await _poll();
|
||||
} else {
|
||||
await refresh();
|
||||
}
|
||||
} catch (e) {
|
||||
state = AsyncError(e, StackTrace.current);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _poll() async {
|
||||
for (var i = 0; i < 30; i++) {
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
final api = ref.read(apiClientProvider);
|
||||
final data = await api.get<Map<String, dynamic>>('/avatar/get');
|
||||
final status = data['build_status'] as String? ?? '';
|
||||
if (status == 'done' || status == 'failed') {
|
||||
state = AsyncData(_fromData(data));
|
||||
return;
|
||||
}
|
||||
}
|
||||
state = AsyncError(StateError('化身构建超时,请稍后重试'), StackTrace.empty);
|
||||
}
|
||||
}
|
||||
|
||||
final avatarProvider =
|
||||
AsyncNotifierProvider<AvatarNotifier, AvatarState>(AvatarNotifier.new);
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
|
||||
import '../../shared/widgets/avatar_viewer.dart';
|
||||
import '../../shared/widgets/loading_view.dart';
|
||||
import 'avatar_provider.dart';
|
||||
|
||||
/// 3D 化身查看页
|
||||
class AvatarViewerPage extends ConsumerWidget {
|
||||
const AvatarViewerPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final avatar = ref.watch(avatarProvider);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('我的 3D 化身')),
|
||||
body: avatar.when(
|
||||
loading: () => const LoadingView(text: '加载化身...'),
|
||||
error: (e, _) => Center(child: Text('加载失败:$e')),
|
||||
data: (a) => ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
AvatarViewer(
|
||||
glbUrl: a.glbUrl,
|
||||
framesUrl: a.framesUrl,
|
||||
title: '我的 3D 化身',
|
||||
subtitle: a.built
|
||||
? '脸型模板 ${a.faceTemplateId} · 体型模板 ${a.bodyTemplateId} · 肤色 ${a.skinToneIndex}'
|
||||
: '尚未构建化身',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('构建说明',
|
||||
style: TextStyle(
|
||||
fontSize: 15, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'化身基于你的三视角全身照(正面/侧面/背面)由 AI 生成 3D 形象,'
|
||||
'构建过程通常需要 1-3 分钟。',
|
||||
style: TextStyle(fontSize: 13, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () async {
|
||||
await ref
|
||||
.read(avatarProvider.notifier)
|
||||
.buildAvatar();
|
||||
if (context.mounted) {
|
||||
Fluttertoast.showToast(msg: '化身构建完成');
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('重新构建化身'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/auth/auth_provider.dart';
|
||||
|
||||
class BodyState {
|
||||
final int height;
|
||||
final int weight;
|
||||
final int skinTone;
|
||||
final int bust;
|
||||
final int waist;
|
||||
final int hip;
|
||||
final int shoulder;
|
||||
|
||||
const BodyState({
|
||||
this.height = 170,
|
||||
this.weight = 60,
|
||||
this.skinTone = 3,
|
||||
this.bust = 88,
|
||||
this.waist = 70,
|
||||
this.hip = 92,
|
||||
this.shoulder = 42,
|
||||
});
|
||||
}
|
||||
|
||||
class BodyNotifier extends AsyncNotifier<BodyState> {
|
||||
@override
|
||||
Future<BodyState> build() async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final data = await api.get<Map<String, dynamic>>('/body-measurement/get');
|
||||
return BodyState(
|
||||
height: (data['height'] as num?)?.toInt() ?? 170,
|
||||
weight: (data['weight'] as num?)?.toInt() ?? 60,
|
||||
skinTone: (data['skin_tone'] as num?)?.toInt() ?? 3,
|
||||
bust: (data['bust'] as num?)?.toInt() ?? 88,
|
||||
waist: (data['waist'] as num?)?.toInt() ?? 70,
|
||||
hip: (data['hip'] as num?)?.toInt() ?? 92,
|
||||
shoulder: (data['shoulder'] as num?)?.toInt() ?? 42,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> save(int height, int weight, int skinTone, int bust,
|
||||
int waist, int hip, int shoulder) async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
await api.post('/body-measurement/save', {
|
||||
'height': height,
|
||||
'weight': weight,
|
||||
'skin_tone': skinTone,
|
||||
'bust': bust,
|
||||
'waist': waist,
|
||||
'hip': hip,
|
||||
'shoulder': shoulder,
|
||||
});
|
||||
state = AsyncData(BodyState(
|
||||
height: height,
|
||||
weight: weight,
|
||||
skinTone: skinTone,
|
||||
bust: bust,
|
||||
waist: waist,
|
||||
hip: hip,
|
||||
shoulder: shoulder));
|
||||
}
|
||||
}
|
||||
|
||||
final bodyProvider =
|
||||
AsyncNotifierProvider<BodyNotifier, BodyState>(BodyNotifier.new);
|
||||
@@ -0,0 +1,79 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/auth/auth_provider.dart';
|
||||
|
||||
/// 照片类型(与后端 consts.PhotoType 对应)
|
||||
class PhotoType {
|
||||
static const int headshot = 1;
|
||||
static const int fullFront = 2;
|
||||
static const int fullSide = 3;
|
||||
static const int fullBack = 4;
|
||||
|
||||
static const Map<int, String> labels = {
|
||||
headshot: '大头照',
|
||||
fullFront: '全身正面',
|
||||
fullSide: '全身侧面',
|
||||
fullBack: '全身背面',
|
||||
};
|
||||
|
||||
static const Map<int, String> descs = {
|
||||
headshot: '清晰正脸、光线充足',
|
||||
fullFront: '站立正面全身,拍全脚底',
|
||||
fullSide: '站立侧面全身,自然放松',
|
||||
fullBack: '站立背面全身,露出轮廓',
|
||||
};
|
||||
|
||||
/// 构建 3D 化身所需视角(Tripo 多视角转 3D)
|
||||
static const List<int> all = [fullFront, fullSide, fullBack];
|
||||
}
|
||||
|
||||
class UserPhotoInfo {
|
||||
final int id;
|
||||
final int type;
|
||||
final String url;
|
||||
|
||||
const UserPhotoInfo({
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.url,
|
||||
});
|
||||
}
|
||||
|
||||
class PhotoNotifier extends AsyncNotifier<List<UserPhotoInfo>> {
|
||||
@override
|
||||
Future<List<UserPhotoInfo>> build() async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final data = await api.get<Map<String, dynamic>>('/user-photo/list');
|
||||
final list = data['list'] as List<dynamic>? ?? [];
|
||||
return list
|
||||
.map((e) => UserPhotoInfo(
|
||||
id: (e['id'] as num).toInt(),
|
||||
type: (e['type'] as num).toInt(),
|
||||
url: e['url'] as String? ?? '',
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> upload(int type, String filePath) async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
await api.upload('/user-photo/upload', {'type': type}, 'file', filePath);
|
||||
await refresh();
|
||||
}
|
||||
|
||||
Future<void> delete(int id) async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
await api.post('/user-photo/delete', {'id': id});
|
||||
await refresh();
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(build);
|
||||
}
|
||||
|
||||
/// 某类型是否已上传
|
||||
bool hasType(int type) => state.value?.any((p) => p.type == type) ?? false;
|
||||
}
|
||||
|
||||
final photoProvider =
|
||||
AsyncNotifierProvider<PhotoNotifier, List<UserPhotoInfo>>(PhotoNotifier.new);
|
||||
@@ -0,0 +1,194 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/config/app_config.dart';
|
||||
import '../../features/cps/cps_provider.dart';
|
||||
import '../../shared/widgets/empty_view.dart';
|
||||
import '../../shared/widgets/error_view.dart';
|
||||
import '../../shared/widgets/loading_view.dart';
|
||||
import 'wardrobe_provider.dart';
|
||||
|
||||
/// 衣橱:服装网格 + 长按删除 + 上传入口
|
||||
class WardrobePage extends ConsumerStatefulWidget {
|
||||
const WardrobePage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<WardrobePage> createState() => _WardrobePageState();
|
||||
}
|
||||
|
||||
class _WardrobePageState extends ConsumerState<WardrobePage> {
|
||||
String? _filter; // 当前分类筛选,null = 全部
|
||||
|
||||
/// 长按菜单:找升级款 / 删除
|
||||
Future<void> _showItemMenu(WardrobeItemInfo item) async {
|
||||
final action = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.upgrade_outlined),
|
||||
title: const Text('找升级款'),
|
||||
onTap: () => Navigator.pop(ctx, 'upgrade'),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete_outline),
|
||||
title: const Text('删除'),
|
||||
onTap: () => Navigator.pop(ctx, 'delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (action == 'upgrade') {
|
||||
await _openUpgrade(item);
|
||||
} else if (action == 'delete') {
|
||||
await _confirmDelete(item);
|
||||
}
|
||||
}
|
||||
|
||||
/// 升级款推荐:空结果提示后不跳转
|
||||
Future<void> _openUpgrade(WardrobeItemInfo item) async {
|
||||
final rec = await ref.read(cpsUpgradeProvider(item.id).future);
|
||||
if (!mounted) return;
|
||||
if (rec.isEmpty) {
|
||||
Fluttertoast.showToast(msg: '暂无升级款');
|
||||
return;
|
||||
}
|
||||
final first = rec.first;
|
||||
context.push('/cps-product-list', extra: CpsListArgs(
|
||||
title: '${item.category}升级款',
|
||||
source: first.source,
|
||||
categoryCode: first.categoryCode,
|
||||
city: first.city,
|
||||
scene: 'wardrobe_upgrade',
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(WardrobeItemInfo item) async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('删除这件服装?'),
|
||||
content: Text('「${item.category}」删除后不可恢复'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('取消')),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('删除')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok == true) {
|
||||
await ref.read(wardrobeProvider.notifier).delete(item.id);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final items = ref.watch(wardrobeProvider);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('我的衣橱')),
|
||||
body: Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 48,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
children: [
|
||||
for (final c in [null, ...wardrobeCategories])
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ChoiceChip(
|
||||
label: Text(c ?? '全部'),
|
||||
selected: _filter == c,
|
||||
onSelected: (_) => setState(() => _filter = c),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: items.when(
|
||||
loading: () => const LoadingView(text: '加载衣橱...'),
|
||||
error: (e, _) => ErrorView(
|
||||
message: e.toString().replaceFirst('Exception: ', ''),
|
||||
onRetry: () => ref.read(wardrobeProvider.notifier).refresh(),
|
||||
),
|
||||
data: (list) {
|
||||
final shown = _filter == null
|
||||
? list
|
||||
: list.where((i) => i.category == _filter).toList();
|
||||
if (shown.isEmpty) {
|
||||
return EmptyView(
|
||||
message: '衣橱还是空的,上传你的服装吧',
|
||||
actionText: '上传服装',
|
||||
onAction: () => context.go('/wardrobe-upload'));
|
||||
}
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.all(12),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
childAspectRatio: 0.8,
|
||||
),
|
||||
itemCount: shown.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final item = shown[i];
|
||||
return GestureDetector(
|
||||
onLongPress: () => _showItemMenu(item),
|
||||
child: Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: item.photoUrl.isEmpty
|
||||
? const Icon(Icons.checkroom,
|
||||
size: 48, color: Colors.grey)
|
||||
: Image.network(
|
||||
AppConfig.resolveUrl(item.photoUrl),
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => const Icon(
|
||||
Icons.broken_image_outlined,
|
||||
size: 48,
|
||||
color: Colors.grey),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Text(
|
||||
'${item.category}${item.season.isNotEmpty ? ' · ${item.season}' : ''}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 13, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () => context.go('/wardrobe-upload'),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('上传'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/auth/auth_provider.dart';
|
||||
|
||||
/// 服装分类(与后端校验一致)
|
||||
const wardrobeCategories = ['上衣', '下装', '鞋', '配饰'];
|
||||
|
||||
const wardrobeSeasons = ['春', '夏', '秋', '冬', '四季'];
|
||||
|
||||
class WardrobeItemInfo {
|
||||
final int id;
|
||||
final String photoUrl;
|
||||
final String category;
|
||||
final String season;
|
||||
final String styleTags;
|
||||
final String colorInfo;
|
||||
|
||||
const WardrobeItemInfo({
|
||||
required this.id,
|
||||
required this.photoUrl,
|
||||
required this.category,
|
||||
required this.season,
|
||||
required this.styleTags,
|
||||
required this.colorInfo,
|
||||
});
|
||||
}
|
||||
|
||||
class WardrobeNotifier extends AsyncNotifier<List<WardrobeItemInfo>> {
|
||||
@override
|
||||
Future<List<WardrobeItemInfo>> build() async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final data = await api.get<Map<String, dynamic>>('/wardrobe/list');
|
||||
final list = data['list'] as List<dynamic>? ?? [];
|
||||
return list
|
||||
.map((e) => WardrobeItemInfo(
|
||||
id: (e['id'] as num).toInt(),
|
||||
photoUrl: e['photo_url'] as String? ?? '',
|
||||
category: e['category'] as String? ?? '',
|
||||
season: e['season'] as String? ?? '',
|
||||
styleTags: e['style_tags'] as String? ?? '',
|
||||
colorInfo: e['color_info'] as String? ?? '',
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> upload(
|
||||
String filePath, {
|
||||
required String category,
|
||||
String season = '',
|
||||
String styleTags = '',
|
||||
String colorInfo = '',
|
||||
}) async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
await api.upload('/wardrobe/upload', {
|
||||
'category': category,
|
||||
'season': season,
|
||||
'style_tags': styleTags,
|
||||
'color_info': colorInfo,
|
||||
}, 'file', filePath);
|
||||
await refresh();
|
||||
}
|
||||
|
||||
Future<void> delete(int id) async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
await api.post('/wardrobe/delete', {'id': id});
|
||||
await refresh();
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(build);
|
||||
}
|
||||
}
|
||||
|
||||
final wardrobeProvider =
|
||||
AsyncNotifierProvider<WardrobeNotifier, List<WardrobeItemInfo>>(
|
||||
WardrobeNotifier.new);
|
||||
@@ -0,0 +1,163 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import 'wardrobe_provider.dart';
|
||||
|
||||
/// 服装上传:选图 + 分类/季节/风格/颜色信息
|
||||
class WardrobeUploadPage extends ConsumerStatefulWidget {
|
||||
const WardrobeUploadPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<WardrobeUploadPage> createState() => _WardrobeUploadPageState();
|
||||
}
|
||||
|
||||
class _WardrobeUploadPageState extends ConsumerState<WardrobeUploadPage> {
|
||||
final _picker = ImagePicker();
|
||||
final _styleCtrl = TextEditingController();
|
||||
final _colorCtrl = TextEditingController();
|
||||
|
||||
String? _imagePath;
|
||||
String? _category;
|
||||
String? _season;
|
||||
bool _uploading = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_styleCtrl.dispose();
|
||||
_colorCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _pickImage() async {
|
||||
final file = await _picker.pickImage(
|
||||
source: ImageSource.gallery, maxWidth: 2048, imageQuality: 85);
|
||||
if (file == null) return;
|
||||
setState(() => _imagePath = file.path);
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (_imagePath == null) {
|
||||
Fluttertoast.showToast(msg: '请先选择服装照片');
|
||||
return;
|
||||
}
|
||||
if (_category == null) {
|
||||
Fluttertoast.showToast(msg: '请选择分类');
|
||||
return;
|
||||
}
|
||||
setState(() => _uploading = true);
|
||||
try {
|
||||
await ref.read(wardrobeProvider.notifier).upload(
|
||||
_imagePath!,
|
||||
category: _category!,
|
||||
season: _season ?? '',
|
||||
styleTags: _styleCtrl.text.trim(),
|
||||
colorInfo: _colorCtrl.text.trim(),
|
||||
);
|
||||
if (!mounted) return;
|
||||
Fluttertoast.showToast(msg: '上传成功');
|
||||
context.go('/wardrobe');
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
Fluttertoast.showToast(
|
||||
msg: '上传失败:${e.toString().replaceFirst('Exception: ', '')}');
|
||||
} finally {
|
||||
if (mounted) setState(() => _uploading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('上传服装')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: _uploading ? null : _pickImage,
|
||||
child: Container(
|
||||
height: 220,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: _imagePath == null
|
||||
? Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.add_photo_alternate_outlined,
|
||||
size: 48, color: Colors.grey.shade500),
|
||||
const SizedBox(height: 8),
|
||||
const Text('点击选择服装照片',
|
||||
style: TextStyle(color: Colors.grey)),
|
||||
],
|
||||
)
|
||||
: Image.file(File(_imagePath!), fit: BoxFit.cover),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _category,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '分类', border: OutlineInputBorder()),
|
||||
items: [
|
||||
for (final c in wardrobeCategories)
|
||||
DropdownMenuItem(value: c, child: Text(c)),
|
||||
],
|
||||
onChanged: _uploading
|
||||
? null
|
||||
: (v) => setState(() => _category = v),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _season,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '适用季节(选填)', border: OutlineInputBorder()),
|
||||
items: [
|
||||
for (final s in wardrobeSeasons)
|
||||
DropdownMenuItem(value: s, child: Text(s)),
|
||||
],
|
||||
onChanged:
|
||||
_uploading ? null : (v) => setState(() => _season = v),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _styleCtrl,
|
||||
enabled: !_uploading,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '风格标签(选填,如:通勤、休闲)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _colorCtrl,
|
||||
enabled: !_uploading,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '颜色描述(选填,如:黑色)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: _uploading ? null : _submit,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14)),
|
||||
child: _uploading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Text('上传到衣橱'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'core/auth/auth_provider.dart';
|
||||
import 'features/auth/login_page.dart';
|
||||
import 'features/cps/cps_product_list_page.dart';
|
||||
import 'features/cps/cps_provider.dart';
|
||||
import 'features/home/home_page.dart';
|
||||
import 'features/member/pay_page.dart';
|
||||
import 'features/outfit/outfit_page.dart';
|
||||
import 'features/outfit/outfit_provider.dart';
|
||||
import 'features/outfit/plan_effect_page.dart';
|
||||
import 'features/outfit/plan_viewer_page.dart';
|
||||
import 'features/profile/avatar_build_flow_page.dart';
|
||||
import 'features/profile/avatar_viewer_page.dart';
|
||||
import 'features/wardrobe/wardrobe_page.dart';
|
||||
import 'features/wardrobe/wardrobe_upload_page.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const ProviderScope(child: SloganApp()));
|
||||
}
|
||||
|
||||
class SloganApp extends ConsumerWidget {
|
||||
const SloganApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final router = ref.watch(routerProvider);
|
||||
final auth = ref.watch(authProvider);
|
||||
// 路由守卫:token 失效/登出 → 回登录页;token 有效且在登录页 → 进主框架
|
||||
final path =
|
||||
router.routerDelegate.currentConfiguration.uri.path;
|
||||
final authed = auth.value?.authenticated ?? false;
|
||||
if (!auth.isLoading) {
|
||||
if (!authed && path != '/login') {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (context.mounted) router.go('/login');
|
||||
});
|
||||
} else if (authed && path == '/login') {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (context.mounted) router.go('/home');
|
||||
});
|
||||
}
|
||||
}
|
||||
return MaterialApp.router(
|
||||
title: '我的形象穿搭',
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF5C6BC0)),
|
||||
useMaterial3: true,
|
||||
appBarTheme: const AppBarTheme(centerTitle: true),
|
||||
),
|
||||
routerConfig: router,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 路由表:启动无 token 进登录页;有 token 进主框架
|
||||
final routerProvider = Provider<GoRouter>((ref) {
|
||||
return GoRouter(
|
||||
initialLocation: '/login',
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/login',
|
||||
builder: (ctx, state) => const LoginPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/home',
|
||||
builder: (ctx, state) => const HomePage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/avatar-build',
|
||||
builder: (ctx, state) => const AvatarBuildFlowPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/wardrobe',
|
||||
builder: (ctx, state) => const WardrobePage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/wardrobe-upload',
|
||||
builder: (ctx, state) => const WardrobeUploadPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/outfit',
|
||||
builder: (ctx, state) => const OutfitPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/pay',
|
||||
builder: (context, state) => PayPage(args: state.extra! as PayArgs),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/plan-viewer',
|
||||
builder: (ctx, state) => const PlanViewerPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/plan-effect',
|
||||
builder: (ctx, state) =>
|
||||
PlanEffectPage(images: state.extra as List<PlanEffectImageInfo>),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/avatar-viewer',
|
||||
builder: (ctx, state) => const AvatarViewerPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/cps-product-list',
|
||||
builder: (ctx, state) =>
|
||||
CpsProductListPage(args: state.extra! as CpsListArgs),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/config/app_config.dart';
|
||||
|
||||
/// 3D 化身查看组件
|
||||
///
|
||||
/// 服务端预渲染帧序列(36 帧绕 Y 轴旋转 PNG)轮播模拟 3D;
|
||||
/// framesUrl 为空时降级为静态占位。
|
||||
class AvatarViewer extends StatefulWidget {
|
||||
final String? glbUrl;
|
||||
final String? framesUrl;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final double height;
|
||||
|
||||
const AvatarViewer({
|
||||
super.key,
|
||||
this.glbUrl,
|
||||
this.framesUrl,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
this.height = 360,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AvatarViewer> createState() => _AvatarViewerState();
|
||||
}
|
||||
|
||||
class _AvatarViewerState extends State<AvatarViewer> {
|
||||
static const int _frameCount = 36;
|
||||
|
||||
Timer? _timer;
|
||||
int _frame = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.framesUrl != null && widget.framesUrl!.isNotEmpty) {
|
||||
_timer = Timer.periodic(const Duration(milliseconds: 120), (_) {
|
||||
setState(() => _frame = (_frame + 1) % _frameCount);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final framesUrl = widget.framesUrl;
|
||||
if (framesUrl != null && framesUrl.isNotEmpty) {
|
||||
final frameUrl = AppConfig.resolveUrl(
|
||||
'$framesUrl/frame_${_frame.toString().padLeft(3, '0')}.png');
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
height: widget.height,
|
||||
width: double.infinity,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
scheme.primaryContainer,
|
||||
scheme.primary.withValues(alpha: 0.3),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Stack(
|
||||
alignment: Alignment.bottomCenter,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Image.network(
|
||||
frameUrl,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, _, _) => _placeholder(context),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Text(
|
||||
widget.subtitle,
|
||||
style: const TextStyle(
|
||||
color: Colors.white, fontSize: 12, shadows: [
|
||||
Shadow(color: Colors.black45, blurRadius: 4),
|
||||
]),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return _placeholder(context);
|
||||
}
|
||||
|
||||
Widget _placeholder(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
height: widget.height,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
scheme.primaryContainer,
|
||||
scheme.primary.withValues(alpha: 0.3),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.accessibility_new, size: 72, color: scheme.primary),
|
||||
const SizedBox(height: 12),
|
||||
Text(widget.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 4),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Text(
|
||||
widget.subtitle.isEmpty ? '3D 渲染服务未就绪' : widget.subtitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 空态 + 引导动作
|
||||
class EmptyView extends StatelessWidget {
|
||||
final String message;
|
||||
final String? actionText;
|
||||
final VoidCallback? onAction;
|
||||
|
||||
const EmptyView({
|
||||
super.key,
|
||||
required this.message,
|
||||
this.actionText,
|
||||
this.onAction,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.inbox_outlined, size: 48, color: Colors.grey.shade400),
|
||||
const SizedBox(height: 12),
|
||||
Text(message, style: const TextStyle(color: Colors.grey)),
|
||||
if (actionText != null && onAction != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(onPressed: onAction, child: Text(actionText!)),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 错误态 + 重试
|
||||
class ErrorView extends StatelessWidget {
|
||||
final String message;
|
||||
final VoidCallback? onRetry;
|
||||
|
||||
const ErrorView({super.key, required this.message, this.onRetry});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 48, color: Colors.grey.shade400),
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Text(message,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.grey)),
|
||||
),
|
||||
if (onRetry != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
OutlinedButton.icon(
|
||||
onPressed: onRetry, icon: const Icon(Icons.refresh), label: const Text('重试')),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 加载骨架屏
|
||||
class LoadingView extends StatelessWidget {
|
||||
final String? text;
|
||||
|
||||
const LoadingView({super.key, this.text});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
if (text != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(text!, style: const TextStyle(color: Colors.grey)),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
flutter/ephemeral
|
||||
@@ -0,0 +1,128 @@
|
||||
# Project-level configuration.
|
||||
cmake_minimum_required(VERSION 3.13)
|
||||
project(runner LANGUAGES CXX)
|
||||
|
||||
# The name of the executable created for the application. Change this to change
|
||||
# the on-disk name of your application.
|
||||
set(BINARY_NAME "slogan_app")
|
||||
# The unique GTK application identifier for this application. See:
|
||||
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
|
||||
set(APPLICATION_ID "com.slogan.slogan_app")
|
||||
|
||||
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
|
||||
# versions of CMake.
|
||||
cmake_policy(SET CMP0063 NEW)
|
||||
|
||||
# Load bundled libraries from the lib/ directory relative to the binary.
|
||||
set(CMAKE_INSTALL_RPATH "$ORIGIN/lib")
|
||||
|
||||
# Root filesystem for cross-building.
|
||||
if(FLUTTER_TARGET_PLATFORM_SYSROOT)
|
||||
set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT})
|
||||
set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT})
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
|
||||
endif()
|
||||
|
||||
# Define build configuration options.
|
||||
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
|
||||
set(CMAKE_BUILD_TYPE "Debug" CACHE
|
||||
STRING "Flutter build mode" FORCE)
|
||||
set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS
|
||||
"Debug" "Profile" "Release")
|
||||
endif()
|
||||
|
||||
# Compilation settings that should be applied to most targets.
|
||||
#
|
||||
# Be cautious about adding new options here, as plugins use this function by
|
||||
# default. In most cases, you should add new options to specific targets instead
|
||||
# of modifying this function.
|
||||
function(APPLY_STANDARD_SETTINGS TARGET)
|
||||
target_compile_features(${TARGET} PUBLIC cxx_std_14)
|
||||
target_compile_options(${TARGET} PRIVATE -Wall -Werror)
|
||||
target_compile_options(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:-O3>")
|
||||
target_compile_definitions(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:NDEBUG>")
|
||||
endfunction()
|
||||
|
||||
# Flutter library and tool build rules.
|
||||
set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter")
|
||||
add_subdirectory(${FLUTTER_MANAGED_DIR})
|
||||
|
||||
# System-level dependencies.
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
|
||||
|
||||
# Application build; see runner/CMakeLists.txt.
|
||||
add_subdirectory("runner")
|
||||
|
||||
# Run the Flutter tool portions of the build. This must not be removed.
|
||||
add_dependencies(${BINARY_NAME} flutter_assemble)
|
||||
|
||||
# Only the install-generated bundle's copy of the executable will launch
|
||||
# correctly, since the resources must in the right relative locations. To avoid
|
||||
# people trying to run the unbundled copy, put it in a subdirectory instead of
|
||||
# the default top-level location.
|
||||
set_target_properties(${BINARY_NAME}
|
||||
PROPERTIES
|
||||
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run"
|
||||
)
|
||||
|
||||
|
||||
# Generated plugin build rules, which manage building the plugins and adding
|
||||
# them to the application.
|
||||
include(flutter/generated_plugins.cmake)
|
||||
|
||||
|
||||
# === Installation ===
|
||||
# By default, "installing" just makes a relocatable bundle in the build
|
||||
# directory.
|
||||
set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle")
|
||||
if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
|
||||
set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
|
||||
endif()
|
||||
|
||||
# Start with a clean build bundle directory every time.
|
||||
install(CODE "
|
||||
file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\")
|
||||
" COMPONENT Runtime)
|
||||
|
||||
set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data")
|
||||
set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib")
|
||||
|
||||
install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES})
|
||||
install(FILES "${bundled_library}"
|
||||
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
endforeach(bundled_library)
|
||||
|
||||
# Copy the native assets provided by the build.dart from all packages.
|
||||
set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/")
|
||||
install(DIRECTORY "${NATIVE_ASSETS_DIR}"
|
||||
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
# Fully re-copy the assets directory on each build to avoid having stale files
|
||||
# from a previous install.
|
||||
set(FLUTTER_ASSET_DIR_NAME "flutter_assets")
|
||||
install(CODE "
|
||||
file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\")
|
||||
" COMPONENT Runtime)
|
||||
install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}"
|
||||
DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime)
|
||||
|
||||
# Install the AOT library on non-Debug builds only.
|
||||
if(NOT CMAKE_BUILD_TYPE MATCHES "Debug")
|
||||
install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
endif()
|
||||
@@ -0,0 +1,88 @@
|
||||
# This file controls Flutter-level build steps. It should not be edited.
|
||||
cmake_minimum_required(VERSION 3.10)
|
||||
|
||||
set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral")
|
||||
|
||||
# Configuration provided via flutter tool.
|
||||
include(${EPHEMERAL_DIR}/generated_config.cmake)
|
||||
|
||||
# TODO: Move the rest of this into files in ephemeral. See
|
||||
# https://github.com/flutter/flutter/issues/57146.
|
||||
|
||||
# Serves the same purpose as list(TRANSFORM ... PREPEND ...),
|
||||
# which isn't available in 3.10.
|
||||
function(list_prepend LIST_NAME PREFIX)
|
||||
set(NEW_LIST "")
|
||||
foreach(element ${${LIST_NAME}})
|
||||
list(APPEND NEW_LIST "${PREFIX}${element}")
|
||||
endforeach(element)
|
||||
set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
# === Flutter Library ===
|
||||
# System-level dependencies.
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
|
||||
pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0)
|
||||
pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0)
|
||||
|
||||
set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so")
|
||||
|
||||
# Published to parent scope for install step.
|
||||
set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE)
|
||||
set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE)
|
||||
set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE)
|
||||
set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE)
|
||||
|
||||
list(APPEND FLUTTER_LIBRARY_HEADERS
|
||||
"fl_basic_message_channel.h"
|
||||
"fl_binary_codec.h"
|
||||
"fl_binary_messenger.h"
|
||||
"fl_dart_project.h"
|
||||
"fl_engine.h"
|
||||
"fl_json_message_codec.h"
|
||||
"fl_json_method_codec.h"
|
||||
"fl_message_codec.h"
|
||||
"fl_method_call.h"
|
||||
"fl_method_channel.h"
|
||||
"fl_method_codec.h"
|
||||
"fl_method_response.h"
|
||||
"fl_plugin_registrar.h"
|
||||
"fl_plugin_registry.h"
|
||||
"fl_standard_message_codec.h"
|
||||
"fl_standard_method_codec.h"
|
||||
"fl_string_codec.h"
|
||||
"fl_value.h"
|
||||
"fl_view.h"
|
||||
"flutter_linux.h"
|
||||
)
|
||||
list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/")
|
||||
add_library(flutter INTERFACE)
|
||||
target_include_directories(flutter INTERFACE
|
||||
"${EPHEMERAL_DIR}"
|
||||
)
|
||||
target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}")
|
||||
target_link_libraries(flutter INTERFACE
|
||||
PkgConfig::GTK
|
||||
PkgConfig::GLIB
|
||||
PkgConfig::GIO
|
||||
)
|
||||
add_dependencies(flutter flutter_assemble)
|
||||
|
||||
# === Flutter tool backend ===
|
||||
# _phony_ is a non-existent file to force this command to run every time,
|
||||
# since currently there's no way to get a full input/output list from the
|
||||
# flutter tool.
|
||||
add_custom_command(
|
||||
OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS}
|
||||
${CMAKE_CURRENT_BINARY_DIR}/_phony_
|
||||
COMMAND ${CMAKE_COMMAND} -E env
|
||||
${FLUTTER_TOOL_ENVIRONMENT}
|
||||
"${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh"
|
||||
${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE}
|
||||
VERBATIM
|
||||
)
|
||||
add_custom_target(flutter_assemble DEPENDS
|
||||
"${FLUTTER_LIBRARY}"
|
||||
${FLUTTER_LIBRARY_HEADERS}
|
||||
)
|
||||