diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..03b86d2 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,9 @@ +# Flutter/Dart +.dart_tool/ +build/ +.flutter-plugins +.flutter-plugins-dependencies +*.iml +.idea/ +# 系统 +.DS_Store diff --git a/app/.metadata b/app/.metadata new file mode 100644 index 0000000..ea0a198 --- /dev/null +++ b/app/.metadata @@ -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' diff --git a/app/README.md b/app/README.md new file mode 100644 index 0000000..8ddca5b --- /dev/null +++ b/app/README.md @@ -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. diff --git a/app/analysis_options.yaml b/app/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/app/analysis_options.yaml @@ -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 diff --git a/app/android/.gitignore b/app/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/app/android/.gitignore @@ -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 diff --git a/app/android/app/build.gradle.kts b/app/android/app/build.gradle.kts new file mode 100644 index 0000000..0346753 --- /dev/null +++ b/app/android/app/build.gradle.kts @@ -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 = "../.." +} diff --git a/app/android/app/src/debug/AndroidManifest.xml b/app/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/app/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..d01b264 --- /dev/null +++ b/app/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/android/app/src/main/kotlin/com/slogan/slogan_app/MainActivity.kt b/app/android/app/src/main/kotlin/com/slogan/slogan_app/MainActivity.kt new file mode 100644 index 0000000..b68d5d5 --- /dev/null +++ b/app/android/app/src/main/kotlin/com/slogan/slogan_app/MainActivity.kt @@ -0,0 +1,5 @@ +package com.slogan.slogan_app + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/app/android/app/src/main/res/drawable-v21/launch_background.xml b/app/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/app/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/app/android/app/src/main/res/drawable/launch_background.xml b/app/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/app/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/android/app/src/main/res/values-night/styles.xml b/app/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/app/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/app/android/app/src/main/res/values/styles.xml b/app/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/app/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/app/android/app/src/profile/AndroidManifest.xml b/app/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/app/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/android/build.gradle.kts b/app/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/app/android/build.gradle.kts @@ -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("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/app/android/gradle.properties b/app/android/gradle.properties new file mode 100644 index 0000000..e96108c --- /dev/null +++ b/app/android/gradle.properties @@ -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 diff --git a/app/android/gradle/wrapper/gradle-wrapper.properties b/app/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..2d428bf --- /dev/null +++ b/app/android/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/app/android/settings.gradle.kts b/app/android/settings.gradle.kts new file mode 100644 index 0000000..c21f0c5 --- /dev/null +++ b/app/android/settings.gradle.kts @@ -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") diff --git a/app/docs/superpowers/plans/2026-07-31-commerce-p0-app.md b/app/docs/superpowers/plans/2026-07-31-commerce-p0-app.md new file mode 100644 index 0000000..7cdbdc6 --- /dev/null +++ b/app/docs/superpowers/plans/2026-07-31-commerce-p0-app.md @@ -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 showRewarded(); +} + +/// 本地模拟激励视频(约 1 秒"播放"后返回完整观看) +class MockAdsService implements AdsService { + @override + bool get enabled => true; + + @override + Future showRewarded() async { + await Future.delayed(const Duration(milliseconds: 900)); + return true; + } +} + +final adsServiceProvider = Provider((ref) { + // P1:AppConfig.pangleAppId 非空时替换为 PangleAdsService(穿山甲 SDK 实现) + return MockAdsService(); +}); +``` + +- [ ] **Step 5: 提交** + +```bash +git add pubspec.yaml lib/core/ads test/features/member +git commit -m "feat: 广告服务抽象(Mock 激励视频)+ url_launcher 依赖" +``` + +--- + +### Task 2: member provider(会员状态/套餐/下单/轮询/领奖) + +**Files:** +- Create: `lib/features/member/member_provider.dart` + +- [ ] **Step 1: 实现 member_provider.dart**(含 Task 1 测试依赖的 `MemberInfo` 与 `benefitTexts`) + +```dart +import 'dart:convert'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/network/api_client.dart'; + +class MemberInfo { + final bool isVip; + final String expireAt; + final String planName; + final List benefits; + + const MemberInfo({ + required this.isVip, + required this.expireAt, + required this.planName, + required this.benefits, + }); + + factory MemberInfo.fromJson(Map 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? ?? []).cast(), + ); +} + +/// 权益 key → 文案 +const benefitLabels = { + 'effect_unlimited': '无限效果图', + 'ai_priority': '优先 AI 方案', + 'cps_commission_x15': '返现加成 1.5x', + 'store_discount': '门店折扣', +}; + +List benefitTexts(MemberInfo m) => + m.benefits.map((b) => benefitLabels[b] ?? b).toList(); + +class MemberNotifier extends AsyncNotifier { + @override + Future build() async { + final api = ref.read(apiClientProvider); + final data = await api.get>('/member/status'); + return MemberInfo.fromJson(data ?? {}); + } + + Future refresh() async { + state = await AsyncValue.guard(build); + } + + /// 领取广告激励(服务端限频);adType: effect_extra | vip_trial + /// 返回当日剩余次数;超出限频抛 ApiException + Future claimReward(String adType) async { + final api = ref.read(apiClientProvider); + final data = + await api.post>('/ad/reward/claim', {'ad_type': adType}); + await refresh(); // vip_trial 可能开通体验会员 + return (data?['reward']?['remaining_today'] as num?)?.toInt() ?? 0; + } +} + +final memberProvider = + AsyncNotifierProvider(MemberNotifier.new); + +class MemberPlan { + final int id; + final String name; + final int priceFen; + final int durationDays; + final List features; + + const MemberPlan({ + required this.id, + required this.name, + required this.priceFen, + required this.durationDays, + required this.features, + }); + + factory MemberPlan.fromJson(Map 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 _parseFeatures(String s) { + try { + return (jsonDecode(s) as List).cast(); + } catch (_) { + return const []; + } + } + + String get priceText => + '¥${(priceFen / 100).toStringAsFixed(priceFen % 100 == 0 ? 0 : 1)}'; +} + +final memberPlanProvider = FutureProvider>((ref) async { + final api = ref.read(apiClientProvider); + final list = await api.get>('/member/plan/list'); + return (list ?? []) + .map((e) => MemberPlan.fromJson(e as Map)) + .toList(); +}); + +class OrderResult { + final String orderNo; + final String payUrl; + + const OrderResult({required this.orderNo, required this.payUrl}); +} + +/// 创建支付订单(后端调虎皮棋下单,返回收银台 URL) +Future createMemberOrder(WidgetRef ref, int planId) async { + final api = ref.read(apiClientProvider); + final data = + await api.post>('/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 fetchOrderStatus(WidgetRef ref, String orderNo) async { + final api = ref.read(apiClientProvider); + final data = await api.get>('/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> { + @override + Future> build() async { + final api = ref.read(apiClientProvider); + final list = await api.get>('/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 refresh() async { + state = const AsyncLoading(); + state = await AsyncValue.guard(build); + } +} + +final storeProvider = + AsyncNotifierProvider>(StoreNotifier.new); + +const _storeTypeLabels = {1: '造型', 2: '服装'}; + +/// 会员中心:会员状态/套餐充值/广告激励 + 合作门店(P0;最近优惠 P1) +class MemberCenterPage extends ConsumerStatefulWidget { + const MemberCenterPage({super.key}); + + @override + ConsumerState createState() => _MemberCenterPageState(); +} + +class _MemberCenterPageState extends ConsumerState { + int? _typeFilter; + bool _rewarding = false; + + bool get _isIOS => Platform.isIOS; + + Future _openPlans() async { + final plans = await showModalBottomSheet>( + 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 _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 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 createState() => _PayPageState(); +} + +class _PayPageState extends ConsumerState { + PayPhase _phase = PayPhase.launching; + Timer? _timer; + int _elapsed = 0; + + @override + void initState() { + super.initState(); + _start(); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + Future _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 _check() async { + _elapsed += 2; + try { + final status = await fetchOrderStatus(ref, widget.args.orderNo); + if (status == 'paid') { + _timer?.cancel(); + if (!mounted) return; + setState(() => _phase = PayPhase.paid); + Fluttertoast.showToast(msg: '会员开通成功'); + return; + } + if (_elapsed >= 60) { + _timer?.cancel(); + if (!mounted) return; + setState(() => _phase = PayPhase.timeout); + } + } catch (_) { + // 轮询失败不中断,下次再试 + } + } + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Scaffold( + appBar: AppBar(title: const Text('会员支付')), + body: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + switch (_phase) { + PayPhase.launching || + PayPhase.paying => Column(children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + const Text('请在浏览器中完成支付,正在确认结果…'), + const SizedBox(height: 8), + Text('订单号 ${widget.args.orderNo}', + style: const TextStyle(color: Colors.grey, fontSize: 12)), + const SizedBox(height: 16), + OutlinedButton( + onPressed: () => _check(), + child: const Text('我已完成支付'), + ), + ]), + PayPhase.paid => Column(children: [ + Icon(Icons.check_circle, size: 64, color: scheme.primary), + const SizedBox(height: 12), + const Text('支付成功,会员已开通!', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + const SizedBox(height: 16), + FilledButton( + onPressed: () => context.pop(), + child: const Text('返回会员中心'), + ), + ]), + PayPhase.timeout => Column(children: [ + Icon(Icons.hourglass_empty, size: 64, color: Colors.orange), + const SizedBox(height: 12), + const Text('支付结果确认中'), + const SizedBox(height: 8), + const Text('可稍后到会员中心查看开通状态,以支付结果为准', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey, fontSize: 12)), + const SizedBox(height: 16), + OutlinedButton( + onPressed: () => context.pop(), + child: const Text('返回'), + ), + ]), + PayPhase.failed => Column(children: [ + Icon(Icons.error_outline, size: 64, color: scheme.error), + const SizedBox(height: 12), + const Text('无法打开支付页面'), + const SizedBox(height: 16), + OutlinedButton( + onPressed: () => context.pop(), + child: const Text('返回'), + ), + ]), + }, + ], + ), + ), + ), + ); + } +} +``` + +- [ ] **Step 2: main.dart 注册路由** + +```dart + GoRoute( + path: '/pay', + builder: (context, state) => + PayPage(args: state.extra! as PayArgs), + ), +``` + +(import `../features/member/pay_page.dart`) + +- [ ] **Step 3: 静态检查** + +Run: `dart analyze lib 2>&1 | tail -5` Expected: 无 error + +- [ ] **Step 4: 提交** + +```bash +git add lib/features/member/pay_page.dart lib/main.dart +git commit -m "feat: 支付页(系统浏览器收银台 + 2s 轮询确认)" +``` + +--- + +### Task 5: 全量验证与冒烟 + +- [ ] **Step 1: 单元测试** + +Run: `flutter test` Expected: 全部 PASS(含新增 benefits 2 个用例) + +- [ ] **Step 2: 全量编译(web 兜底)** + +Run: `cd /Users/zhangbin/Desktop/d盘/work/slogan/slogan-app && flutter build web --release` Expected: 构建成功(若提示 stale cache,先 `flutter clean && flutter pub get`) + +- [ ] **Step 3: 冒烟(起后端,mock 支付/广告降级路径)** + +后端按后端计划 T9 起服务(不配 mock 时): +```bash +# 会员状态:未登录返回 401;已登录非会员返回 is_vip=false +# 套餐列表:返回 2 个套餐 +# 下单:报"支付未开通" → App 开通按钮 toast 提示,不崩溃 +# 广告:claim 前 2 次成功(remaining 1/0),第 3 次报"今日次数已用完" → toast 展示 +``` + +- [ ] **Step 4: 提交** + +```bash +git add -A +git status # 确认无残留 +git log --oneline -5 +``` + +--- + +## 自检清单 + +- [ ] /commercial(home 第 4 Tab 与路由)指向会员中心,旧 commercial_page 已删除 +- [ ] iOS 隐藏充值入口(Platform.isIOS),广告激励保留 +- [ ] 支付页轮询 2s/60s 上限,paid/timeout/failed 三态完整;返回后刷新会员状态 +- [ ] 广告入口走 adsServiceProvider 抽象,Mock 可用,穿山甲 P1 替换点已注明 +- [ ] 所有后端接口错误 toast 展示 message,不崩溃;未开通时入口不渲染/隐藏 +- [ ] `dart analyze` 无 error、`flutter test` 全过、web 编译成功 + +## 后续计划(P1,不在本计划内) + +方案页三处 CPS 入口(做同款发型/买同款/到店试穿)、/cps-product-list 商品列表、衣橱「找升级款」、最近优惠、穿山甲 SDK 替换 Mock、webview 内嵌收银台。 diff --git a/app/docs/superpowers/plans/2026-07-31-slogan-app-mvp.md b/app/docs/superpowers/plans/2026-07-31-slogan-app-mvp.md new file mode 100644 index 0000000..0940afc --- /dev/null +++ b/app/docs/superpowers/plans/2026-07-31-slogan-app-mvp.md @@ -0,0 +1,372 @@ +# slogan-app MVP 实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 实现 slogan-app Flutter MVP:登录 → 4 Tab 框架 → 拍照上传 → 衣橱管理 → 化身查看 → 穿搭生成(日期/地点 → 轮询 → 方案流 3D+2D 切换查看)。 + +**Architecture:** Flutter 单仓,Riverpod 状态管理,Dio 网络层(JWT 拦截器 + 统一响应),AvatarViewer 渲染抽象层(three_dart v1 实现,flutter_scene 后续替换),核心交互为方案 PageView 横滑 + 3D 化身拖拽旋转。 + +**Tech Stack:** Flutter (stable) / Riverpod / Dio / go_router / three_dart + three_dart_jsm / cached_network_image / camera + image_picker / shared_preferences + +**后端对接:** slogan-agent(Go)API,见 slogan-agent 仓库 `docs/superpowers/specs/2026-07-31-slogan-agent-design.md` 第 11 节路由表。 + +--- + +### Task 1: 项目骨架 + +**Files:** +- Run: `flutter create` 初始化(org 自定,项目名 slogan_app) +- Modify: `pubspec.yaml`(依赖) +- Create: `lib/main.dart`(入口 + 主题 + go_router 路由表) +- Create: `lib/core/config/app_config.dart` + +- [ ] **Step 1: 初始化** + +```bash +cd slogan-app +flutter create --org com.slogan --project-name slogan_app . +``` + +- [ ] **Step 2: pubspec.yaml 依赖** + +```yaml +dependencies: + flutter_riverpod: ^2.6.0 + dio: ^5.7.0 + go_router: ^14.0.0 + cached_network_image: ^3.4.0 + image_picker: ^1.1.0 + camera: ^0.11.0 + shared_preferences: ^2.3.0 + three_dart: ^0.2.0 + three_dart_jsm: ^0.2.0 + fluttertoast: ^8.2.0 +``` + +(版本以 pub.dev 最新稳定为准,`flutter pub add` 逐个添加) + +- [ ] **Step 3: main.dart**:MaterialApp + Theme(Material 3,seed 主色)+ go_router 路由(login / home(4 tab) / photo-guide / plan-flow / plan-detail);启动时读取 token → 无 token 重定向登录页 + +- [ ] **Step 4: 验证** `flutter analyze` 无错误 + `flutter test` 默认通过 + +- [ ] **Step 5: Commit** `git add -A && git commit -m "feat: flutter skeleton with router and theme"` + +--- + +### Task 2: 网络层(ApiClient + JWT 拦截器) + +**Files:** +- Create: `lib/core/network/api_client.dart` +- Create: `lib/core/network/api_exception.dart` +- Create: `lib/core/storage/token_storage.dart` +- Test: `test/core/network/api_client_test.dart` + +- [ ] **Step 1: 写失败测试**(mock Dio adapter:401 触发登出回调;code!=0 抛 ApiException 带 message;成功解析 data) + +- [ ] **Step 2: 确认失败** `flutter test` + +- [ ] **Step 3: 实现 api_client.dart** + +```dart +class ApiClient { + ApiClient({Dio? dio, required TokenStorage tokenStorage}) + : _tokenStorage = tokenStorage { + _dio = dio ?? Dio(BaseOptions( + baseUrl: AppConfig.baseUrl, + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 30), + )); + _dio.interceptors.add(InterceptorsWrapper( + onRequest: (options, handler) { + final token = _tokenStorage.token; + if (token != null) options.headers['Authorization'] = 'Bearer $token'; + handler.next(options); + }, + onError: (e, handler) { + if (e.response?.statusCode == 401) onUnauthorized?.call(); + handler.next(e); + }, + )); + } + + Future post(String path, Map body, + {T Function(dynamic data)? parse}) async { + final res = await _dio.post(path, data: body); + return _unwrap(res, parse); + } + Future get(String path, {Map? query, T Function(dynamic data)? parse}) async { ... } + Future upload(String path, Map fields, String fileField, String filePath, {String Function(dynamic)? parse}) async { ... } + + T _unwrap(Response res, ...) { + final code = res.data['code'] as int; + final message = res.data['message'] as String? ?? ''; + if (code != 0) throw ApiException(code, message); + return parse?.call(res.data['data']) ?? res.data['data'] as T; + } + + VoidCallback? onUnauthorized; +} +``` + +- [ ] **Step 4: TokenStorage**(shared_preferences 封装:token 读写 + 清空) + +- [ ] **Step 5: 测试通过** + `flutter analyze` + **Commit** + +--- + +### Task 3: 认证(登录页 + 状态) + +**Files:** +- Create: `lib/core/auth/auth_provider.dart` +- Create: `lib/features/auth/login_page.dart` +- Test: `test/core/auth/auth_provider_test.dart` + +- [ ] **Step 1: 写失败测试**(ProviderContainer:登录成功 → token 持久化 + 状态 authenticated;失败 → 状态 error 携带 message) + +- [ ] **Step 2: 实现 auth_provider.dart**(AsyncNotifier:login(account, password) → ApiClient.post('/user/login') → 存 token) + +- [ ] **Step 3: login_page.dart**:账号/密码输入 + 登录按钮 + 加载态 + 错误提示(fluttertoast);登录成功 go_router push 替换到 home + +- [ ] **Step 4: 测试通过** + **Commit** + +--- + +### Task 4: 4 Tab 主框架 + +**Files:** +- Create: `lib/features/home/home_page.dart`(BottomNavigationBar + IndexedStack 4 Tab) +- Create: `lib/features/profile/profile_page.dart`(占位) +- Create: `lib/features/wardrobe/wardrobe_page.dart`(占位) +- Create: `lib/features/outfit/outfit_page.dart`(占位) +- Create: `lib/features/commercial/commercial_page.dart`(占位) + +- [ ] **Step 1: 实现 4 Tab 框架**:底部导航(我的形象/我的衣橱/穿搭方案/门店电商)+ 图标 + IndexedStack 保状态 + +- [ ] **Step 2: Widget 测试**:切 Tab 显示对应页面 + +- [ ] **Step 3: 测试通过** + **Commit** + +--- + +### Task 5: 拍照引导 + 照片上传(Tab1) + +**Files:** +- Create: `lib/features/profile/photo_guide_page.dart`(引导 + 拍摄) +- Create: `lib/features/profile/photo_guide_item.dart`(单张拍摄卡片) +- Create: `lib/features/profile/photo_upload_provider.dart` +- Create: `lib/features/profile/profile_page.dart`(集成:照片齐备度展示 + 上传入口) +- Test: `test/features/profile/photo_upload_provider_test.dart` + +- [ ] **Step 1: 写失败测试**(provider:mock ApiClient.upload 成功 → 状态更新;失败 → error) + +- [ ] **Step 2: 实现 provider**:4 个类型照片上传(type 1-4),逐张上传成功后标记完成;本地压缩(`image_picker` 自带 maxWidth: 2048) + +- [ ] **Step 3: photo_guide_page.dart**:4 张卡片(大头照/全身正面/侧面/背面,各含拍摄示例说明文案 + 相机按钮 image_picker 拍摄)+ 上传进度 + 完成态跳转 + +- [ ] **Step 4: profile_page.dart**:显示 4 张照片状态(已传/未传)+ "进入拍摄引导" 按钮 + 身形参数入口(Task 6) + +- [ ] **Step 5: 测试通过** + **Commit** + +--- + +### Task 6: 身形参数 + 化身状态(Tab1) + +**Files:** +- Create: `lib/features/profile/body_tune_page.dart`(滑杆微调) +- Create: `lib/features/profile/body_provider.dart` +- Create: `lib/features/profile/avatar_provider.dart` +- Test: `test/features/profile/avatar_provider_test.dart` + +- [ ] **Step 1: 写失败测试**(avatar provider:get → build → 状态 done + glbUrl 非空) + +- [ ] **Step 2: body_tune_page.dart**:身高(145-200cm)/体重/肤色(1-5) 滑杆,保存 → POST /body-measurement/save + +- [ ] **Step 3: avatar_provider.dart**:页面进入时 GET /avatar/get → 无记录则提示先传照片 → POST /avatar/build → 轮询 build_status(每 2s × 最多 30 次)→ done 后展示 glb_url + +- [ ] **Step 4: profile_page.dart** 集成:身形参数卡片 + 化身构建按钮 + 构建状态展示 + +- [ ] **Step 5: 测试通过** + **Commit** + +--- + +### Task 7: 衣橱管理(Tab2) + +**Files:** +- Create: `lib/features/wardrobe/wardrobe_provider.dart` +- Create: `lib/features/wardrobe/wardrobe_page.dart`(网格) +- Create: `lib/features/wardrobe/wardrobe_upload_page.dart`(上传表单:照片 + 分类 + 季节 + 风格标签) +- Test: `test/features/wardrobe/wardrobe_provider_test.dart` + +- [ ] **Step 1: 写失败测试**(provider:upload 成功追加列表;delete 移除;list 加载) + +- [ ] **Step 2: 实现 provider** + 上传表单页(DropdownButton 分类[上衣/下装/鞋/配饰] + 季节 + 标签输入) + +- [ ] **Step 3: 网格页**:GridView 服装照片 + 长按删除确认 + 空态引导("衣橱空空如也,去上传第一件衣服吧") + +- [ ] **Step 4: 测试通过** + **Commit** + +--- + +### Task 8: 生成入口(Tab3 上半) + +**Files:** +- Create: `lib/features/outfit/generate_page.dart` +- Create: `lib/features/outfit/outfit_generate_provider.dart` +- Test: `test/features/outfit/outfit_generate_provider_test.dart` + +- [ ] **Step 1: 写失败测试**(provider:generate 成功返回 taskId;开始轮询状态) + +- [ ] **Step 2: 生成入口页**:日期范围(showDateRangePicker)+ 地点(TextField + 定位按钮[geolocator 可选,v1 手动输入])+ "生成穿搭" 按钮(校验:日期非空/地点非空/衣橱非空提示) + +- [ ] **Step 3: 生成确认后** → 跳转任务状态页(Task 9) + +- [ ] **Step 4: 测试通过** + **Commit** + +--- + +### Task 9: 任务轮询 + 方案流(Tab3 核心) + +**Files:** +- Create: `lib/features/outfit/task_status_page.dart` +- Create: `lib/features/outfit/plan_flow_page.dart`(PageView 横滑) +- Create: `lib/features/outfit/plan_provider.dart` +- Test: `test/features/outfit/plan_provider_test.dart` + +- [ ] **Step 1: 写失败测试**(provider:task 轮询 done → 加载 plan list;failed → error) + +- [ ] **Step 2: task_status_page.dart**:轮询 GET /outfit/task/status(Timer.periodic 3s),状态文案映射(pending=准备中/planning=方案规划中/scoring=方案评分中/rendering=效果图生成中/done=完成/failed=失败+error 展示);done → 跳转方案流;失败显示重试 + +- [ ] **Step 3: plan_provider.dart**:GET /outfit/plan/list + detail(含 items/images/hairstyle) + +- [ ] **Step 4: plan_flow_page.dart**:PageView.builder 每页一张方案卡片: + - 3D 化身区(AvatarViewer,Task 10) + - 方案摘要(标题/评分 Chip/来源标签:衣橱组合=蓝 / AI 推荐=橙) + - 发型切换(横排发型 chip)+ 发色取色(HSV 面板) + - 底部条目列表(slot 图标 + 名称 + 描述;推荐条目带"查看商品"入口,v1 占位) + - "选为主方案" 按钮 → POST select-main → 效果图页(Task 11) + +- [ ] **Step 5: 测试通过** + **Commit** + +--- + +### Task 10: AvatarViewer 抽象 + three_dart 实现 + +**Files:** +- Create: `lib/features/outfit/viewer/avatar_viewer.dart`(接口 + controller) +- Create: `lib/features/outfit/viewer/avatar_viewer_three_dart.dart`(three_dart 实现) +- Create: `lib/features/outfit/viewer/avatar_viewer_placeholder.dart`(降级占位:头像图 + 手势提示) +- Create: `lib/features/outfit/viewer/viewer_factory.dart` +- Test: `test/features/outfit/viewer/avatar_viewer_placeholder_test.dart` + +- [ ] **Step 1: 定义抽象接口** + +```dart +/// 渲染层抽象:业务代码只依赖此接口,flutter_scene 进 stable 后提供第二实现 +abstract class AvatarViewerController { + Future loadAvatar(String glbUrl); // 头像+体型主体 + Future loadHairstyle(String glbUrl); // 发型层 + void setHairColor(Color color); // 发色 PBR baseColor + void rotateBy(double dx, double dy); + void zoomBy(double scale); + void resetView(); +} + +class AvatarViewer extends StatefulWidget { + final AvatarViewerController Function() controllerFactory; + ... +} +``` + +- [ ] **Step 2: 实现 three_dart 版**:`three_dart` + `three_dart_jsm` GLTFLoader 加载 GLB → Scene 显示(DirectionalLight + AmbientLight + OrbitControls 式手动手势:onPanUpdate → rotateBy 旋转 Object3D,onScaleUpdate → zoomBy 缩放相机/模型)—— 参考 `three_js_advanced_loaders` 示例;GLB 本地缓存(Task 13) + +- [ ] **Step 3: 降级实现**:加载失败(网络/格式)→ placeholder(用户大头照 Image + "3D 模型加载失败,显示照片效果" 文案) + +- [ ] **Step 4: viewer_factory.dart**:`AvatarViewer createViewer()` → three_dart 实现(有 GLB url 时)/ placeholder(无 url 时) + +- [ ] **Step 5: Widget 测试**(placeholder 渲染)+ `flutter analyze` + **Commit** + +--- + +### Task 11: 效果图查看 + 方案详情(Tab3 下半) + +**Files:** +- Create: `lib/features/outfit/effect_image_page.dart` +- Create: `lib/features/outfit/plan_detail_provider.dart` +- Test: `test/features/outfit/plan_detail_provider_test.dart` + +- [ ] **Step 1: 写失败测试**(provider:select-main → 轮询 detail.images 直到 3 张完成) + +- [ ] **Step 2: select-main 后跳转效果图页**:3 视角(正面/侧面/背面)Tab/滑块切换,cached_network_image 加载,生成中展示进度(轮询 plan/detail images status) + +- [ ] **Step 3: 方案详情页**(从列表进入):完整 detail 渲染(items 图片/名称/描述 + 效果图 + 收藏按钮 POST review) + +- [ ] **Step 4: 测试通过** + **Commit** + +--- + +### Task 12: 门店/电商(Tab4,MVP 列表展示) + +**Files:** +- Create: `lib/features/commercial/store_page.dart`(附近门店列表) +- Create: `lib/features/commercial/store_provider.dart` +- Create: `lib/features/commercial/subscription_page.dart`(订阅占位) +- Test: `test/features/commercial/store_provider_test.dart` + +- [ ] **Step 1: 写失败测试**(provider:GET /partner-store/list 解析列表) + +- [ ] **Step 2: 门店列表页**:类型筛选 chip(形象设计/服装门店)+ ListView 卡片(名称/类型/地址/距离占位) + +- [ ] **Step 3: 订阅页**:标准版/Pro 权益卡片 + "开通"按钮(v1 占位 toast"支付功能开发中") + +- [ ] **Step 4: 测试通过** + **Commit** + +--- + +### Task 13: 缓存与性能 + +**Files:** +- Modify: `lib/core/storage/`(新增 glb_cache.dart) +- Create: `lib/core/storage/glb_cache.dart`(GLB 本地 LRU) +- Test: `test/core/storage/glb_cache_test.dart` + +- [ ] **Step 1: 写失败测试**(缓存:put → hit;LRU 超限淘汰;过期清理) + +- [ ] **Step 2: 实现**:文件缓存目录 `getApplicationSupportDirectory()/glb_cache/`,key=url hash,256MB 上限(超限按最后访问时间淘汰);方案流预取下一页 GLB + +- [ ] **Step 3: 测试通过** + **Commit** + +--- + +### Task 14: 错误处理与加载状态组件 + +**Files:** +- Create: `lib/shared/widgets/loading_view.dart`(骨架屏) +- Create: `lib/shared/widgets/error_view.dart`(错误 + 重试) +- Create: `lib/shared/widgets/empty_view.dart`(空态 + 引导动作) + +- [ ] **Step 1: 三个组件**(loading 骨架、error 文案+重试回调、empty 图标+文案+按钮) + +- [ ] **Step 2: 接入**:profile/wardrobe/outfit/commercial 各页加载态/空态/错误态替换 + +- [ ] **Step 3: Widget 测试** + **Commit** + +--- + +### Task 15: 集成冒烟(联调 slogan-agent) + +- [ ] **Step 1: 本地起 slogan-agent**(`go run main.go`,3007 端口,mock 效果图供应商) + +- [ ] **Step 2: App 联调**(iOS 模拟器 + Android 模拟器各一遍): + 1. 登录 → 2. 拍照引导上传 4 张(相册选图代替拍摄)→ 3. 身形参数 → 4. 构建化身 → 5. 衣橱上传 3+ 件 → 6. 生成穿搭(日期+地点)→ 7. 任务轮询 → 8. 方案流(3D 查看/发型切换/横滑)→ 9. 选主方案 → 10. 效果图 3 视角查看 + +- [ ] **Step 3: 修复联调问题**(网络/字段/状态映射),`flutter analyze` 0 错误 + +- [ ] **Step 4: Commit** `git commit -m "feat: mvp complete, verified against slogan-agent"` + +--- + +## Self-Review 备注 + +- 后端字段命名 snake_case(JSON),Dart 侧解析用 map 取值避免命名映射负担 +- 效果图生成在后端为异步任务,App 端轮询 plan/detail 的 images 状态(rendering→done) +- three_dart 若 iOS 渲染异常(着色器兼容),降级路径 placeholder 保证 MVP 可用;GLB 渲染验证放 Task 10 明确检查 diff --git a/app/docs/superpowers/specs/2026-07-31-commerce-monetization-design.md b/app/docs/superpowers/specs/2026-07-31-commerce-monetization-design.md new file mode 100644 index 0000000..858e6e4 --- /dev/null +++ b/app/docs/superpowers/specs/2026-07-31-commerce-monetization-design.md @@ -0,0 +1,204 @@ +# 商业化四支柱设计(客户端)· slogan-app + +> **目标:** 以「个人形象设计」为主流程,把四支柱收入入口从「方案/单品」里长出来:VIP 会员充值、穿山甲广告激励、线下门店引流(美团联盟)、线上商品(京东/淘宝 CPS)。不做泛化场景广场。 +> **核心原则:** 客户端零硬编码业务配置;所有商业化入口以「接口可用」为开关 —— 后端未配置 key 时接口报错/返回空 → App 自动隐藏对应入口,主流程(生成方案 → 查看)不受影响。 + +## 1. 信息架构改造 + +``` +改造前:/commercial 孤立 tab(会员占位卡 + 门店列表) +改造后: + ├─ /commercial 会员中心(P0) 会员状态卡 · 套餐 · 广告激励 · 合作门店 · 最近优惠 + ├─ /plan-viewer 方案页(P0) 发型卡「做同款发型」· 穿衣清单「买同款/到店试穿」· 场合「延伸优惠」 + ├─ /wardrobe 衣橱(P1) 长按「找升级款」 + └─ 全局(P2) 开屏广告 / 信息流广告位(效果图页底部) +``` + +**开关原则**:每个商业化入口包一层 `commercialGate`(统一查询后端配置/捕获接口错误),未开通 → 按钮不渲染。启动时不做额外网络调用,**入口可见性由首次打开该页面的接口结果决定**(零新增请求)。 + +## 2. 支柱 A:会员中心(/commercial 重构) + +### 2.1 页面结构 + +``` +/ commercial(ConsumerStatefulWidget,保留现有门店列表与类型筛选) + ├─ 会员状态卡:头像/会员名 · is_vip · expire_at(倒计时)· 权益 chips(无限效果图/优先AI/返现1.5x/门店折扣) + │ ├─ 未开通 →「立即开通」按钮 → 打开套餐 bottom sheet + │ └─ 已开通 →「会员码」按钮(store_discount 到店出示,P1) + ├─ 广告激励卡(非会员时显示): + │ ├─ 看视频 +1 效果图(每日 2 次,剩余次数显示) + │ └─ 看视频 1 天体验会员(每日 1 次) + ├─ 合作门店列表(现有 /partner-store/list 品牌合作门店保留,含「会员价」角标 P1; + │ 美团联盟到店券走 /cps/product/list,两条链路互不替换) + └─ 最近优惠(/cps/my/recent,P1,未开通则隐藏整卡) +``` + +### 2.2 支付时序(App 侧) + +``` +点击套餐 → POST /member/order/create {plan_id} → 返回 {order_no, pay_url} + → 打开 PayWebViewPage(内嵌 webview_flutter,iOS 用 WKWebView) + → WebView 加载 pay_url,监听 url 变化(payment 完成跳转) + → 同时 Timer 每 2s GET /member/order/status?order_no=... 轮询(上限 60s) + → status=paid → 关闭 WebView → 刷新 memberProvider → 成功 toast + → 超时 → 提示「支付结果确认中,请稍后在会员中心查看」(状态以服务端为准) +``` + +- 支付页依赖 `webview_flutter`(P0 引入,国内 App 常规做法);若平台编译受限,降级方案:`url_launcher` 唤起系统浏览器支付,返回 App 后仍走轮询(**P0 默认 url_launcher 方案,webview_flutter 留 P1**,减小依赖风险) + +### 2.3 状态与 Provider + +| Provider | 类型 | 数据 | 接口 | +|---|---|---|---| +| `memberProvider` | AsyncNotifier | isVip, expireAt, planName, benefits[] | GET /member/status | +| `memberPlanProvider` | FutureProvider | 套餐列表 | GET /member/plan/list | +| `orderCreateProvider` | Notifier.family(planId) | orderNo, payUrl | POST /member/order/create | +| `orderStatusProvider` | FutureProvider.family(orderNo) | status | GET /member/order/status | + +- `memberProvider` 缓存登录态期间;`refresh()` 在支付成功、广告领奖后调用 +- 权益 chips 文案从套餐 `features` JSON 解析 → 本地文案 map(`effect_unlimited→无限效果图` 等) + +## 3. 支柱 B:广告激励(lib/core/ads/ 新建) + +### 3.1 抽象(供应商隔离) + +```dart +// lib/core/ads/ads_provider.dart +abstract class AdsService { + bool get enabled; // appid 未配置 → false,App 隐藏广告入口 + Future showRewarded(); // 激励视频,返回是否完整观看 +} + +// lib/core/ads/pangle_ads_service.dart —— 穿山甲实现(P1 接入 SDK,P0 仅接口 + mock) +// P0:MockAdsService —— 本地模拟 3 秒「播放」返回 true,保证主链路可开发可测 +``` + +- **初始化**:`AdsConfig`(AppConfig 常量:pangleAppId 默认空)→ `adsServiceProvider` 单例 +- **降级**:appid 空 / SDK 初始化失败 → `enabled=false` → 会员中心激励卡、广告位全部不渲染 + +### 3.2 激励流程(服务端防刷,客户端只展示) + +``` +点击「看视频」→ adsService.showRewarded() + → 完整观看 → POST /ad/reward/claim {ad_type: effect_extra | vip_trial} + → 成功 → 展示奖励弹窗(+1 效果图 / 1 天体验会员)→ refresh 会员卡 + → 失败(限频/未配置)→ 隐藏式错误(后端返回「今日次数已用完」→ 入口变灰) +``` + +- 剩余次数展示:`/ad/reward/claim` 响应带回 `{reward: {ad_type, remaining_today}}`,App 本地缓存当日显示;或 P0 简单化 —— 仅在领取失败时提示次数用完 +- **效果图配额联动**:后端限额 = 基础 3 + 当日额外次数;App 端文案统一显示「今日剩余 X 次」(P1 后端在生成接口响应中带 `remaining` 字段,P0 保持现状提示) + +### 3.3 广告位(P2,本期只留占位) + +- 开屏广告:`/home` 进入时加载(P2) +- 信息流广告:`/plan-effect` 效果图 GridView 底部插一条(P2) + +## 4. 支柱 C/D:方案驱动 CPS 入口 + +### 4.1 方案页(/plan-viewer)三处入口 + +| 位置 | 按钮 | scene | 请求 | 跳转 | +|---|---|---|---|---| +| 发型卡尾部 | 「做同款发型」 | haircut | GET /cps/plan/recommend {plan_id, scene: haircut} | /cps-product-list(美团丽人/理发券) | +| 穿衣清单每项 trailing | 「买同款」 | item_buy | 京东搜索单品名 | /cps-product-list(电商商品) | +| 穿衣清单每项 trailing | 「到店试穿」 | item_upgrade | 美团服装类目 | /cps-product-list(门店券) | +| 场合卡(P1,detail 有 occasion 字段后) | 「延伸优惠」 | occasion | 映射表推荐 | /cps-product-list | + +- **可见性**:推荐接口返回空列表 / 接口报错(CPS 未开通)→ 该按钮隐藏;发型卡无发型名(默认发型)→ 隐藏「做同款」 +- **交互**:推荐返回列表 → push `/cps-product-list?source=&category_code=&city=`(带标题「做同款发型 · 丽人」);列表点击 → POST /cps/product/link {product_id, scene, plan_id} → 打开 deeplink +- **打开方式**:`url_launcher`(系统浏览器,携 pid 转链 URL;电商商品优先深链 App,P1 增强) + +### 4.2 衣橱升级款(/wardrobe 长按菜单加一项) + +``` +长按衣物卡 → 菜单:删除 / 找升级款 +「找升级款」→ GET /cps/wardrobe/upgrade {item_id} → /cps-product-list(标题「升级款 · 上衣」) +``` + +### 4.3 商品列表页 /cps-product-list + +``` +AppBar 标题(由入口传入)+ 分类 chips(/cps/category/list,P1) +商品卡:封面图(AppConfig.resolveUrl)· 名称 · 价格(分→元)· 店铺 · 佣金标 +上滑加载更多(page 分页,has_more 判定) +点击 → POST /cps/product/link → deeplink → url_launcher 打开 +``` + +### 4.4 Provider + +| Provider | 类型 | 接口 | +|---|---|---| +| `cpsRecommendProvider` | FutureProvider.family((planId, scene)) | GET /cps/plan/recommend | +| `cpsProductProvider` | AsyncNotifierProvider.family((source, categoryCode, city)) | GET /cps/product/list 分页 | +| `cpsUpgradeProvider` | FutureProvider.family(itemId) | GET /cps/wardrobe/upgrade | +| `cpsLinkAction` | Notifier | POST /cps/product/link | + +## 5. 会员权益在客户端的呈现 + +| 权益 | 客户端表现 | +|---|---| +| effect_unlimited | 效果图页配额提示隐藏(「每日限 3 次」文案在 isVip 时不显示) | +| ai_priority | 生成页状态文案「VIP 优先排队」(MVP 仅文案) | +| cps_commission_x15 | 商品卡佣金标签「返现加成 1.5x」(VIP 用户) | +| store_discount | 门店列表「会员价」角标 + 会员码页(P1) | + +## 6. 配置(AppConfig 常量,均为本地编译期配置) + +```dart +// lib/core/config/app_config.dart 追加 +static const String pangleAppId = ''; // 穿山甲 AppId,空 = 广告功能关闭 +static const bool cpsEnabled = true; // 兜底开关;最终以接口结果为准 +``` + +- 商业化开关**最终以接口为准**(后端 config 未配置 → 接口错误 → App 隐藏入口),本地常量只控制「广告 SDK 是否初始化」 + +## 7. API 映射与错误处理 + +| 后端接口 | 客户端方法 | 错误处理 | +|---|---|---| +| GET /member/status | memberProvider.build | 401 → 重登;其他 → 默认非会员 | +| GET /member/plan/list | memberPlanProvider | 错误 → 套餐区显示「暂未开通」 | +| POST /member/order/create | 下单动作 | 错误 → toast「支付未开通」 | +| GET /member/order/status | 轮询 | 404/错误 → 结束轮询提示稍后查看 | +| POST /member/order/notify | -(后端回调,客户端不参与) | - | +| POST /ad/reward/claim | 领奖动作 | 错误 → toast 后端 message(限频等) | +| GET /cps/plan/recommend | cpsRecommendProvider | 空/错误 → 入口隐藏 | +| GET /cps/product/list | cpsProductProvider | 空/错误 → 列表空态「暂未开放」 | +| POST /cps/product/link | cpsLinkAction | 错误 → toast「跳转失败」 | +| GET /cps/my/recent | recentProvider | 错误 → 整卡隐藏 | + +- 所有新接口走现有 `apiClientProvider`(Dio 封装,自动带 token),无需改动网络层 + +## 8. 路由与依赖变更 + +``` +/lib/main.dart 新增 route: + /cps-product-list(extra: CpsListArgs{title, source, categoryCode, city}) + /pay-webview(extra: PayWebviewArgs{orderNo, payUrl},P1 webview_flutter) +依赖(P0):url_launcher(打开 deeplink / 系统浏览器支付) +依赖(P1):webview_flutter(内嵌收银台)、穿山甲 SDK(pangle 插件) +``` + +- 穿山甲 SDK Flutter 插件社区维护不稳定 → **P1 先验证 iOS/Android 编译,若插件不可用则改为原生 module 接入(P2)**;P0 用 MockAdsService 保证业务链路先闭环 + +## 9. 分期与对齐 + +| 分期 | 客户端内容 | 依赖 | +|---|---|---| +| **P0** | /commercial 重构会员中心(状态/套餐/下单/轮询)+ MockAds + 广告激励入口 + 方案页「做同款发型/买同款/到店试穿」+ /cps-product-list + url_launcher + cps 入口隐藏逻辑 | 后端 P0(会员+广告接口) | +| **P1** | 场合卡「延伸优惠」+ 衣橱「找升级款」+ 最近优惠 + 会员码/门店折扣角标 + webview_flutter 内嵌收银台 + 穿山甲 SDK 接入(替换 Mock) | 后端 P1(CPS 引擎) | +| **P2** | 开屏/信息流广告位 + 穿山甲插件不可用时的原生 module 兜底 + 收益/返现展示 | 后端 P2 | + +## 10. 合规(客户端侧) + +- **iOS 充值**:App Store 虚拟商品政策风险 → iOS 端隐藏会员套餐充值入口(`Platform.isIOS` 判断),保留广告激励 + 门店引流;「会员价」到店核销不受影响 +- **广告**:隐私政策文案补充穿山甲 SDK 信息收集披露;提供「个性化广告关闭」设置项(穿山甲 SDK 提供,P1) +- **跳转**:CPS 转链一律走联盟 deeplink,不在 App 内二次改链 + +## 11. 开发规范约束(沿用 slogan-app 现有规范) + +- Riverpod 3:AsyncNotifier/Notifier/FutureProvider.family;provider 文件放 `lib/features//_provider.dart` +- 新页面组件放 `lib/features/commercial/`(会员中心)、`lib/features/cps/`(商品列表);广告抽象放 `lib/core/ads/` +- 图片 URL 一律 `AppConfig.resolveUrl()`;价格字段分 → 元转换写死规则(`(fen / 100).toStringAsFixed(0)`) +- 所有「隐藏入口」逻辑集中在入口组件内一行判定,不扩散到业务逻辑 +- 新页面必配 empty/error/loading 三态(复用 shared/widgets) diff --git a/app/docs/superpowers/specs/2026-07-31-slogan-app-design.md b/app/docs/superpowers/specs/2026-07-31-slogan-app-design.md new file mode 100644 index 0000000..fc4b1e3 --- /dev/null +++ b/app/docs/superpowers/specs/2026-07-31-slogan-app-design.md @@ -0,0 +1,174 @@ +# slogan-app 客户端设计方案 + +> 日期:2026-07-31 +> 关联:slogan-agent 服务端方案(Go + GoFrame)见 slogan-agent 仓库对应文档 + +## 1. 项目概述 + +slogan 是一个"人形象设计"应用:用户上传大头照和全身多角度照片、维护个人服装资产(衣橱),指定日期范围和地点后一键生成最适合的穿搭方案(含发型、发色、服装穿搭),方案以 3D 化身 + 2D 效果图双形态呈现,手指滑动切换方案与查看角度。 + +本仓库为客户端(slogan-app),Flutter 实现,一次编写双端(iOS/Android)运行,追求类原生用户操作体验。 + +## 2. 技术选型 + +| 决策点 | 选择 | 理由 | +|--------|------|------| +| 语言/框架 | Flutter (Dart) | 一次编写双端;自绘引擎保证体验一致;动画/手势流畅 | +| 状态管理 | Riverpod | 类型安全、可测试,适合表单/异步任务/缓存类状态 | +| 网络层 | Dio + 拦截器 | JWT 自动注入、统一响应解析 `{code,message,data}`、超时重试 | +| 3D 渲染 | three_dart v1 + 渲染抽象层 | 稳定发布版(v0.3.0,GLTF/GLB loader,纯 Dart 双端);flutter_scene 进 stable 后经抽象层无缝替换 | +| 图片缓存 | cached_network_image | 效果图/服装照片懒加载缓存 | +| 拍照/相册 | camera + image_picker | 大头照/全身多角度拍摄引导 | +| 本地存储 | shared_preferences | token/偏好;身形微调参数本地实时生效 | +| 手势 | 原生 GestureDetector 组合 | 方案横滑切换(PageView)+ 化身旋转拖拽/捏合缩放 + 惯性滚动 | + +**3D 渲染层风险控制**:`AvatarViewer` 接口抽象(loadGLB / setHairstyle / setHairColor / rotate / zoom / switchOutfit),v1 实现为 three_dart;flutter_scene(官方,基于 Flutter GPU)进入 stable 后提供第二实现,业务代码零改动。 + +## 3. 项目结构 + +``` +slogan-app/ +├── lib/ +│ ├── main.dart # 入口 + 路由 + 主题 +│ ├── core/ +│ │ ├── network/ # Dio 封装:JWT 拦截器/统一响应/错误码映射 +│ │ ├── auth/ # 登录页 + token 管理(账号密码,复用服务端 /user/login) +│ │ ├── config/ # API 地址/环境 +│ │ ├── storage/ # shared_preferences 封装(token/偏好) +│ │ └── router/ # go_router(未登录 → 登录页) +│ ├── features/ +│ │ ├── profile/ # Tab1 我的形象 +│ │ │ ├── photo_guide/ # 拍照引导页(大头照/全身多角度拍摄指引) +│ │ │ ├── avatar_viewer/ # 3D 化身查看(AvatarViewer 抽象层实现) +│ │ │ └── body_tune/ # 滑杆微调(身高/胖瘦/肤色,本地实时) +│ │ ├── wardrobe/ # Tab2 我的衣橱 +│ │ │ ├── upload/ # 服装照片上传 + 分类标签 +│ │ │ └── item_grid/ # 服装资产网格/详情 +│ │ ├── outfit/ # Tab3 穿搭方案 +│ │ │ ├── generate/ # 生成入口(日期范围选择器/地点选择) +│ │ │ ├── task_status/ # 生成任务进度(轮询) +│ │ │ ├── plan_flow/ # 方案流(PageView 横滑切换方案) +│ │ │ ├── viewer_3d/ # 3D 方案查看(发型切换/发色取色/旋转/捏合) +│ │ │ ├── effect_images/ # 2D 效果图(正面/侧面/背面切换) +│ │ │ └── review/ # 收藏/反馈 +│ │ └── commercial/ # Tab4 门店/电商 +│ │ ├── stores/ # LBS 附近门店(形象设计/服装) +│ │ ├── leads/ # 导流订单/到店核销 +│ │ ├── products/ # CPS 商品跳转 +│ │ └── subscription/ # 会员订阅 +│ └── shared/ # 组件/主题/工具(日期选择器/评分展示等) +├── assets/ +│ ├── avatars/ # 模板/发型 GLB 缓存(与后端 assets 对应,v1 从服务端拉取) +│ └── images/ # 图标/占位图 +└── test/ # 单元/widget 测试 +``` + +## 4. 页面与交互设计(4 Tab 主框架) + +``` +┌─────────────────────────────────────────┐ +│ Tab 架构(底部导航,类原生体验) │ +│ ┌────────┬────────┬────────┬─────────┐ │ +│ │ 我的形象│ 我的衣橱│ 穿搭方案 │ 门店/电商│ │ +│ └────────┴────────┴────────┴─────────┘ │ +└─────────────────────────────────────────┘ +``` + +### Tab1 我的形象 +- 首次进入:拍照引导流程(大头照 + 全身正面/侧面/背面 4 张,含姿势示例图) +- 化身构建进度(后端任务轮询) +- 3D 化身查看:单指拖拽旋转、双指捏合缩放 +- 滑杆微调:身高/胖瘦/肤色,调参即时反映(本地计算,GLB 缩放参数 + 肤色材质) + +### Tab2 我的衣橱 +- 服装照片上传(多选)+ 分类(上衣/下装/鞋/配饰)+ 季节/风格标签自动识别(后端 AI 辅助,本地可手改) +- 网格展示 + 详情编辑/删除 + +### Tab3 穿搭方案(核心) +- 生成入口:日期范围选择器 + 地点选择(定位/搜索) +- 生成任务进度页(规则规划 → 方案评分 → 完成) +- 方案流:PageView 左右滑动切换 3 套方案;每套方案卡片 = 3D 化身 + 方案摘要(评分/来源标签:衣橱组合/AI 推荐) +- 3D 查看:发型点击切换 + 发色取色盘(HSV 调色实时渲染)+ 拖拽旋转 + 捏合缩放 +- 主方案选定 → 触发 2D 效果图生成(3 视角:正面/侧面/背面,切换查看) +- 方案条目明细:每件服装(衣橱照片 或 电商商品图)+ 单品操作(跳转商品/加入衣橱) +- 收藏/反馈 → 回流 Agent 优化下次生成 + +### Tab4 门店/电商 +- LBS 附近合作门店(类型筛选:形象设计/服装),导航/预约/到店核销 +- CPS 商品推荐列表(跳转电商) +- 会员订阅入口与权益展示 + +## 5. 3D 查看器设计(核心模块) + +### AvatarViewer 抽象接口 + +```dart +abstract class AvatarViewer extends StatelessWidget { + // 由具体实现提供(three_dart v1 / flutter_scene v2) +} + +abstract class AvatarViewerController { + Future loadAvatar(AvatarSpec spec); // 头像 + 体型 + 皮肤贴图 + Future loadHairstyle(String glbUrl); // 加载发型 + void setHairColor(Color color); // 发色(PBR baseColor 调色) + void setOutfit(List 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//`,组件 `shared/`;文件 snake_case,类 PascalCase +- 测试随功能同步编写(TDD:先写失败测试再实现) diff --git a/app/ios/.gitignore b/app/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/app/ios/.gitignore @@ -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 diff --git a/app/ios/Flutter/AppFrameworkInfo.plist b/app/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..391a902 --- /dev/null +++ b/app/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + + diff --git a/app/ios/Flutter/Debug.xcconfig b/app/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/app/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/app/ios/Flutter/Release.xcconfig b/app/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/app/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/app/ios/Runner.xcodeproj/project.pbxproj b/app/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..5afece9 --- /dev/null +++ b/app/ios/Runner.xcodeproj/project.pbxproj @@ -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 = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 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 = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 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 = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* 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 = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 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 = ""; + }; +/* 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 = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* 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 */; +} diff --git a/app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..c3fedb2 --- /dev/null +++ b/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/ios/Runner.xcworkspace/contents.xcworkspacedata b/app/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/app/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/app/ios/Runner/AppDelegate.swift b/app/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..c30b367 --- /dev/null +++ b/app/ios/Runner/AppDelegate.swift @@ -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) + } +} diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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" + } +} diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..6ed2d93 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cd7b00 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..fe73094 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..502f463 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e9f5fea Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..0467bf1 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -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" + } +} diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -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. \ No newline at end of file diff --git a/app/ios/Runner/Base.lproj/LaunchScreen.storyboard b/app/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/app/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/ios/Runner/Base.lproj/Main.storyboard b/app/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/app/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/ios/Runner/Info.plist b/app/ios/Runner/Info.plist new file mode 100644 index 0000000..638e7c3 --- /dev/null +++ b/app/ios/Runner/Info.plist @@ -0,0 +1,70 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Slogan App + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + slogan_app + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/app/ios/Runner/Runner-Bridging-Header.h b/app/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/app/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/app/ios/Runner/SceneDelegate.swift b/app/ios/Runner/SceneDelegate.swift new file mode 100644 index 0000000..b9ce8ea --- /dev/null +++ b/app/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { + +} diff --git a/app/ios/RunnerTests/RunnerTests.swift b/app/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/app/ios/RunnerTests/RunnerTests.swift @@ -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. + } + +} diff --git a/app/lib/core/ads/ads_service.dart b/app/lib/core/ads/ads_service.dart new file mode 100644 index 0000000..84a70ad --- /dev/null +++ b/app/lib/core/ads/ads_service.dart @@ -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 showRewarded(); + + /// 横幅/信息流广告位(Mock 返回占位卡,P1 换原生广告组件) + Widget showBanner(BuildContext context); +} + +/// 本地模拟广告(约 1 秒"播放"后返回完整观看) +class MockAdsService implements AdsService { + @override + bool get enabled => AppConfig.pangleAppId.isNotEmpty; + + @override + Future 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((ref) { + // P1:AppConfig.pangleAppId 非空时替换为 PangleAdsService(穿山甲 SDK 实现) + return MockAdsService(); +}); diff --git a/app/lib/core/auth/auth_provider.dart b/app/lib/core/auth/auth_provider.dart new file mode 100644 index 0000000..9ebc6d6 --- /dev/null +++ b/app/lib/core/auth/auth_provider.dart @@ -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((ref) => TokenStorage()); + +final apiClientProvider = Provider((ref) { + final client = ApiClient(tokenStorage: ref.watch(tokenStorageProvider)); + client.onUnauthorized = () { + ref.read(authProvider.notifier).logout(); + }; + return client; +}); + +class AuthNotifier extends AsyncNotifier { + @override + Future build() async { + final storage = ref.watch(tokenStorageProvider); + await storage.load(); + return AuthState(authenticated: storage.hasToken); + } + + Future login(String account, String password) async { + state = const AsyncLoading(); + try { + final client = ref.read(apiClientProvider); + final data = await client.post>('/user/login', { + 'account': account, + 'password': password, + }); + final token = data['token'] as String? ?? ''; + final user = data['user'] as Map? ?? {}; + 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 logout() async { + await ref.read(tokenStorageProvider).clear(); + state = const AsyncData(AuthState()); + } +} + +final authProvider = + AsyncNotifierProvider(AuthNotifier.new); diff --git a/app/lib/core/config/app_config.dart b/app/lib/core/config/app_config.dart new file mode 100644 index 0000000..d77b6ff --- /dev/null +++ b/app/lib/core/config/app_config.dart @@ -0,0 +1,14 @@ +/// 全局配置 +class AppConfig { + /// 后端地址:iOS 模拟器用 127.0.0.1;Android 模拟器用 10.0.2.2;真机填局域网 IP + static const String baseUrl = 'http://127.0.0.1:3007'; + + /// 后端返回的相对路径(/workspace/...)拼成完整 URL + static String resolveUrl(String path) { + if (path.startsWith('http')) return path; + return '$baseUrl$path'; + } + + /// 穿山甲 App ID(空 = 广告未开通,广告位不渲染) + static const String pangleAppId = ''; +} diff --git a/app/lib/core/network/api_client.dart b/app/lib/core/network/api_client.dart new file mode 100644 index 0000000..1b02423 --- /dev/null +++ b/app/lib/core/network/api_client.dart @@ -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 post(String path, Map body, + {T Function(dynamic data)? parse}) async { + final res = await _dio.post(path, data: body); + return _unwrap(res, parse); + } + + Future get(String path, + {Map? query, T Function(dynamic data)? parse}) async { + final res = await _dio.get(path, queryParameters: query); + return _unwrap(res, parse); + } + + /// multipart 上传:fields 为文本字段,fileField 为文件字段名 + Future upload(String path, Map 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(Response res, T Function(dynamic data)? parse) { + final data = res.data; + if (data is! Map) { + 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; + } +} diff --git a/app/lib/core/network/api_exception.dart b/app/lib/core/network/api_exception.dart new file mode 100644 index 0000000..dbe6ca0 --- /dev/null +++ b/app/lib/core/network/api_exception.dart @@ -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; +} diff --git a/app/lib/core/storage/token_storage.dart b/app/lib/core/storage/token_storage.dart new file mode 100644 index 0000000..2227df7 --- /dev/null +++ b/app/lib/core/storage/token_storage.dart @@ -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 load() async { + final prefs = await SharedPreferences.getInstance(); + _token = prefs.getString(_key); + } + + Future save(String token) async { + _token = token; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_key, token); + } + + Future clear() async { + _token = null; + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_key); + } +} diff --git a/app/lib/features/auth/login_page.dart b/app/lib/features/auth/login_page.dart new file mode 100644 index 0000000..71383de --- /dev/null +++ b/app/lib/features/auth/login_page.dart @@ -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 createState() => _LoginPageState(); +} + +class _LoginPageState extends ConsumerState { + final _accountCtrl = TextEditingController(); + final _passwordCtrl = TextEditingController(); + + @override + void dispose() { + _accountCtrl.dispose(); + _passwordCtrl.dispose(); + super.dispose(); + } + + Future _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 _showRegisterDialog() async { + final account = TextEditingController(); + final password = TextEditingController(); + final name = TextEditingController(); + final result = await showDialog( + 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>('/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('还没有账号?注册新账号'), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/app/lib/features/cps/cps_product_list_page.dart b/app/lib/features/cps/cps_product_list_page.dart new file mode 100644 index 0000000..e1ca0ac --- /dev/null +++ b/app/lib/features/cps/cps_product_list_page.dart @@ -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 createState() => + _CpsProductListPageState(); +} + +class _CpsProductListPageState extends ConsumerState { + 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 _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 _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), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/features/cps/cps_provider.dart b/app/lib/features/cps/cps_provider.dart new file mode 100644 index 0000000..95d528a --- /dev/null +++ b/app/lib/features/cps/cps_provider.dart @@ -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 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>((ref) async { + final api = ref.read(apiClientProvider); + final data = await api.get>('/cps/category/list'); + final list = data['list'] as List? ?? []; + 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> { + CpsProductListNotifier(this.arg); + + final CpsListArgs arg; + int _page = 1; + bool _hasMore = true; + + @override + Future> build() async { + _page = 1; + _hasMore = true; + return _fetch(1); + } + + Future loadMore() async { + if (!_hasMore || state.isLoading) return; + _page += 1; + try { + final next = await _fetch(_page); + state = AsyncData([...?state.value, ...next]); + } catch (_) { + _page -= 1; // 失败回退页码,允许下次重试 + } + } + + Future> _fetch(int page) async { + final api = ref.read(apiClientProvider); + final data = await api.get>('/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? ?? []) + .map((e) => _parseProduct(e as Map)) + .toList(); + } +} + +final cpsProductProvider = AsyncNotifierProvider.family< + CpsProductListNotifier, List, CpsListArgs>( + CpsProductListNotifier.new); + +/// 方案驱动推荐(plan_id + scene) +final cpsRecommendProvider = FutureProvider.family, + ({int planId, String scene})>((ref, args) async { + final api = ref.read(apiClientProvider); + final data = await api.get>('/cps/plan/recommend', + query: {'plan_id': args.planId, 'scene': args.scene}); + return (data['list'] as List? ?? []) + .map((e) => _parseProduct(e as Map)) + .toList(); +}); + +/// 衣橱升级款 +final cpsUpgradeProvider = + FutureProvider.family, int>((ref, itemId) async { + final api = ref.read(apiClientProvider); + final data = await api.get>('/cps/wardrobe/upgrade', + query: {'item_id': itemId}); + return (data['list'] as List? ?? []) + .map((e) => _parseProduct(e as Map)) + .toList(); +}); + +/// 转链动作(POST /cps/product/link,返回 deeplink) +class CpsLinkAction { + Future link( + WidgetRef ref, { + required int productId, + String scene = '', + int planId = 0, + }) async { + final api = ref.read(apiClientProvider); + final data = await api.post>('/cps/product/link', { + 'product_id': productId, + 'scene': scene, + 'plan_id': planId, + }); + return data['deeplink'] as String? ?? ''; + } +} + +final cpsLinkProvider = Provider((ref) => CpsLinkAction()); + +/// 最近优惠(会员中心) +final cpsRecentProvider = FutureProvider>((ref) async { + final api = ref.read(apiClientProvider); + final data = await api.get>('/cps/my/recent'); + return (data['list'] as List? ?? []) + .map((e) => _parseProduct(e as Map)) + .toList(); +}); diff --git a/app/lib/features/home/home_page.dart b/app/lib/features/home/home_page.dart new file mode 100644 index 0000000..a7edeea --- /dev/null +++ b/app/lib/features/home/home_page.dart @@ -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 createState() => _HomePageState(); +} + +class _HomePageState extends ConsumerState { + 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( + 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)'), + ), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/features/member/member_provider.dart b/app/lib/features/member/member_provider.dart new file mode 100644 index 0000000..a74e8ef --- /dev/null +++ b/app/lib/features/member/member_provider.dart @@ -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 benefits; + + const MemberInfo({ + required this.isVip, + required this.expireAt, + required this.planName, + required this.benefits, + }); + + factory MemberInfo.fromJson(Map 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? ?? []).cast(), + ); +} + +/// 权益 key → 文案 +const benefitLabels = { + 'effect_unlimited': '无限效果图', + 'ai_priority': '优先 AI 方案', + 'cps_commission_x15': '返现加成 1.5x', + 'store_discount': '门店折扣', +}; + +List benefitTexts(MemberInfo m) => + m.benefits.map((b) => benefitLabels[b] ?? b).toList(); + +class MemberNotifier extends AsyncNotifier { + @override + Future build() async { + final api = ref.read(apiClientProvider); + final data = await api.get>('/member/status'); + return MemberInfo.fromJson(data); + } + + Future refresh() async { + state = await AsyncValue.guard(build); + } + + /// 领取广告激励(服务端限频);adType: effect_extra | vip_trial + /// 返回当日剩余次数;超出限频抛 ApiException + Future claimReward(String adType) async { + final api = ref.read(apiClientProvider); + final data = + await api.post>('/ad/reward/claim', {'ad_type': adType}); + await refresh(); // vip_trial 可能开通体验会员 + return (data['reward']?['remaining_today'] as num?)?.toInt() ?? 0; + } +} + +final memberProvider = + AsyncNotifierProvider(MemberNotifier.new); + +class MemberPlan { + final int id; + final String name; + final int priceFen; + final int durationDays; + final List features; + + const MemberPlan({ + required this.id, + required this.name, + required this.priceFen, + required this.durationDays, + required this.features, + }); + + factory MemberPlan.fromJson(Map 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 _parseFeatures(String s) { + try { + return (jsonDecode(s) as List).cast(); + } catch (_) { + return const []; + } + } + + String get priceText => + '¥${(priceFen / 100).toStringAsFixed(priceFen % 100 == 0 ? 0 : 1)}'; +} + +final memberPlanProvider = FutureProvider>((ref) async { + final api = ref.read(apiClientProvider); + final data = await api.get>('/member/plan/list'); + final list = data['list'] as List? ?? []; + return list + .map((e) => MemberPlan.fromJson(e as Map)) + .toList(); +}); + +class OrderResult { + final String orderNo; + final String payUrl; + + const OrderResult({required this.orderNo, required this.payUrl}); +} + +/// 创建支付订单(后端调虎皮棋下单,返回收银台 URL) +Future createMemberOrder(WidgetRef ref, int planId) async { + final api = ref.read(apiClientProvider); + final data = + await api.post>('/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 fetchOrderStatus(WidgetRef ref, String orderNo) async { + final api = ref.read(apiClientProvider); + final data = await api.get>('/member/order/status', + query: {'order_no': orderNo}); + return data['status'] as String? ?? ''; +} diff --git a/app/lib/features/member/pay_page.dart b/app/lib/features/member/pay_page.dart new file mode 100644 index 0000000..5df5061 --- /dev/null +++ b/app/lib/features/member/pay_page.dart @@ -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 createState() => _PayPageState(); +} + +class _PayPageState extends ConsumerState { + PayPhase _phase = PayPhase.launching; + Timer? _timer; + int _elapsed = 0; + + @override + void initState() { + super.initState(); + _start(); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + Future _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 _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('返回'), + ), + ]), + }, + ], + ), + ), + ), + ); + } +} diff --git a/app/lib/features/mine/mine_page.dart b/app/lib/features/mine/mine_page.dart new file mode 100644 index 0000000..cfd3697 --- /dev/null +++ b/app/lib/features/mine/mine_page.dart @@ -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 createState() => _MinePageState(); +} + +class _MinePageState extends ConsumerState { + bool _rewarding = false; + + // web 上无 Platform(dart:io 不可用),且 web 端默认走系统浏览器收银台 + bool get _isIOS => + !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS; + + Future _openPlans() async { + final plans = await showModalBottomSheet( + 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 _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 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( + 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), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/features/outfit/outfit_page.dart b/app/lib/features/outfit/outfit_page.dart new file mode 100644 index 0000000..11544d1 --- /dev/null +++ b/app/lib/features/outfit/outfit_page.dart @@ -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 createState() => _OutfitPageState(); +} + +class _OutfitPageState extends ConsumerState { + DateTime? _startDate; + DateTime? _endDate; + final _locationCtrl = TextEditingController(); + String _occasion = '通勤'; + bool _submitting = false; + + static const _occasions = ['通勤', '约会', '聚会', '运动']; + + @override + void dispose() { + _locationCtrl.dispose(); + super.dispose(); + } + + Future _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 _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 _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), + ], + ), + ), + ), + ); + } +} diff --git a/app/lib/features/outfit/outfit_provider.dart b/app/lib/features/outfit/outfit_provider.dart new file mode 100644 index 0000000..de3e16c --- /dev/null +++ b/app/lib/features/outfit/outfit_provider.dart @@ -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 items; + final List images; + final String hairstyleName; + + const PlanDetail({ + required this.plan, + required this.items, + required this.images, + required this.hairstyleName, + }); +} + +/// 方案列表 +class OutfitPlanNotifier extends AsyncNotifier> { + @override + Future> build() async { + final api = ref.read(apiClientProvider); + final data = await api.get>('/outfit/plan/list'); + final list = data['list'] as List? ?? []; + 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 refresh() async { + state = const AsyncLoading(); + state = await AsyncValue.guard(build); + } + + Future selectMain(int planId) async { + final api = ref.read(apiClientProvider); + await api.post('/outfit/plan/select-main', {'plan_id': planId}); + await refresh(); + } + + Future 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.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 { + @override + GenerateState build() => const GenerateState(); + + Future 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>('/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>('/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.new); + +/// 方案详情 +final planDetailProvider = FutureProvider.family((ref, planId) async { + final api = ref.read(apiClientProvider); + final data = await api.get>('/outfit/plan/detail', + query: {'plan_id': planId}); + final planData = data['plan'] as Map? ?? {}; + final items = (data['items'] as List? ?? []) + .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? ?? []) + .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?; + 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? ?? '', + ); +}); diff --git a/app/lib/features/outfit/plan_effect_page.dart b/app/lib/features/outfit/plan_effect_page.dart new file mode 100644 index 0000000..a3e4a9a --- /dev/null +++ b/app/lib/features/outfit/plan_effect_page.dart @@ -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 images; + + const PlanEffectPage({super.key, required this.images}); + + @override + ConsumerState createState() => _PlanEffectPageState(); +} + +class _PlanEffectPageState extends ConsumerState { + 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), + ], + ), + ); + } +} diff --git a/app/lib/features/outfit/plan_viewer_page.dart b/app/lib/features/outfit/plan_viewer_page.dart new file mode 100644 index 0000000..e5f473c --- /dev/null +++ b/app/lib/features/outfit/plan_viewer_page.dart @@ -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 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 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 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; + } + } +} diff --git a/app/lib/features/profile/avatar_build_flow_page.dart b/app/lib/features/profile/avatar_build_flow_page.dart new file mode 100644 index 0000000..3560866 --- /dev/null +++ b/app/lib/features/profile/avatar_build_flow_page.dart @@ -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 = { + 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 createState() => + _AvatarBuildFlowPageState(); +} + +class _AvatarBuildFlowPageState extends ConsumerState { + 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 _pickAndUpload(int type) async { + final source = await showModalBottomSheet( + 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 _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 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 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)), + ], + ), + ); + } +} diff --git a/app/lib/features/profile/avatar_provider.dart b/app/lib/features/profile/avatar_provider.dart new file mode 100644 index 0000000..87499d8 --- /dev/null +++ b/app/lib/features/profile/avatar_provider.dart @@ -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 { + @override + Future build() async { + final api = ref.read(apiClientProvider); + final data = await api.get>('/avatar/get'); + return _fromData(data); + } + + AvatarState _fromData(Map 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 refresh() async { + state = const AsyncLoading(); + state = await AsyncValue.guard(build); + } + + /// 触发构建;若服务端异步则轮询直到 done/failed + Future buildAvatar() async { + state = const AsyncLoading(); + try { + final api = ref.read(apiClientProvider); + final data = + await api.post>('/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 _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>('/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.new); diff --git a/app/lib/features/profile/avatar_viewer_page.dart b/app/lib/features/profile/avatar_viewer_page.dart new file mode 100644 index 0000000..98882fe --- /dev/null +++ b/app/lib/features/profile/avatar_viewer_page.dart @@ -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('重新构建化身'), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/features/profile/body_provider.dart b/app/lib/features/profile/body_provider.dart new file mode 100644 index 0000000..397b30b --- /dev/null +++ b/app/lib/features/profile/body_provider.dart @@ -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 { + @override + Future build() async { + final api = ref.read(apiClientProvider); + final data = await api.get>('/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 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.new); diff --git a/app/lib/features/profile/photo_upload_provider.dart b/app/lib/features/profile/photo_upload_provider.dart new file mode 100644 index 0000000..850273d --- /dev/null +++ b/app/lib/features/profile/photo_upload_provider.dart @@ -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 labels = { + headshot: '大头照', + fullFront: '全身正面', + fullSide: '全身侧面', + fullBack: '全身背面', + }; + + static const Map descs = { + headshot: '清晰正脸、光线充足', + fullFront: '站立正面全身,拍全脚底', + fullSide: '站立侧面全身,自然放松', + fullBack: '站立背面全身,露出轮廓', + }; + + /// 构建 3D 化身所需视角(Tripo 多视角转 3D) + static const List 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> { + @override + Future> build() async { + final api = ref.read(apiClientProvider); + final data = await api.get>('/user-photo/list'); + final list = data['list'] as List? ?? []; + return list + .map((e) => UserPhotoInfo( + id: (e['id'] as num).toInt(), + type: (e['type'] as num).toInt(), + url: e['url'] as String? ?? '', + )) + .toList(); + } + + Future upload(int type, String filePath) async { + final api = ref.read(apiClientProvider); + await api.upload('/user-photo/upload', {'type': type}, 'file', filePath); + await refresh(); + } + + Future delete(int id) async { + final api = ref.read(apiClientProvider); + await api.post('/user-photo/delete', {'id': id}); + await refresh(); + } + + Future 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.new); diff --git a/app/lib/features/wardrobe/wardrobe_page.dart b/app/lib/features/wardrobe/wardrobe_page.dart new file mode 100644 index 0000000..e1b808a --- /dev/null +++ b/app/lib/features/wardrobe/wardrobe_page.dart @@ -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 createState() => _WardrobePageState(); +} + +class _WardrobePageState extends ConsumerState { + String? _filter; // 当前分类筛选,null = 全部 + + /// 长按菜单:找升级款 / 删除 + Future _showItemMenu(WardrobeItemInfo item) async { + final action = await showModalBottomSheet( + 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 _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 _confirmDelete(WardrobeItemInfo item) async { + final ok = await showDialog( + 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('上传'), + ), + ); + } +} diff --git a/app/lib/features/wardrobe/wardrobe_provider.dart b/app/lib/features/wardrobe/wardrobe_provider.dart new file mode 100644 index 0000000..a1bc6ee --- /dev/null +++ b/app/lib/features/wardrobe/wardrobe_provider.dart @@ -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> { + @override + Future> build() async { + final api = ref.read(apiClientProvider); + final data = await api.get>('/wardrobe/list'); + final list = data['list'] as List? ?? []; + 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 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 delete(int id) async { + final api = ref.read(apiClientProvider); + await api.post('/wardrobe/delete', {'id': id}); + await refresh(); + } + + Future refresh() async { + state = const AsyncLoading(); + state = await AsyncValue.guard(build); + } +} + +final wardrobeProvider = + AsyncNotifierProvider>( + WardrobeNotifier.new); diff --git a/app/lib/features/wardrobe/wardrobe_upload_page.dart b/app/lib/features/wardrobe/wardrobe_upload_page.dart new file mode 100644 index 0000000..842479b --- /dev/null +++ b/app/lib/features/wardrobe/wardrobe_upload_page.dart @@ -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 createState() => _WardrobeUploadPageState(); +} + +class _WardrobeUploadPageState extends ConsumerState { + 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 _pickImage() async { + final file = await _picker.pickImage( + source: ImageSource.gallery, maxWidth: 2048, imageQuality: 85); + if (file == null) return; + setState(() => _imagePath = file.path); + } + + Future _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( + 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( + 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('上传到衣橱'), + ), + ], + ), + ); + } +} diff --git a/app/lib/main.dart b/app/lib/main.dart new file mode 100644 index 0000000..da9440d --- /dev/null +++ b/app/lib/main.dart @@ -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((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), + ), + GoRoute( + path: '/avatar-viewer', + builder: (ctx, state) => const AvatarViewerPage(), + ), + GoRoute( + path: '/cps-product-list', + builder: (ctx, state) => + CpsProductListPage(args: state.extra! as CpsListArgs), + ), + ], + ); +}); diff --git a/app/lib/shared/widgets/avatar_viewer.dart b/app/lib/shared/widgets/avatar_viewer.dart new file mode 100644 index 0000000..d39c4db --- /dev/null +++ b/app/lib/shared/widgets/avatar_viewer.dart @@ -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 createState() => _AvatarViewerState(); +} + +class _AvatarViewerState extends State { + 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), + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/shared/widgets/empty_view.dart b/app/lib/shared/widgets/empty_view.dart new file mode 100644 index 0000000..47f5aa3 --- /dev/null +++ b/app/lib/shared/widgets/empty_view.dart @@ -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!)), + ], + ], + ), + ); + } +} diff --git a/app/lib/shared/widgets/error_view.dart b/app/lib/shared/widgets/error_view.dart new file mode 100644 index 0000000..cd08195 --- /dev/null +++ b/app/lib/shared/widgets/error_view.dart @@ -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('重试')), + ], + ], + ), + ); + } +} diff --git a/app/lib/shared/widgets/loading_view.dart b/app/lib/shared/widgets/loading_view.dart new file mode 100644 index 0000000..cde48b0 --- /dev/null +++ b/app/lib/shared/widgets/loading_view.dart @@ -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)), + ], + ], + ), + ); + } +} diff --git a/app/linux/.gitignore b/app/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/app/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/app/linux/CMakeLists.txt b/app/linux/CMakeLists.txt new file mode 100644 index 0000000..80c8953 --- /dev/null +++ b/app/linux/CMakeLists.txt @@ -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 "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>: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() diff --git a/app/linux/flutter/CMakeLists.txt b/app/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/app/linux/flutter/CMakeLists.txt @@ -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} +) diff --git a/app/linux/flutter/generated_plugin_registrant.cc b/app/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..7299b5c --- /dev/null +++ b/app/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,19 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); +} diff --git a/app/linux/flutter/generated_plugin_registrant.h b/app/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/app/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/app/linux/flutter/generated_plugins.cmake b/app/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..786ff5c --- /dev/null +++ b/app/linux/flutter/generated_plugins.cmake @@ -0,0 +1,25 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux + url_launcher_linux +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/app/linux/runner/CMakeLists.txt b/app/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..e97dabc --- /dev/null +++ b/app/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/app/linux/runner/main.cc b/app/linux/runner/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/app/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/app/linux/runner/my_application.cc b/app/linux/runner/my_application.cc new file mode 100644 index 0000000..2115543 --- /dev/null +++ b/app/linux/runner/my_application.cc @@ -0,0 +1,148 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView* view) { + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "slogan_app"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "slogan_app"); + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments( + project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 + // for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), + self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, + gchar*** arguments, + int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, "flags", + G_APPLICATION_NON_UNIQUE, nullptr)); +} diff --git a/app/linux/runner/my_application.h b/app/linux/runner/my_application.h new file mode 100644 index 0000000..db16367 --- /dev/null +++ b/app/linux/runner/my_application.h @@ -0,0 +1,21 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, + my_application, + MY, + APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/app/macos/.gitignore b/app/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/app/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/app/macos/Flutter/Flutter-Debug.xcconfig b/app/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/app/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/app/macos/Flutter/Flutter-Release.xcconfig b/app/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/app/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/app/macos/Flutter/GeneratedPluginRegistrant.swift b/app/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..21ae5af --- /dev/null +++ b/app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,16 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import file_selector_macos +import shared_preferences_foundation +import url_launcher_macos + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) +} diff --git a/app/macos/Runner.xcodeproj/project.pbxproj b/app/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..50ffd6b --- /dev/null +++ b/app/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,729 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* slogan_app.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "slogan_app.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* slogan_app.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* slogan_app.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + 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)/slogan_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/slogan_app"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + 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)/slogan_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/slogan_app"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + 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)/slogan_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/slogan_app"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + 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_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + 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_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + 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_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + 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_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + 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_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + 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_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + 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_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* 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 = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..7f98d90 --- /dev/null +++ b/app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/macos/Runner.xcworkspace/contents.xcworkspacedata b/app/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/app/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/app/macos/Runner/AppDelegate.swift b/app/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..b3c1761 --- /dev/null +++ b/app/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..82b6f9d Binary files /dev/null and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..13b35eb Binary files /dev/null and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..0a3f5fa Binary files /dev/null and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..bdb5722 Binary files /dev/null and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..f083318 Binary files /dev/null and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..326c0e7 Binary files /dev/null and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..2f1632c Binary files /dev/null and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/app/macos/Runner/Base.lproj/MainMenu.xib b/app/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/app/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/macos/Runner/Configs/AppInfo.xcconfig b/app/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..44e3bdf --- /dev/null +++ b/app/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = slogan_app + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.slogan.sloganApp + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2026 com.slogan. All rights reserved. diff --git a/app/macos/Runner/Configs/Debug.xcconfig b/app/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/app/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/app/macos/Runner/Configs/Release.xcconfig b/app/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/app/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/app/macos/Runner/Configs/Warnings.xcconfig b/app/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/app/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/app/macos/Runner/DebugProfile.entitlements b/app/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..dddb8a3 --- /dev/null +++ b/app/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/app/macos/Runner/Info.plist b/app/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/app/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/app/macos/Runner/MainFlutterWindow.swift b/app/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/app/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/app/macos/Runner/Release.entitlements b/app/macos/Runner/Release.entitlements new file mode 100644 index 0000000..852fa1a --- /dev/null +++ b/app/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/app/macos/RunnerTests/RunnerTests.swift b/app/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/app/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +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. + } + +} diff --git a/app/pubspec.lock b/app/pubspec.lock new file mode 100644 index 0000000..ec58cec --- /dev/null +++ b/app/pubspec.lock @@ -0,0 +1,858 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: a49d6cf99e8d8e7a8e93668d09ced0bbdb954d0b4fccc2f5f9241c6b87fad95c + url: "https://pub.flutter-io.cn" + source: hosted + version: "99.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "663efa951fb8a45e06f491223a604c93820598f20e6a99c25617a1576065e8b7" + url: "https://pub.flutter-io.cn" + source: hosted + version: "12.1.0" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.1" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.2.0" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "956a3de0725ca232ad353565a8290d3357592bf4250f6f298a185e2d949c5d3d" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.15.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.3.5+4" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.7" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.9" + dio: + dependency: "direct main" + description: + name: dio + sha256: ea2bad3c89a27635ce2d85cce4d6b199da49a5a48ec77b03e45b65a3b90922b0 + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.10.0" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: dd58dc3861eb36edb13b217efc006a1c21e5bbc341de8c229b85634fa5e362e4 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.0" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.flutter-io.cn" + source: hosted + version: "7.0.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.9.3+5" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.35" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "9255e1e3ad6e38906a1b4f8287678f95f378744c5b46b1985588543f3f19046e" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.3.2" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + fluttertoast: + dependency: "direct main" + description: + name: fluttertoast + sha256: "7903c9d5339173497bfecbc23bc4212f5a87e0edfac2e1693fb74465ea67da7e" + url: "https://pub.flutter-io.cn" + source: hosted + version: "9.1.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.3" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: "5922b2861e2235a3504896f0d6fa07d84141b480cf52eecd2f42cd25585a9e8a" + url: "https://pub.flutter-io.cn" + source: hosted + version: "17.3.0" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.1.2" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: d8402284df184bc05f4a2210c6c23983b0720f4cd87cbd05c5390a78af602667 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.3" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "6f3a1995eafb000333174fae92202622033b0ee7fd917a6cd3730295264df84a" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.8.13+19" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.8.13+6" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.2.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.5" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.flutter-io.cn" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.18.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.9.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.0" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "17100416c51db7810c71a7bb2c34d1f881faa0074fd452afb0c4db6f8f126c76" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.3.2" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "0634e64bd719f89c012f392938e173521f535d3ecaf66558fa94a056d22b5cc7" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.27" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.10.13" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.12.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.2" + test: + dependency: transitive + description: + name: test + sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.31.0" + test_api: + dependency: transitive + description: + name: test_api + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.7.11" + test_core: + dependency: transitive + description: + name: test_core + sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.6.17" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32 + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.3.32" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.5" + uuid: + dependency: transitive + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.6.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.flutter-io.cn" + source: hosted + version: "15.2.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.12.2 <4.0.0" + flutter: ">=3.44.0" diff --git a/app/pubspec.yaml b/app/pubspec.yaml new file mode 100644 index 0000000..e05ebb9 --- /dev/null +++ b/app/pubspec.yaml @@ -0,0 +1,98 @@ +name: slogan_app +description: "A new Flutter project." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.12.2 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + flutter_riverpod: ^3.3.2 + dio: ^5.10.0 + go_router: ^17.3.0 + image_picker: ^1.2.3 + shared_preferences: ^2.5.5 + fluttertoast: ^9.1.0 + url_launcher: ^6.3.0 + # v2 3D 渲染接入时引入:three_dart ^0.0.16 + three_dart_jsm ^0.0.10 + flutter_gl ^0.0.20 + # (flutter_gl 0.0.21 在 Flutter 3.44 下无法编译:platformViewRegistry 已移除) + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^6.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/app/test/core/auth/auth_provider_test.dart b/app/test/core/auth/auth_provider_test.dart new file mode 100644 index 0000000..6410f99 --- /dev/null +++ b/app/test/core/auth/auth_provider_test.dart @@ -0,0 +1,78 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:slogan_app/core/auth/auth_provider.dart'; +import 'package:slogan_app/core/network/api_client.dart'; +import 'package:slogan_app/core/storage/token_storage.dart'; + +import '../network/api_client_test.dart'; + +void main() { + test('登录成功:token 持久化 + 状态 authenticated', () async { + final adapter = MockAdapter(); + adapter.onRequest = (o) => Response( + requestOptions: o, + data: { + 'code': 0, + 'message': 'OK', + 'data': { + 'token': 'jwt-token', + 'user': {'id': 7, 'name': '测试'} + } + }); + final storage = InMemoryTokenStorage(); + final container = ProviderContainer(overrides: [ + tokenStorageProvider.overrideWithValue(storage), + apiClientProvider.overrideWith((ref) => ApiClient( + tokenStorage: storage, + dio: Dio(BaseOptions(baseUrl: 'http://test')) + ..httpClientAdapter = adapter)), + ]); + addTearDown(container.dispose); + + await container.read(authProvider.notifier).login('a', '123456'); + final state = container.read(authProvider); + expect(state.value?.authenticated, true); + expect(state.value?.userId, 7); + expect(storage.token, 'jwt-token'); + }); + + test('登录失败:状态 error 携带 message', () async { + final adapter = MockAdapter(); + adapter.onRequest = (o) => Response( + requestOptions: o, + data: {'code': 50, 'message': '账号不存在', 'data': null}); + final storage = InMemoryTokenStorage(); + final container = ProviderContainer(overrides: [ + tokenStorageProvider.overrideWithValue(storage), + apiClientProvider.overrideWith((ref) => ApiClient( + tokenStorage: storage, + dio: Dio(BaseOptions(baseUrl: 'http://test')) + ..httpClientAdapter = adapter)), + ]); + addTearDown(container.dispose); + + await container.read(authProvider.notifier).login('a', '123456'); + final state = container.read(authProvider); + expect(state.hasError, true); + expect(state.error.toString(), contains('账号不存在')); + expect(state.value?.authenticated, false); + }); +} + +/// 内存版 TokenStorage(测试用,无需 SharedPreferences 插件) +class InMemoryTokenStorage extends TokenStorage { + String? _t; + + @override + String? get token => _t; + + @override + Future load() async {} + + @override + Future save(String token) async => _t = token; + + @override + Future clear() async => _t = null; +} diff --git a/app/test/core/network/api_client_test.dart b/app/test/core/network/api_client_test.dart new file mode 100644 index 0000000..20bef76 --- /dev/null +++ b/app/test/core/network/api_client_test.dart @@ -0,0 +1,102 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:slogan_app/core/network/api_client.dart'; +import 'package:slogan_app/core/network/api_exception.dart'; +import 'package:slogan_app/core/storage/token_storage.dart'; + +/// 可编程 mock 的 Dio adapter +class MockAdapter implements HttpClientAdapter { + Response Function(RequestOptions options)? onRequest; + + @override + Future fetch(RequestOptions options, + Stream? requestStream, Future? cancelFuture) async { + final r = onRequest?.call(options); + if (r == null) return ResponseBody.fromString('', 500); + return ResponseBody.fromString( + jsonEncode(r.data), r.statusCode ?? 200, + headers: { + Headers.contentTypeHeader: [Headers.jsonContentType] + }); + } + + @override + void close({bool force = false}) {} +} + +class _FakeTokenStorage extends TokenStorage { + String? _t; + + _FakeTokenStorage(this._t); + + @override + String? get token => _t; + + @override + Future load() async {} + + @override + Future save(String token) async => _t = token; + + @override + Future clear() async => _t = null; +} + +void main() { + late MockAdapter adapter; + late ApiClient client; + + setUp(() { + adapter = MockAdapter(); + client = ApiClient( + tokenStorage: _FakeTokenStorage('test-token'), + dio: Dio(BaseOptions(baseUrl: 'http://test'))..httpClientAdapter = adapter); + }); + + test('成功响应解析 data', () async { + adapter.onRequest = (o) => Response( + requestOptions: o, + data: {'code': 0, 'message': 'OK', 'data': {'id': 1}}); + final id = await client.post>('/x', {}); + expect(id['id'], 1); + }); + + test('code != 0 抛 ApiException 带 message', () async { + adapter.onRequest = (o) => Response( + requestOptions: o, + data: {'code': 50, 'message': '衣橱服装不足', 'data': null}); + expect( + () => client.get>('/x'), + throwsA(isA() + .having((e) => e.code, 'code', 50) + .having((e) => e.message, 'message', '衣橱服装不足')), + ); + }); + + test('请求携带 Bearer token', () async { + String? auth; + adapter.onRequest = (o) { + auth = o.headers['Authorization']; + return Response( + requestOptions: o, data: {'code': 0, 'message': 'OK', 'data': null}); + }; + await client.get('/x'); + expect(auth, 'Bearer test-token'); + }); + + test('401 触发登出回调', () async { + var called = false; + client.onUnauthorized = () => called = true; + adapter.onRequest = (o) => Response( + requestOptions: o, + statusCode: 401, + data: {'code': 401, 'message': '未登录', 'data': null}); + try { + await client.get('/x'); + } catch (_) {} + expect(called, true); + }); +} diff --git a/app/test/features/member/benefits_test.dart b/app/test/features/member/benefits_test.dart new file mode 100644 index 0000000..4a10a15 --- /dev/null +++ b/app/test/features/member/benefits_test.dart @@ -0,0 +1,24 @@ +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); + }); +} diff --git a/app/web/favicon.png b/app/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/app/web/favicon.png differ diff --git a/app/web/icons/Icon-192.png b/app/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/app/web/icons/Icon-192.png differ diff --git a/app/web/icons/Icon-512.png b/app/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/app/web/icons/Icon-512.png differ diff --git a/app/web/icons/Icon-maskable-192.png b/app/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/app/web/icons/Icon-maskable-192.png differ diff --git a/app/web/icons/Icon-maskable-512.png b/app/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/app/web/icons/Icon-maskable-512.png differ diff --git a/app/web/index.html b/app/web/index.html new file mode 100644 index 0000000..436d7a7 --- /dev/null +++ b/app/web/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + slogan_app + + + + + + + diff --git a/app/web/manifest.json b/app/web/manifest.json new file mode 100644 index 0000000..9f70f69 --- /dev/null +++ b/app/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "slogan_app", + "short_name": "slogan_app", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/app/windows/.gitignore b/app/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/app/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/app/windows/CMakeLists.txt b/app/windows/CMakeLists.txt new file mode 100644 index 0000000..5aa7dd8 --- /dev/null +++ b/app/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(slogan_app 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") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + 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() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# 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_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +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) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +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. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/app/windows/flutter/CMakeLists.txt b/app/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..903f489 --- /dev/null +++ b/app/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +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. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# 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/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app 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. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/app/windows/flutter/generated_plugin_registrant.cc b/app/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..043a96f --- /dev/null +++ b/app/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,17 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); +} diff --git a/app/windows/flutter/generated_plugin_registrant.h b/app/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/app/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/app/windows/flutter/generated_plugins.cmake b/app/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..a95e267 --- /dev/null +++ b/app/windows/flutter/generated_plugins.cmake @@ -0,0 +1,25 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + file_selector_windows + url_launcher_windows +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/app/windows/runner/CMakeLists.txt b/app/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/app/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/app/windows/runner/Runner.rc b/app/windows/runner/Runner.rc new file mode 100644 index 0000000..fa24afc --- /dev/null +++ b/app/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.slogan" "\0" + VALUE "FileDescription", "slogan_app" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "slogan_app" "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 com.slogan. All rights reserved." "\0" + VALUE "OriginalFilename", "slogan_app.exe" "\0" + VALUE "ProductName", "slogan_app" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/app/windows/runner/flutter_window.cpp b/app/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..955ee30 --- /dev/null +++ b/app/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/app/windows/runner/flutter_window.h b/app/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/app/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/app/windows/runner/main.cpp b/app/windows/runner/main.cpp new file mode 100644 index 0000000..6a71b3b --- /dev/null +++ b/app/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"slogan_app", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/app/windows/runner/resource.h b/app/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/app/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/app/windows/runner/resources/app_icon.ico b/app/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..c04e20c Binary files /dev/null and b/app/windows/runner/resources/app_icon.ico differ diff --git a/app/windows/runner/runner.exe.manifest b/app/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..153653e --- /dev/null +++ b/app/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/app/windows/runner/utils.cpp b/app/windows/runner/utils.cpp new file mode 100644 index 0000000..3cb7146 --- /dev/null +++ b/app/windows/runner/utils.cpp @@ -0,0 +1,69 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + // First, find the length of the string with a safe upper bound (CWE-126). + // UNICODE_STRING_MAX_CHARS (32767) is the maximum length of a UNICODE_STRING. + int input_length = static_cast(wcsnlen(utf16_string, UNICODE_STRING_MAX_CHARS)); + // Now use that bounded length to determine the required buffer size. + // When an explicit length is passed, WideCharToMultiByte does not include + // the null terminator in its returned size. + int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, nullptr, 0, nullptr, nullptr); + std::string utf8_string; + if (target_length == 0 || static_cast(target_length) > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/app/windows/runner/utils.h b/app/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/app/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/app/windows/runner/win32_window.cpp b/app/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/app/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/app/windows/runner/win32_window.h b/app/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/app/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_