Add 'app/' from commit 'a11a66886941bba128d89c1124115e1b1c87a128'

git-subtree-dir: app
git-subtree-mainline: 6ebd902c6b
git-subtree-split: a11a668869
This commit is contained in:
2026-08-04 14:59:55 +08:00
165 changed files with 11852 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
# Flutter/Dart
.dart_tool/
build/
.flutter-plugins
.flutter-plugins-dependencies
*.iml
.idea/
# 系统
.DS_Store
+45
View File
@@ -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'
+17
View File
@@ -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.
+28
View File
@@ -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
+14
View File
@@ -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
+45
View File
@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

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>
+24
View File
@@ -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)
}
+6
View File
@@ -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
+5
View File
@@ -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
+26
View File
@@ -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) {
// P1AppConfig.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
```
---
## 自检清单
- [ ] /commercialhome 第 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-agentGoAPI,见 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 + ThemeMaterial 3seed 主色)+ 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 adapter401 触发登出回调;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**AsyncNotifierlogin(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: 写失败测试**providermock 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 providerget → 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: 写失败测试**providerupload 成功追加列表;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: 写失败测试**providergenerate 成功返回 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: 写失败测试**providertask 轮询 done → 加载 plan listfailed → error
- [ ] **Step 2: task_status_page.dart**:轮询 GET /outfit/task/statusTimer.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 化身区(AvatarViewerTask 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 旋转 Object3DonScaleUpdate → 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: 写失败测试**providerselect-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: 门店/电商(Tab4MVP 列表展示)
**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: 写失败测试**providerGET /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 hash256MB 上限(超限按最后访问时间淘汰);方案流预取下一页 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_caseJSON),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 页面结构
```
/ commercialConsumerStatefulWidget,保留现有门店列表与类型筛选)
├─ 会员状态卡:头像/会员名 · 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/recentP1,未开通则隐藏整卡)
```
### 2.2 支付时序(App 侧)
```
点击套餐 → POST /member/order/create {plan_id} → 返回 {order_no, pay_url}
→ 打开 PayWebViewPage(内嵌 webview_flutteriOS 用 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 未配置 → falseApp 隐藏广告入口
Future<bool> showRewarded(); // 激励视频,返回是否完整观看
}
// lib/core/ads/pangle_ads_service.dart —— 穿山甲实现(P1 接入 SDK,P0 仅接口 + mock
// P0MockAdsService —— 本地模拟 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(门店券) |
| 场合卡(P1detail 有 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/listP1
商品卡:封面图(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-listextra: CpsListArgs{title, source, categoryCode, city}
/pay-webviewextra: 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 3AsyncNotifier/Notifier/FutureProvider.familyprovider 文件放 `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.0GLTF/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_dartflutter_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:先写失败测试再实现)
+34
View File
@@ -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
+24
View File
@@ -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>
+1
View File
@@ -0,0 +1 @@
#include "Generated.xcconfig"
+1
View File
@@ -0,0 +1 @@
#include "Generated.xcconfig"
+644
View File
@@ -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 &quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;">
<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>
+7
View File
@@ -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>
+16
View File
@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

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>
+26
View File
@@ -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>
+70
View File
@@ -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>
+1
View File
@@ -0,0 +1 @@
#import "GeneratedPluginRegistrant.h"
+6
View File
@@ -0,0 +1,6 @@
import Flutter
import UIKit
class SceneDelegate: FlutterSceneDelegate {
}
+12
View File
@@ -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.
}
}
+52
View File
@@ -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) {
// P1AppConfig.pangleAppId 非空时替换为 PangleAdsService(穿山甲 SDK 实现)
return MockAdsService();
});
+64
View File
@@ -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);
+14
View File
@@ -0,0 +1,14 @@
/// 全局配置
class AppConfig {
/// 后端地址:iOS 模拟器用 127.0.0.1Android 模拟器用 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 = '';
}
+77
View File
@@ -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;
}
}
+10
View File
@@ -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;
}
+28
View File
@@ -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);
}
}
+161
View File
@@ -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),
),
],
),
),
],
),
),
);
},
);
},
),
),
],
),
);
}
}
+201
View File
@@ -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();
});
+137
View File
@@ -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? ?? '';
}
+152
View File
@@ -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('返回'),
),
]),
},
],
),
),
),
);
}
}
+459
View File
@@ -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 上无 Platformdart: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),
),
],
),
),
],
),
),
);
}
}
+317
View File
@@ -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('上传到衣橱'),
),
],
),
);
}
}
+111
View File
@@ -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),
),
],
);
});
+139
View File
@@ -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),
),
),
],
),
);
}
}
+33
View File
@@ -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!)),
],
],
),
);
}
}
+33
View File
@@ -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('重试')),
],
],
),
);
}
}
+24
View File
@@ -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)),
],
],
),
);
}
}
+1
View File
@@ -0,0 +1 @@
flutter/ephemeral
+128
View File
@@ -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()
+88
View File
@@ -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}
)

Some files were not shown because too many files have changed in this diff Show More