新增:基础UI加登录接口
52
.gitignore
vendored
Normal file
@ -0,0 +1,52 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# VS Code
|
||||
.vscode/
|
||||
|
||||
# Flutter/Dart/Pub
|
||||
.dart_tool/
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
.pub-cache/
|
||||
.pub/
|
||||
/build/
|
||||
pubspec.lock
|
||||
|
||||
# Android
|
||||
**/android/**/gradle-wrapper.jar
|
||||
**/android/.gradle
|
||||
**/android/captures/
|
||||
**/android/gradlew
|
||||
**/android/gradlew.bat
|
||||
**/android/local.properties
|
||||
**/android/**/GeneratedPluginRegistrant.java
|
||||
|
||||
# iOS
|
||||
**/ios/**/*.mode1v3
|
||||
**/ios/**/*.mode2v3
|
||||
**/ios/**/*.moved-aside
|
||||
**/ios/**/*.pbxuser
|
||||
**/ios/**/*.perspectivev3
|
||||
**/ios/**/*sync/
|
||||
**/ios/**/.sconsign.dblite
|
||||
**/ios/**/.tags*
|
||||
**/ios/**/Podfile.lock
|
||||
**/ios/**/.symlinks/
|
||||
**/ios/**/Flutter/Flutter.framework
|
||||
**/ios/**/Flutter/Flutter.podspec
|
||||
9
.metadata
Normal file
@ -0,0 +1,9 @@
|
||||
# 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.
|
||||
version:
|
||||
revision: "stable"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
42
README.md
Normal file
@ -0,0 +1,42 @@
|
||||
# MagiEvery AI - Flutter 应用
|
||||
|
||||
基于 `design/MagiEveryAI-AB` 设计图实现的 AI 图像/视频生成应用。
|
||||
|
||||
## 功能
|
||||
|
||||
- **Home(图片生成)**:风格分类(All、Realistic、Anime、3D Render 等)、图片轮播、选择开始创作
|
||||
- **Video(视频生成)**:视频模板网格、积分消耗展示
|
||||
- **Gallery(我的画廊)**:图片/视频历史、网格展示
|
||||
- **Profile(个人中心)**:头像、UID、可用积分、Top Up、隐私政策、用户协议
|
||||
- **Create Masterpiece**:上传参考图、生成杰作
|
||||
- **Generation Progress**:生成进度、阶段提示、Pro Tip
|
||||
- **Generation Result**:生成结果、保存与分享
|
||||
- **Credit Store**:积分商店、多种套餐购买
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
lib/
|
||||
├── main.dart # 应用入口与底部导航
|
||||
├── theme/app_theme.dart # MagiEveryAI-AB 设计系统
|
||||
├── screens/ # 各页面
|
||||
│ ├── image_generation_home_screen.dart
|
||||
│ ├── video_generation_home_screen.dart
|
||||
│ ├── gallery_screen.dart
|
||||
│ ├── profile_screen.dart
|
||||
│ ├── create_masterpiece_screen.dart
|
||||
│ ├── generation_progress_screen.dart
|
||||
│ ├── generation_result_screen.dart
|
||||
│ └── credit_store_screen.dart
|
||||
└── widgets/
|
||||
└── credit_badge.dart # 积分徽章
|
||||
```
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
flutter pub get
|
||||
flutter run
|
||||
```
|
||||
|
||||
支持 iOS、Android 和 Web 平台。
|
||||
7
analysis_options.yaml
Normal file
@ -0,0 +1,7 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
linter:
|
||||
rules:
|
||||
prefer_const_constructors: true
|
||||
prefer_const_declarations: true
|
||||
avoid_print: false
|
||||
65
android/app/build.gradle
Normal file
@ -0,0 +1,65 @@
|
||||
plugins {
|
||||
id "com.android.application"
|
||||
id "kotlin-android"
|
||||
id "dev.flutter.flutter-gradle-plugin"
|
||||
}
|
||||
|
||||
def localProperties = new Properties()
|
||||
def localPropertiesFile = rootProject.file('local.properties')
|
||||
if (localPropertiesFile.exists()) {
|
||||
localPropertiesFile.withReader('UTF-8') { reader ->
|
||||
localProperties.load(reader)
|
||||
}
|
||||
}
|
||||
|
||||
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
|
||||
if (flutterVersionCode == null) {
|
||||
flutterVersionCode = '1'
|
||||
}
|
||||
|
||||
def flutterVersionName = localProperties.getProperty('flutter.versionName')
|
||||
if (flutterVersionName == null) {
|
||||
flutterVersionName = '1.0'
|
||||
}
|
||||
|
||||
android {
|
||||
namespace "com.petsheroai.app"
|
||||
compileSdk 36
|
||||
ndkVersion flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '17'
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main.java.srcDirs += 'src/main/kotlin'
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.petsheroai.app"
|
||||
minSdkVersion flutter.minSdkVersion
|
||||
targetSdk 36
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
// 允许 HTTP 连接本地代理(手机无法直连域名时)
|
||||
manifestPlaceholders = [usesCleartextTraffic: "true"]
|
||||
}
|
||||
release {
|
||||
signingConfig signingConfigs.debug
|
||||
manifestPlaceholders = [usesCleartextTraffic: "false"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source '../..'
|
||||
}
|
||||
29
android/app/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1,29 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<application
|
||||
android:usesCleartextTraffic="${usesCleartextTraffic}"
|
||||
android:label="AI创作"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@android:drawable/ic_menu_gallery">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
</manifest>
|
||||
@ -0,0 +1,5 @@
|
||||
package com.petsheroai.app
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity: FlutterActivity()
|
||||
10
android/app/src/main/res/values-night/styles.xml
Normal file
@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">@android:color/black</item>
|
||||
</style>
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">@android:color/black</item>
|
||||
<item name="android:colorBackground">@android:color/black</item>
|
||||
</style>
|
||||
</resources>
|
||||
10
android/app/src/main/res/values/styles.xml
Normal file
@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">@android:color/black</item>
|
||||
</style>
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">@android:color/black</item>
|
||||
<item name="android:colorBackground">@android:color/black</item>
|
||||
</style>
|
||||
</resources>
|
||||
21
android/build.gradle
Normal file
@ -0,0 +1,21 @@
|
||||
allprojects {
|
||||
repositories {
|
||||
maven { url 'https://maven.aliyun.com/repository/google' }
|
||||
maven { url 'https://maven.aliyun.com/repository/public' }
|
||||
maven { url 'https://maven.aliyun.com/repository/central' }
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.buildDir = "../build"
|
||||
subprojects {
|
||||
project.buildDir = "${rootProject.buildDir}/${project.name}"
|
||||
}
|
||||
subprojects {
|
||||
project.evaluationDependsOn(":app")
|
||||
}
|
||||
|
||||
tasks.register("clean", Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
9
android/gradle.properties
Normal file
@ -0,0 +1,9 @@
|
||||
org.gradle.jvmargs=-Xmx4G
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
systemProp.http.proxyHost=
|
||||
systemProp.http.proxyPort=
|
||||
systemProp.https.proxyHost=
|
||||
systemProp.https.proxyPort=
|
||||
systemProp.socksProxyHost=
|
||||
systemProp.socksProxyPort=
|
||||
5
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.11.1-all.zip
|
||||
28
android/settings.gradle
Normal file
@ -0,0 +1,28 @@
|
||||
pluginManagement {
|
||||
def flutterSdkPath = {
|
||||
def properties = new Properties()
|
||||
file("local.properties").withInputStream { properties.load(it) }
|
||||
def flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
|
||||
return flutterSdkPath
|
||||
}
|
||||
|
||||
includeBuild("${flutterSdkPath()}/packages/flutter_tools/gradle")
|
||||
|
||||
repositories {
|
||||
maven { url 'https://maven.aliyun.com/repository/gradle-plugin' }
|
||||
maven { url 'https://maven.aliyun.com/repository/google' }
|
||||
maven { url 'https://maven.aliyun.com/repository/public' }
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||
id "com.android.application" version "8.9.1" apply false
|
||||
id "org.jetbrains.kotlin.android" version "1.9.22" apply false
|
||||
}
|
||||
|
||||
include ":app"
|
||||
0
assets/images/.gitkeep
Normal file
20
design/README.md
Normal file
@ -0,0 +1,20 @@
|
||||
# Design
|
||||
|
||||
设计文件目录,基于 [Pencil](https://pencil.dev) 的 `.pen` 格式。
|
||||
|
||||
## 文件说明
|
||||
|
||||
- **app-design.pen** - AI创作 App 设计稿,源自 [Figma 设计](https://blog-flat-29730549.figma.site/)
|
||||
|
||||
## Pencil 格式
|
||||
|
||||
`.pen` 是 Pencil 设计工具的 JSON 格式,包含:
|
||||
|
||||
- **variables** - 设计变量(颜色、尺寸等)
|
||||
- **children** - 画布上的对象树(frame、text、rectangle 等)
|
||||
- **reusable** - 可复用组件
|
||||
- **ref** - 组件实例引用
|
||||
|
||||
## 使用
|
||||
|
||||
在 [Pencil](https://pencil.dev) 中打开 `.pen` 文件即可编辑和预览设计。
|
||||
BIN
design/images/generated-1772942434587.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
design/images/generated-1772942448443.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
design/images/generated-1772942462462.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
design/images/generated-1772942470719.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
design/images/generated-1772942481069.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
design/images/generated-1772942485221.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
design/images/generated-1772942494302.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
design/images/generated-1772942500640.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
design/images/generated-1772942509300.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
design/images/generated-1772942514836.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
design/images/generated-1772942527064.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
design/images/generated-1772942542657.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
3408
design/pencil-app-client.pen
Normal file
165
docs/api_flow_summary.md
Normal file
@ -0,0 +1,165 @@
|
||||
# petsHeroAI 接口调用流程说明
|
||||
|
||||
## 一、整体流程
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 客户端请求流程 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
业务参数(原始字段)
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ 字段名映射 │ body / params / headers 中的原始字段 → V2 字段
|
||||
└─────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ V2 包装 │ 将 body 包装为 arsenal/vault/tome/codex/grimoire/sanctum 结构
|
||||
└─────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ JSON 序列化 │
|
||||
└─────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ AES+Base64 │ AES-128-ECB, PKCS5Padding 加密
|
||||
└─────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ 构造代理请求 │ 填入 hero_class, pet_species, power_level 等参数
|
||||
└─────────────┘
|
||||
│
|
||||
▼
|
||||
POST {baseUrl}/quester/defender/summoner
|
||||
```
|
||||
|
||||
## 二、响应处理流程
|
||||
|
||||
```
|
||||
POST 代理入口响应
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ 提取密文 │ 从响应中获取加密字段
|
||||
└─────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ Base64 解码 │
|
||||
└─────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ AES 解密 │ AES-128-ECB 解密
|
||||
└─────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ JSON 解析 │
|
||||
└─────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ 字段逆映射 │ V2 字段 → 原始字段 (便于业务使用)
|
||||
└─────────────┘
|
||||
│
|
||||
▼
|
||||
业务数据 (code/msg/data)
|
||||
```
|
||||
|
||||
## 三、接口分类与调用顺序
|
||||
|
||||
### 3.1 登录与用户
|
||||
|
||||
| 顺序 | 接口 | 方法 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 1 | `/v1/user/fast_login` | POST | 设备快速登录,获取 userToken |
|
||||
| 2 | `/v1/user/common_info` | GET | 获取用户通用信息(含积分、头像等) |
|
||||
| 3 | `/v1/user/account` | GET | 获取用户账户信息 |
|
||||
| 4 | `/v1/user/referrer` | POST | 归因上报 |
|
||||
| 5 | `/v1/user/delete` | GET | 注销账户 |
|
||||
|
||||
### 3.2 支付
|
||||
|
||||
| 顺序 | 接口 | 方法 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 1 | `/v1/payment/getGooglePayActivities` | GET | 获取 Google 商品列表 |
|
||||
| 2 | `/v1/payment/getApplePayActivities` | GET | 获取 Apple 商品列表 |
|
||||
| 3 | `/v1/payment/createPayment` | POST | 创建支付订单 |
|
||||
| 4 | `/v1/payment/googlepay` | POST | Google 支付结果回调 |
|
||||
| 5 | `/v1/payment/applepay` | POST | Apple 支付结果回调 |
|
||||
| 6 | `/v1/payment/getPaymentDetailList` | GET | 获取支付订单列表 |
|
||||
|
||||
### 3.3 图片生成
|
||||
|
||||
| 顺序 | 接口 | 方法 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 1 | `/v1/image/prompt/recomends` | GET | 获取推荐提示词 |
|
||||
| 2 | `/v1/image/txt2img_tags` | GET | 获取文生图标签 |
|
||||
| 3 | `/v1/image/txt2img_prompts` | POST | 获取文生图提示词模板 |
|
||||
| 4 | `/v1/image/txt2img_create` | POST | 创建文生图任务 |
|
||||
| 5 | `/v1/image/progress` | GET | 查询图片生成进度 |
|
||||
|
||||
### 3.4 图转视频
|
||||
|
||||
| 顺序 | 接口 | 方法 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 1 | `/v1/image/img2Video_pose_template` | GET | 获取图转视频姿态模板 |
|
||||
| 2 | `/v1/image/img2video_pose_task` | POST | 创建图转视频姿态任务 |
|
||||
| 3 | `/v1/image/progress` | GET | 查询任务进度 |
|
||||
|
||||
### 3.5 换衣 / 换脸
|
||||
|
||||
| 顺序 | 接口 | 方法 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 1 | `/v1/image/clothes_template` | GET | 获取换衣模板 |
|
||||
| 2 | `/v1/image/clothes_swap_ex` | POST | 创建换衣任务 |
|
||||
| 3 | `/v1/image/faceswap_task` | POST | 创建换脸任务 |
|
||||
| 4 | `/v1/image/video_facewap_task` | POST | 创建视频换脸任务 |
|
||||
|
||||
### 3.6 其他
|
||||
|
||||
| 接口 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/v1/image/category-list` | GET | 获取分类列表 |
|
||||
| `/v1/log/appevent` | POST | App 事件打点上报 |
|
||||
| `/v1/image/getCreditsPageInfo` | GET | 获取积分页面信息 |
|
||||
| `/v1/log/uploadUrl` | POST | 获取预签名上传 URL |
|
||||
|
||||
## 四、通用请求头
|
||||
|
||||
登录后所有请求需携带:
|
||||
|
||||
| 原始字段 | V2 字段 | 说明 |
|
||||
|----------|---------|------|
|
||||
| pkg | portal | 应用包名,必填,如 `com.petsheroai.app` |
|
||||
| User_token | knight | 用户登录 token |
|
||||
|
||||
## 五、通用响应结构
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0, // helm,0=成功
|
||||
"msg": "", // rampart,消息
|
||||
"data": {} // sidekick,业务数据
|
||||
}
|
||||
```
|
||||
|
||||
## 六、错误码
|
||||
|
||||
| code | 说明 |
|
||||
|------|------|
|
||||
| 0 | 成功 |
|
||||
| -1 | 系统错误 |
|
||||
| -2 | 未登录 |
|
||||
| -3 | 无权限 |
|
||||
| -4 | 请求过于频繁 |
|
||||
| -5 | 参数错误 |
|
||||
| 1001 | 积分不足 |
|
||||
| 1002 | 免费次数已用完 |
|
||||
| 1003 | 免费次数和积分均已用完 |
|
||||
2301
docs/petsHeroAI_client_guide.md
Normal file
14
ios/Flutter/Generated.xcconfig
Normal file
@ -0,0 +1,14 @@
|
||||
// This is a generated file; do not edit or check into version control.
|
||||
FLUTTER_ROOT=/Users/sven/flutter
|
||||
FLUTTER_APPLICATION_PATH=/Users/sven/Project/Workplace/app_client
|
||||
COCOAPODS_PARALLEL_CODE_SIGN=true
|
||||
FLUTTER_TARGET=lib/main.dart
|
||||
FLUTTER_BUILD_DIR=build
|
||||
FLUTTER_BUILD_NAME=1.0.0
|
||||
FLUTTER_BUILD_NUMBER=1
|
||||
EXCLUDED_ARCHS[sdk=iphonesimulator*]=i386
|
||||
EXCLUDED_ARCHS[sdk=iphoneos*]=armv7
|
||||
DART_OBFUSCATION=false
|
||||
TRACK_WIDGET_CREATION=true
|
||||
TREE_SHAKE_ICONS=false
|
||||
PACKAGE_CONFIG=.dart_tool/package_config.json
|
||||
32
ios/Flutter/ephemeral/flutter_lldb_helper.py
Normal file
@ -0,0 +1,32 @@
|
||||
#
|
||||
# Generated file, do not edit.
|
||||
#
|
||||
|
||||
import lldb
|
||||
|
||||
def handle_new_rx_page(frame: lldb.SBFrame, bp_loc, extra_args, intern_dict):
|
||||
"""Intercept NOTIFY_DEBUGGER_ABOUT_RX_PAGES and touch the pages."""
|
||||
base = frame.register["x0"].GetValueAsAddress()
|
||||
page_len = frame.register["x1"].GetValueAsUnsigned()
|
||||
|
||||
# Note: NOTIFY_DEBUGGER_ABOUT_RX_PAGES will check contents of the
|
||||
# first page to see if handled it correctly. This makes diagnosing
|
||||
# misconfiguration (e.g. missing breakpoint) easier.
|
||||
data = bytearray(page_len)
|
||||
data[0:8] = b'IHELPED!'
|
||||
|
||||
error = lldb.SBError()
|
||||
frame.GetThread().GetProcess().WriteMemory(base, data, error)
|
||||
if not error.Success():
|
||||
print(f'Failed to write into {base}[+{page_len}]', error)
|
||||
return
|
||||
|
||||
def __lldb_init_module(debugger: lldb.SBDebugger, _):
|
||||
target = debugger.GetDummyTarget()
|
||||
# Caveat: must use BreakpointCreateByRegEx here and not
|
||||
# BreakpointCreateByName. For some reasons callback function does not
|
||||
# get carried over from dummy target for the later.
|
||||
bp = target.BreakpointCreateByRegex("^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$")
|
||||
bp.SetScriptCallbackFunction('{}.handle_new_rx_page'.format(__name__))
|
||||
bp.SetAutoContinue(True)
|
||||
print("-- LLDB integration loaded --")
|
||||
5
ios/Flutter/ephemeral/flutter_lldbinit
Normal file
@ -0,0 +1,5 @@
|
||||
#
|
||||
# Generated file, do not edit.
|
||||
#
|
||||
|
||||
command script import --relative-to-command-file flutter_lldb_helper.py
|
||||
13
ios/Flutter/flutter_export_environment.sh
Executable file
@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
# This is a generated file; do not edit or check into version control.
|
||||
export "FLUTTER_ROOT=/Users/sven/flutter"
|
||||
export "FLUTTER_APPLICATION_PATH=/Users/sven/Project/Workplace/app_client"
|
||||
export "COCOAPODS_PARALLEL_CODE_SIGN=true"
|
||||
export "FLUTTER_TARGET=lib/main.dart"
|
||||
export "FLUTTER_BUILD_DIR=build"
|
||||
export "FLUTTER_BUILD_NAME=1.0.0"
|
||||
export "FLUTTER_BUILD_NUMBER=1"
|
||||
export "DART_OBFUSCATION=false"
|
||||
export "TRACK_WIDGET_CREATION=true"
|
||||
export "TREE_SHAKE_ICONS=false"
|
||||
export "PACKAGE_CONFIG=.dart_tool/package_config.json"
|
||||
13
ios/Runner/AppDelegate.swift
Normal file
@ -0,0 +1,13 @@
|
||||
import UIKit
|
||||
import Flutter
|
||||
|
||||
@UIApplicationMain
|
||||
@objc class AppDelegate: FlutterAppDelegate {
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
}
|
||||
19
ios/Runner/GeneratedPluginRegistrant.h
Normal file
@ -0,0 +1,19 @@
|
||||
//
|
||||
// Generated file. Do not edit.
|
||||
//
|
||||
|
||||
// clang-format off
|
||||
|
||||
#ifndef GeneratedPluginRegistrant_h
|
||||
#define GeneratedPluginRegistrant_h
|
||||
|
||||
#import <Flutter/Flutter.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface GeneratedPluginRegistrant : NSObject
|
||||
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry;
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
#endif /* GeneratedPluginRegistrant_h */
|
||||
28
ios/Runner/GeneratedPluginRegistrant.m
Normal file
@ -0,0 +1,28 @@
|
||||
//
|
||||
// Generated file. Do not edit.
|
||||
//
|
||||
|
||||
// clang-format off
|
||||
|
||||
#import "GeneratedPluginRegistrant.h"
|
||||
|
||||
#if __has_include(<device_info_plus/FPPDeviceInfoPlusPlugin.h>)
|
||||
#import <device_info_plus/FPPDeviceInfoPlusPlugin.h>
|
||||
#else
|
||||
@import device_info_plus;
|
||||
#endif
|
||||
|
||||
#if __has_include(<sqflite_darwin/SqflitePlugin.h>)
|
||||
#import <sqflite_darwin/SqflitePlugin.h>
|
||||
#else
|
||||
@import sqflite_darwin;
|
||||
#endif
|
||||
|
||||
@implementation GeneratedPluginRegistrant
|
||||
|
||||
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry {
|
||||
[FPPDeviceInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPDeviceInfoPlusPlugin"]];
|
||||
[SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]];
|
||||
}
|
||||
|
||||
@end
|
||||
49
ios/Runner/Info.plist
Normal file
@ -0,0 +1,49 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>MagiEvery AI</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>We need access to your photos to use as reference for AI image generation.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>We need camera access to capture reference images for AI generation.</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>app_client</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
72
lib/app.dart
Normal file
@ -0,0 +1,72 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'core/theme/app_theme.dart';
|
||||
import 'features/gallery/gallery_screen.dart';
|
||||
import 'features/generate_video/generate_progress_screen.dart';
|
||||
import 'features/generate_video/generate_video_screen.dart';
|
||||
import 'features/generate_video/generation_result_screen.dart';
|
||||
import 'features/home/home_screen.dart';
|
||||
import 'features/profile/profile_screen.dart';
|
||||
import 'features/recharge/recharge_screen.dart';
|
||||
import 'shared/widgets/bottom_nav_bar.dart';
|
||||
|
||||
/// Root app widget with navigation
|
||||
class App extends StatefulWidget {
|
||||
const App({super.key});
|
||||
|
||||
@override
|
||||
State<App> createState() => _AppState();
|
||||
}
|
||||
|
||||
class _AppState extends State<App> {
|
||||
NavTab _currentTab = NavTab.home;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'AI Video App',
|
||||
theme: AppTheme.light,
|
||||
debugShowCheckedModeBanner: false,
|
||||
initialRoute: '/',
|
||||
routes: {
|
||||
'/': (_) => _MainScaffold(
|
||||
currentTab: _currentTab,
|
||||
onTabSelected: (tab) => setState(() => _currentTab = tab),
|
||||
),
|
||||
'/recharge': (_) => const RechargeScreen(),
|
||||
'/generate': (_) => const GenerateVideoScreen(),
|
||||
'/progress': (_) => const GenerateProgressScreen(),
|
||||
'/result': (_) => const GenerationResultScreen(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MainScaffold extends StatelessWidget {
|
||||
const _MainScaffold({
|
||||
required this.currentTab,
|
||||
required this.onTabSelected,
|
||||
});
|
||||
|
||||
final NavTab currentTab;
|
||||
final ValueChanged<NavTab> onTabSelected;
|
||||
|
||||
static const _screens = [
|
||||
HomeScreen(),
|
||||
GalleryScreen(),
|
||||
ProfileScreen(),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: IndexedStack(
|
||||
index: currentTab.index,
|
||||
children: _screens,
|
||||
),
|
||||
bottomNavigationBar: BottomNavBar(
|
||||
currentTab: currentTab,
|
||||
onTabSelected: onTabSelected,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
22
lib/core/api/api.dart
Normal file
@ -0,0 +1,22 @@
|
||||
/// petsHeroAI API 模块
|
||||
///
|
||||
/// 使用方式:
|
||||
/// ```dart
|
||||
/// // 登录后设置 token
|
||||
/// ApiClient.instance.setUserToken('xxx');
|
||||
///
|
||||
/// // 调用 API
|
||||
/// final res = await UserApi.getAccount(sentinel: ApiConfig.appId);
|
||||
/// if (res.isSuccess) {
|
||||
/// final credit = res.data['credit'];
|
||||
/// }
|
||||
/// ```
|
||||
library;
|
||||
|
||||
export 'api_client.dart';
|
||||
export 'api_config.dart';
|
||||
export 'api_crypto.dart';
|
||||
export 'proxy_client.dart';
|
||||
export 'services/image_api.dart';
|
||||
export 'services/payment_api.dart';
|
||||
export 'services/user_api.dart';
|
||||
22
lib/core/api/api_client.dart
Normal file
@ -0,0 +1,22 @@
|
||||
import 'api_config.dart';
|
||||
import 'proxy_client.dart';
|
||||
|
||||
/// 全局 API 客户端单例
|
||||
class ApiClient {
|
||||
ApiClient._();
|
||||
|
||||
static final ApiClient _instance = ApiClient._();
|
||||
|
||||
static ApiClient get instance => _instance;
|
||||
|
||||
late final ProxyClient _proxy = ProxyClient(
|
||||
packageName: ApiConfig.packageName,
|
||||
);
|
||||
|
||||
/// 设置用户 Token(登录后调用)
|
||||
void setUserToken(String? token) {
|
||||
_proxy.userToken = token;
|
||||
}
|
||||
|
||||
ProxyClient get proxy => _proxy;
|
||||
}
|
||||
36
lib/core/api/api_config.dart
Normal file
@ -0,0 +1,36 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// petsHeroAI API 配置
|
||||
abstract final class ApiConfig {
|
||||
/// AES 密钥
|
||||
static const String aesKey = 'liyP4LkMfP68XvCt';
|
||||
|
||||
/// 应用 ID
|
||||
static const String appId = 'com.petsheroai.app';
|
||||
|
||||
/// 应用包名
|
||||
static const String packageName = 'com.petsheroai.app';
|
||||
|
||||
/// 预发环境域名
|
||||
static const String preBaseUrl = 'https://pre-ai.petsheroai.xyz';
|
||||
|
||||
/// 生产环境域名
|
||||
static const String prodBaseUrl = 'https://ai.petsheroai.xyz';
|
||||
|
||||
/// 代理入口路径
|
||||
static const String proxyPath = '/quester/defender/summoner';
|
||||
|
||||
/// 调试时本地代理地址(手机无法直连域名时,用电脑做转发)
|
||||
/// 例如: 'http://192.168.1.100:8010'(电脑 IP)
|
||||
/// 设为 null 则直连预发域名
|
||||
static const String? debugBaseUrlOverride = null;
|
||||
|
||||
/// 当前使用的 baseUrl(调试用预发,打包用生产)
|
||||
static String get baseUrl {
|
||||
if (!kDebugMode) return prodBaseUrl;
|
||||
return debugBaseUrlOverride ?? preBaseUrl;
|
||||
}
|
||||
|
||||
/// 代理入口完整 URL
|
||||
static String get proxyUrl => '$baseUrl$proxyPath';
|
||||
}
|
||||
42
lib/core/api/api_crypto.dart
Normal file
@ -0,0 +1,42 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:encrypt/encrypt.dart';
|
||||
|
||||
import 'api_config.dart';
|
||||
|
||||
/// AES-128-ECB 加解密
|
||||
abstract final class ApiCrypto {
|
||||
static final _key = Key.fromUtf8(ApiConfig.aesKey);
|
||||
|
||||
static final _encrypter = Encrypter(
|
||||
AES(
|
||||
_key,
|
||||
mode: AESMode.ecb,
|
||||
padding: 'PKCS7',
|
||||
),
|
||||
);
|
||||
|
||||
/// AES 加密,返回 Base64 字符串
|
||||
static String encrypt(String plainText) {
|
||||
final encrypted = _encrypter.encrypt(plainText);
|
||||
return encrypted.base64;
|
||||
}
|
||||
|
||||
/// AES 解密,输入 Base64 字符串
|
||||
static String decrypt(String base64Cipher) {
|
||||
final encrypted = Encrypted.fromBase64(base64Cipher);
|
||||
return _encrypter.decrypt(encrypted);
|
||||
}
|
||||
|
||||
/// 生成随机 Base64 字符串(用于噪音字段)
|
||||
static String randomBase64([int byteLength = 16]) {
|
||||
final bytes = List<int>.generate(byteLength, (_) => DateTime.now().millisecondsSinceEpoch % 256);
|
||||
return base64Encode(bytes);
|
||||
}
|
||||
|
||||
/// 生成 8 位随机字母数字
|
||||
static String randomAlnum() {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
return List.generate(8, (_) => chars[DateTime.now().microsecondsSinceEpoch % chars.length]).join();
|
||||
}
|
||||
}
|
||||
195
lib/core/api/proxy_client.dart
Normal file
@ -0,0 +1,195 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'api_config.dart';
|
||||
import 'api_crypto.dart';
|
||||
|
||||
const _logTag = '[ProxyClient]';
|
||||
|
||||
void _log(String msg) {
|
||||
if (kDebugMode) debugPrint('$_logTag $msg');
|
||||
}
|
||||
|
||||
/// 代理请求体字段名(统一请求参数)
|
||||
abstract final class ProxyKeys {
|
||||
static const String heroClass = 'hero_class';
|
||||
static const String petSpecies = 'pet_species';
|
||||
static const String powerLevel = 'power_level';
|
||||
static const String questRank = 'quest_rank';
|
||||
static const String battleScore = 'battle_score';
|
||||
static const String loyaltyIndex = 'loyalty_index';
|
||||
static const String billingAddr = 'billing_addr';
|
||||
static const String utmTerm = 'utm_term';
|
||||
static const String clusterId = 'cluster_id';
|
||||
static const String lsnValue = 'lsn_value';
|
||||
static const String accuracyVal = 'accuracy_val';
|
||||
static const String dirPath = 'dir_path';
|
||||
}
|
||||
|
||||
/// 代理请求客户端
|
||||
class ProxyClient {
|
||||
ProxyClient({
|
||||
this.baseUrl,
|
||||
this.packageName = ApiConfig.packageName,
|
||||
String? userToken,
|
||||
}) : _userToken = userToken;
|
||||
|
||||
final String? baseUrl;
|
||||
final String packageName;
|
||||
String? _userToken;
|
||||
|
||||
String? get userToken => _userToken;
|
||||
set userToken(String? value) => _userToken = value;
|
||||
|
||||
String get _baseUrl => baseUrl ?? ApiConfig.baseUrl;
|
||||
|
||||
/// 构建 V2 包装体,业务参数填入 sanctum
|
||||
Map<String, dynamic> _buildV2Wrapper(Map<String, dynamic> sanctum) {
|
||||
return {
|
||||
'arsenal': 4,
|
||||
'vault': {
|
||||
'tome': {
|
||||
'codex': {
|
||||
'grimoire': {
|
||||
'sanctum': sanctum,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'roar': ApiCrypto.randomAlnum(),
|
||||
'clash': ApiCrypto.randomAlnum(),
|
||||
'thunder': ApiCrypto.randomAlnum(),
|
||||
'rumble': ApiCrypto.randomAlnum(),
|
||||
'howl': ApiCrypto.randomAlnum(),
|
||||
'growl': ApiCrypto.randomAlnum(),
|
||||
};
|
||||
}
|
||||
|
||||
/// 发送代理请求
|
||||
/// [path] 接口路径,如 /v1/user/fast_login
|
||||
/// [method] HTTP 方法,POST 或 GET
|
||||
/// [headers] 请求头,使用 V2 字段名(portal、knight 等)
|
||||
/// [queryParams] 查询参数,使用 V2 字段名(sentinel、asset 等)
|
||||
/// [body] 请求体,使用 V2 字段名,将填入 sanctum
|
||||
Future<ApiResponse> request({
|
||||
required String path,
|
||||
required String method,
|
||||
Map<String, String>? headers,
|
||||
Map<String, String>? queryParams,
|
||||
Map<String, dynamic>? body,
|
||||
}) async {
|
||||
final headersMap = Map<String, dynamic>.from(headers ?? {});
|
||||
if (packageName.isNotEmpty) {
|
||||
headersMap['portal'] = packageName;
|
||||
}
|
||||
if (_userToken != null && _userToken!.isNotEmpty) {
|
||||
headersMap['knight'] = _userToken!;
|
||||
}
|
||||
|
||||
final paramsMap = Map<String, dynamic>.from(
|
||||
queryParams?.map((k, v) => MapEntry(k, v)) ?? {},
|
||||
);
|
||||
|
||||
final sanctum = body ?? {};
|
||||
final v2Body = _buildV2Wrapper(sanctum);
|
||||
|
||||
// 原始入参
|
||||
final headersEncoded = jsonEncode(headersMap);
|
||||
final paramsEncoded = jsonEncode(paramsMap);
|
||||
final v2BodyEncoded = jsonEncode(v2Body);
|
||||
|
||||
_log('========== 原始入参 ==========');
|
||||
_log('path: $path');
|
||||
_log('method: $method');
|
||||
_log('headers: $headersEncoded');
|
||||
_log('queryParams: $paramsEncoded');
|
||||
_log('body(sanctum): ${jsonEncode(sanctum)}');
|
||||
_log('v2Body: $v2BodyEncoded');
|
||||
|
||||
final petSpeciesEnc = ApiCrypto.encrypt(path);
|
||||
final powerLevelEnc = ApiCrypto.encrypt(method);
|
||||
final questRankEnc = ApiCrypto.encrypt(headersEncoded);
|
||||
final battleScoreEnc = ApiCrypto.encrypt(paramsEncoded);
|
||||
final loyaltyIndexEnc = ApiCrypto.encrypt(v2BodyEncoded);
|
||||
|
||||
_log('========== 加密后 ==========');
|
||||
_log('pet_species: $petSpeciesEnc');
|
||||
_log('power_level: $powerLevelEnc');
|
||||
_log('quest_rank: $questRankEnc');
|
||||
_log('battle_score: $battleScoreEnc');
|
||||
_log('loyalty_index: $loyaltyIndexEnc');
|
||||
|
||||
final proxyBody = {
|
||||
ProxyKeys.heroClass: ApiConfig.appId,
|
||||
ProxyKeys.petSpecies: petSpeciesEnc,
|
||||
ProxyKeys.powerLevel: powerLevelEnc,
|
||||
ProxyKeys.questRank: questRankEnc,
|
||||
ProxyKeys.battleScore: battleScoreEnc,
|
||||
ProxyKeys.loyaltyIndex: loyaltyIndexEnc,
|
||||
ProxyKeys.billingAddr: ApiCrypto.randomBase64(),
|
||||
ProxyKeys.utmTerm: ApiCrypto.randomBase64(),
|
||||
ProxyKeys.clusterId: ApiCrypto.randomBase64(),
|
||||
ProxyKeys.lsnValue: ApiCrypto.randomBase64(),
|
||||
ProxyKeys.accuracyVal: ApiCrypto.randomBase64(),
|
||||
ProxyKeys.dirPath: ApiCrypto.randomBase64(),
|
||||
};
|
||||
|
||||
final url = '$_baseUrl${ApiConfig.proxyPath}';
|
||||
_log('========== 请求 URL ==========');
|
||||
_log('$url');
|
||||
|
||||
final response = await http.post(
|
||||
Uri.parse(url),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode(proxyBody),
|
||||
);
|
||||
|
||||
_log('========== 响应 ==========');
|
||||
_log('statusCode: ${response.statusCode}');
|
||||
_log('body: ${response.body}');
|
||||
|
||||
return _parseResponse(response);
|
||||
}
|
||||
|
||||
ApiResponse _parseResponse(http.Response response) {
|
||||
try {
|
||||
// 响应解密流程:Base64 解码 → AES-ECB 解密 → PKCS5 去填充 → UTF-8 字符串
|
||||
final decrypted = ApiCrypto.decrypt(response.body);
|
||||
final json = jsonDecode(decrypted) as Map<String, dynamic>;
|
||||
_log('json: $json');
|
||||
// 解析 helm=code, rampart=msg, sidekick=data
|
||||
final sanctum = json['vault']?['tome']?['codex']?['grimoire']?['sanctum'];
|
||||
if (sanctum is Map<String, dynamic>) {
|
||||
return ApiResponse(
|
||||
code: sanctum['helm'] as int? ?? -1,
|
||||
msg: sanctum['rampart'] as String? ?? '',
|
||||
data: sanctum['sidekick'],
|
||||
);
|
||||
}
|
||||
return ApiResponse(
|
||||
code: json['helm'] as int? ?? -1,
|
||||
msg: json['rampart'] as String? ?? '',
|
||||
data: json['sidekick'],
|
||||
);
|
||||
} catch (e) {
|
||||
return ApiResponse(code: -1, msg: e.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 统一 API 响应
|
||||
class ApiResponse {
|
||||
ApiResponse({
|
||||
required this.code,
|
||||
this.msg = '',
|
||||
this.data,
|
||||
});
|
||||
|
||||
final int code;
|
||||
final String msg;
|
||||
final dynamic data;
|
||||
|
||||
bool get isSuccess => code == 0;
|
||||
}
|
||||
128
lib/core/api/services/image_api.dart
Normal file
@ -0,0 +1,128 @@
|
||||
import '../api_client.dart';
|
||||
import '../proxy_client.dart';
|
||||
|
||||
/// 图片/视频生成相关 API
|
||||
abstract final class ImageApi {
|
||||
static final _client = ApiClient.instance.proxy;
|
||||
|
||||
/// 获取推荐提示词
|
||||
static Future<ApiResponse> getPromptRecommends({
|
||||
required String sentinel,
|
||||
String? crest,
|
||||
String? asset,
|
||||
}) async {
|
||||
return _client.request(
|
||||
path: '/v1/image/prompt/recomends',
|
||||
method: 'GET',
|
||||
queryParams: {
|
||||
'sentinel': sentinel,
|
||||
if (crest != null) 'crest': crest,
|
||||
if (asset != null) 'asset': asset,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 创建文生图任务
|
||||
static Future<ApiResponse> createTxt2Img({
|
||||
required String sentinel,
|
||||
required String ledger,
|
||||
String? crest,
|
||||
String? asset,
|
||||
String? quest,
|
||||
}) async {
|
||||
return _client.request(
|
||||
path: '/v1/image/txt2img_create',
|
||||
method: 'POST',
|
||||
queryParams: {
|
||||
'sentinel': sentinel,
|
||||
if (crest != null) 'crest': crest,
|
||||
if (asset != null) 'asset': asset,
|
||||
},
|
||||
body: {
|
||||
'declaration': 1,
|
||||
if (quest != null) 'quest': quest,
|
||||
'ledger': ledger,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 创建图转视频姿态任务
|
||||
static Future<ApiResponse> createImg2VideoPose({
|
||||
required String sentinel,
|
||||
required String asset,
|
||||
String? congregation,
|
||||
String? profit,
|
||||
String? compendium,
|
||||
String? notification,
|
||||
String? allowance,
|
||||
String? cosmos,
|
||||
}) async {
|
||||
return _client.request(
|
||||
path: '/v1/image/img2video_pose_task',
|
||||
method: 'POST',
|
||||
queryParams: {
|
||||
'sentinel': sentinel,
|
||||
'asset': asset,
|
||||
if (congregation != null) 'congregation': congregation,
|
||||
if (notification != null) 'notification': notification,
|
||||
if (allowance != null) 'allowance': allowance,
|
||||
if (cosmos != null) 'cosmos': cosmos,
|
||||
if (profit != null) 'profit': profit,
|
||||
if (compendium != null) 'compendium': compendium,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 查询任务进度
|
||||
static Future<ApiResponse> getProgress({
|
||||
required String sentinel,
|
||||
required String tree,
|
||||
String? asset,
|
||||
}) async {
|
||||
return _client.request(
|
||||
path: '/v1/image/progress',
|
||||
method: 'GET',
|
||||
queryParams: {
|
||||
'sentinel': sentinel,
|
||||
'tree': tree,
|
||||
if (asset != null) 'asset': asset,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 获取图转视频姿态模板
|
||||
static Future<ApiResponse> getImg2VideoPoseTemplates({
|
||||
required String sentinel,
|
||||
String? crest,
|
||||
String? asset,
|
||||
String? insignia,
|
||||
}) async {
|
||||
return _client.request(
|
||||
path: '/v1/image/img2Video_pose_template',
|
||||
method: 'GET',
|
||||
queryParams: {
|
||||
'sentinel': sentinel,
|
||||
if (crest != null) 'crest': crest,
|
||||
if (asset != null) 'asset': asset,
|
||||
if (insignia != null) 'insignia': insignia,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 获取积分页面信息
|
||||
static Future<ApiResponse> getCreditsPageInfo({
|
||||
required String sentinel,
|
||||
String? asset,
|
||||
String? crest,
|
||||
}) async {
|
||||
return _client.request(
|
||||
path: '/v1/image/getCreditsPageInfo',
|
||||
method: 'GET',
|
||||
queryParams: {
|
||||
'sentinel': sentinel,
|
||||
if (asset != null) 'asset': asset,
|
||||
if (crest != null) 'crest': crest,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
72
lib/core/api/services/payment_api.dart
Normal file
@ -0,0 +1,72 @@
|
||||
import '../api_client.dart';
|
||||
import '../proxy_client.dart';
|
||||
|
||||
/// 支付相关 API
|
||||
abstract final class PaymentApi {
|
||||
static final _client = ApiClient.instance.proxy;
|
||||
|
||||
/// 获取 Google 商品列表
|
||||
static Future<ApiResponse> getGooglePayActivities({
|
||||
required String sentinel,
|
||||
String? shield,
|
||||
String? vambrace,
|
||||
String? portal,
|
||||
}) async {
|
||||
return _client.request(
|
||||
path: '/v1/payment/getGooglePayActivities',
|
||||
method: 'GET',
|
||||
queryParams: {
|
||||
'sentinel': sentinel,
|
||||
if (shield != null) 'shield': shield,
|
||||
if (vambrace != null) 'vambrace': vambrace,
|
||||
if (portal != null) 'portal': portal,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 获取 Apple 商品列表
|
||||
static Future<ApiResponse> getApplePayActivities({
|
||||
required String sentinel,
|
||||
String? shield,
|
||||
String? vambrace,
|
||||
String? portal,
|
||||
}) async {
|
||||
return _client.request(
|
||||
path: '/v1/payment/getApplePayActivities',
|
||||
method: 'GET',
|
||||
queryParams: {
|
||||
'sentinel': sentinel,
|
||||
if (shield != null) 'shield': shield,
|
||||
if (vambrace != null) 'vambrace': vambrace,
|
||||
if (portal != null) 'portal': portal,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 创建支付订单
|
||||
static Future<ApiResponse> createPayment({
|
||||
required String sentinel,
|
||||
required String asset,
|
||||
required String warrior,
|
||||
required String resource,
|
||||
String? lineage,
|
||||
String? armor,
|
||||
}) async {
|
||||
return _client.request(
|
||||
path: '/v1/payment/createPayment',
|
||||
method: 'POST',
|
||||
queryParams: {
|
||||
'sentinel': sentinel,
|
||||
'asset': asset,
|
||||
},
|
||||
body: {
|
||||
'sentinel': sentinel,
|
||||
'asset': asset,
|
||||
'warrior': warrior,
|
||||
'resource': resource,
|
||||
if (lineage != null) 'lineage': lineage,
|
||||
if (armor != null) 'armor': armor,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
75
lib/core/api/services/user_api.dart
Normal file
@ -0,0 +1,75 @@
|
||||
import '../api_client.dart';
|
||||
import '../api_config.dart';
|
||||
import '../proxy_client.dart';
|
||||
|
||||
/// 用户相关 API
|
||||
abstract final class UserApi {
|
||||
static final _client = ApiClient.instance.proxy;
|
||||
|
||||
/// 设备快速登录
|
||||
/// 参数使用 V2 字段名:digest=referer, resolution=sign, origin=deviceId
|
||||
static Future<ApiResponse> fastLogin({
|
||||
required String origin,
|
||||
required String resolution,
|
||||
String? digest,
|
||||
String? crest,
|
||||
String? accolade,
|
||||
}) async {
|
||||
return _client.request(
|
||||
path: '/v1/user/fast_login',
|
||||
method: 'POST',
|
||||
queryParams: {
|
||||
if (crest != null) 'crest': crest,
|
||||
'portal': ApiConfig.packageName,
|
||||
if (accolade != null) 'accolade': accolade,
|
||||
},
|
||||
body: {
|
||||
'digest': digest ?? '',
|
||||
'resolution': resolution,
|
||||
'origin': origin,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 获取用户通用信息
|
||||
static Future<ApiResponse> getCommonInfo({
|
||||
required String sentinel,
|
||||
String? shield,
|
||||
String? asset,
|
||||
String? crest,
|
||||
String? item,
|
||||
String? origin,
|
||||
String? gauntlet,
|
||||
String? portal,
|
||||
}) async {
|
||||
return _client.request(
|
||||
path: '/v1/user/common_info',
|
||||
method: 'GET',
|
||||
queryParams: {
|
||||
'sentinel': sentinel,
|
||||
if (shield != null) 'shield': shield,
|
||||
if (asset != null) 'asset': asset,
|
||||
if (crest != null) 'crest': crest,
|
||||
if (item != null) 'item': item,
|
||||
if (origin != null) 'origin': origin,
|
||||
if (gauntlet != null) 'gauntlet': gauntlet,
|
||||
if (portal != null) 'portal': portal,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 获取用户账户信息
|
||||
static Future<ApiResponse> getAccount({
|
||||
required String sentinel,
|
||||
String? asset,
|
||||
}) async {
|
||||
return _client.request(
|
||||
path: '/v1/user/account',
|
||||
method: 'GET',
|
||||
queryParams: {
|
||||
'sentinel': sentinel,
|
||||
if (asset != null) 'asset': asset,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
100
lib/core/auth/auth_service.dart
Normal file
@ -0,0 +1,100 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../api/api_client.dart';
|
||||
import '../api/proxy_client.dart';
|
||||
import '../api/services/user_api.dart';
|
||||
|
||||
/// 认证服务:APP 启动时执行快速登录
|
||||
class AuthService {
|
||||
AuthService._();
|
||||
|
||||
static const _tag = '[AuthService]';
|
||||
|
||||
static void _log(String msg) {
|
||||
debugPrint('$_tag $msg');
|
||||
}
|
||||
|
||||
/// 获取设备 ID(Android: androidId, iOS: identifierForVendor, Web: fallback)
|
||||
static Future<String> _getDeviceId() async {
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
switch (defaultTargetPlatform) {
|
||||
case TargetPlatform.android:
|
||||
final android = await deviceInfo.androidInfo;
|
||||
return android.id;
|
||||
case TargetPlatform.iOS:
|
||||
final ios = await deviceInfo.iosInfo;
|
||||
return ios.identifierForVendor ?? 'ios-unknown';
|
||||
default:
|
||||
return 'device-${DateTime.now().millisecondsSinceEpoch}';
|
||||
}
|
||||
}
|
||||
|
||||
/// 计算 sign:MD5(deviceId) 大写 32 位
|
||||
static String _computeSign(String deviceId) {
|
||||
final bytes = utf8.encode(deviceId);
|
||||
final digest = md5.convert(bytes);
|
||||
return digest.toString().toUpperCase();
|
||||
}
|
||||
|
||||
/// APP 启动时调用快速登录
|
||||
/// 启动时网络可能未就绪,会延迟后重试
|
||||
static Future<void> init() async {
|
||||
_log('init: 开始快速登录');
|
||||
const maxRetries = 3;
|
||||
const retryDelay = Duration(seconds: 2);
|
||||
|
||||
try {
|
||||
// 等待网络就绪(浏览器能访问但 App 报错时,多为启动时网络未初始化)
|
||||
await Future<void>.delayed(const Duration(seconds: 2));
|
||||
|
||||
final deviceId = await _getDeviceId();
|
||||
_log('init: deviceId=$deviceId');
|
||||
|
||||
final sign = _computeSign(deviceId);
|
||||
_log('init: sign=$sign');
|
||||
|
||||
ApiResponse? res;
|
||||
for (var i = 0; i < maxRetries; i++) {
|
||||
if (i > 0) {
|
||||
_log('init: 第 ${i + 1} 次重试,等待 ${retryDelay.inSeconds}s...');
|
||||
await Future<void>.delayed(retryDelay);
|
||||
}
|
||||
try {
|
||||
res = await UserApi.fastLogin(
|
||||
origin: deviceId,
|
||||
resolution: sign,
|
||||
digest: '',
|
||||
);
|
||||
break;
|
||||
} catch (e) {
|
||||
_log('init: 第 ${i + 1} 次请求失败: $e');
|
||||
if (i == maxRetries - 1) rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
if (res == null) return;
|
||||
|
||||
_log('init: 登录结果 code=${res.code} msg=${res.msg}');
|
||||
|
||||
if (res.isSuccess && res.data != null) {
|
||||
final data = res.data as Map<String, dynamic>?;
|
||||
final token = data?['reevaluate'] as String?;
|
||||
if (token != null && token.isNotEmpty) {
|
||||
ApiClient.instance.setUserToken(token);
|
||||
_log('init: 已设置 userToken');
|
||||
} else {
|
||||
_log('init: 响应中无 reevaluate (userToken)');
|
||||
}
|
||||
} else {
|
||||
_log('init: 登录失败');
|
||||
}
|
||||
} catch (e, st) {
|
||||
_log('init: 异常 $e');
|
||||
_log('init: 堆栈 $st');
|
||||
}
|
||||
}
|
||||
}
|
||||
30
lib/core/theme/app_colors.dart
Normal file
@ -0,0 +1,30 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Design tokens for AI Video App - 1:1 from Pencil design
|
||||
abstract final class AppColors {
|
||||
// Primary
|
||||
static const Color primary = Color(0xFF8B5CF6);
|
||||
static const Color primaryLight = Color(0x338B5CF6); // #8B5CF620
|
||||
static const Color primaryShadow = Color(0x338B5CF6); // #8B5CF620 for shadow
|
||||
|
||||
// Neutrals
|
||||
static const Color background = Color(0xFFFAFAFA);
|
||||
static const Color surface = Color(0xFFFFFFFF);
|
||||
static const Color surfaceAlt = Color(0xFFF4F4F5);
|
||||
static const Color border = Color(0xFFE4E4E7);
|
||||
|
||||
// Text
|
||||
static const Color textPrimary = Color(0xFF18181B);
|
||||
static const Color textSecondary = Color(0xFF71717A);
|
||||
static const Color textMuted = Color(0xFFA1A1AA);
|
||||
|
||||
// Accent
|
||||
static const Color accentOrange = Color(0xFFF59E0B);
|
||||
static const Color accentOrangeLight = Color(0x33F59E0B); // #F59E0B20
|
||||
|
||||
// Overlay
|
||||
static const Color overlayDark = Color(0x99000000);
|
||||
static const Color shadowLight = Color(0x14000000); // #00000008
|
||||
static const Color shadowMedium = Color(0x0D000000); // #0000000D
|
||||
static const Color shadowSoft = Color(0x0A000000); // #0000000A
|
||||
}
|
||||
14
lib/core/theme/app_spacing.dart
Normal file
@ -0,0 +1,14 @@
|
||||
/// Spacing tokens from Pencil design
|
||||
abstract final class AppSpacing {
|
||||
static const double xs = 4;
|
||||
static const double sm = 6;
|
||||
static const double md = 8;
|
||||
static const double lg = 12;
|
||||
static const double xl = 16;
|
||||
static const double xxl = 20;
|
||||
static const double xxxl = 24;
|
||||
|
||||
// Horizontal padding
|
||||
static const double screenPadding = 20;
|
||||
static const double screenPaddingLarge = 24;
|
||||
}
|
||||
17
lib/core/theme/app_theme.dart
Normal file
@ -0,0 +1,17 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'app_colors.dart';
|
||||
|
||||
/// App theme configuration
|
||||
abstract final class AppTheme {
|
||||
static ThemeData get light => ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: AppColors.primary,
|
||||
brightness: Brightness.light,
|
||||
primary: AppColors.primary,
|
||||
surface: AppColors.surface,
|
||||
),
|
||||
scaffoldBackgroundColor: AppColors.background,
|
||||
fontFamily: 'Inter',
|
||||
);
|
||||
}
|
||||
46
lib/core/theme/app_typography.dart
Normal file
@ -0,0 +1,46 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
/// Typography tokens from Pencil design
|
||||
abstract final class AppTypography {
|
||||
// Plus Jakarta Sans - for nav titles
|
||||
static TextStyle navTitle = GoogleFonts.plusJakartaSans(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: const Color(0xFF18181B),
|
||||
);
|
||||
|
||||
// Inter - body text
|
||||
static TextStyle bodyLarge = GoogleFonts.inter(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
);
|
||||
static TextStyle bodyMedium = GoogleFonts.inter(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
);
|
||||
static TextStyle bodyRegular = GoogleFonts.inter(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
);
|
||||
static TextStyle bodySmall = GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
);
|
||||
static TextStyle caption = GoogleFonts.inter(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
);
|
||||
static TextStyle label = GoogleFonts.inter(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
);
|
||||
static TextStyle tabLabel = GoogleFonts.inter(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
);
|
||||
static TextStyle tabLabelInactive = GoogleFonts.inter(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w500,
|
||||
);
|
||||
}
|
||||
103
lib/features/gallery/gallery_screen.dart
Normal file
@ -0,0 +1,103 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_spacing.dart';
|
||||
import '../../shared/widgets/top_nav_bar.dart';
|
||||
|
||||
/// Gallery screen - matches Pencil hpwBg
|
||||
class GalleryScreen extends StatelessWidget {
|
||||
const GalleryScreen({super.key});
|
||||
|
||||
static const _galleryImages = [
|
||||
'https://images.unsplash.com/photo-1763929272543-0df093e4f659?w=400',
|
||||
'https://images.unsplash.com/photo-1703592819695-ea63799b7315?w=400',
|
||||
'https://images.unsplash.com/photo-1764787435677-1321e12559e3?w=400',
|
||||
'https://images.unsplash.com/photo-1759264244741-7175af0b7e75?w=400',
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(56),
|
||||
child: TopNavBar(
|
||||
title: 'Gallery',
|
||||
credits: '1,280',
|
||||
onCreditsTap: () => Navigator.of(context).pushNamed('/recharge'),
|
||||
),
|
||||
),
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 390),
|
||||
child: GridView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.screenPadding,
|
||||
AppSpacing.xl,
|
||||
AppSpacing.screenPadding,
|
||||
AppSpacing.screenPaddingLarge,
|
||||
),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
childAspectRatio: 165 / 248,
|
||||
mainAxisSpacing: AppSpacing.xl,
|
||||
crossAxisSpacing: AppSpacing.xl,
|
||||
),
|
||||
itemCount: _galleryImages.length,
|
||||
itemBuilder: (context, index) =>
|
||||
_GalleryCard(imageUrl: _galleryImages[index]),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class _GalleryCard extends StatelessWidget {
|
||||
const _GalleryCard({required this.imageUrl});
|
||||
|
||||
final String imageUrl;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return Container(
|
||||
width: constraints.maxWidth,
|
||||
height: constraints.maxHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceAlt,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(color: AppColors.border, width: 1),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.shadowMedium,
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => Container(
|
||||
color: AppColors.surfaceAlt,
|
||||
),
|
||||
errorWidget: (_, __, ___) => Container(
|
||||
color: AppColors.surfaceAlt,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
136
lib/features/generate_video/generate_progress_screen.dart
Normal file
@ -0,0 +1,136 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_spacing.dart';
|
||||
import '../../core/theme/app_typography.dart';
|
||||
import '../../shared/widgets/top_nav_bar.dart';
|
||||
|
||||
/// Generate Video Progress screen - matches Pencil qGs6n
|
||||
class GenerateProgressScreen extends StatefulWidget {
|
||||
const GenerateProgressScreen({super.key});
|
||||
|
||||
@override
|
||||
State<GenerateProgressScreen> createState() => _GenerateProgressScreenState();
|
||||
}
|
||||
|
||||
class _GenerateProgressScreenState extends State<GenerateProgressScreen> {
|
||||
double _progress = 0.45;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(56),
|
||||
child: TopNavBar(
|
||||
title: 'Generating',
|
||||
showBackButton: true,
|
||||
onBack: () => Navigator.of(context).pop(),
|
||||
),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(AppSpacing.screenPaddingLarge),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_VideoPreview(),
|
||||
const SizedBox(height: AppSpacing.xxl),
|
||||
Text(
|
||||
'Video generation may take some time. Please wait patiently.',
|
||||
textAlign: TextAlign.center,
|
||||
style: AppTypography.bodyRegular.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xxl),
|
||||
_ProgressSection(progress: _progress),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _VideoPreview extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 360,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.textPrimary,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border, width: 1),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.film,
|
||||
size: 64,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Text(
|
||||
'Video Preview',
|
||||
style: AppTypography.bodyRegular.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProgressSection extends StatelessWidget {
|
||||
const _ProgressSection({required this.progress});
|
||||
|
||||
final double progress;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final percentage = (progress * 100).round();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Generating... $percentage%',
|
||||
style: AppTypography.bodyMedium.copyWith(
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final fillWidth =
|
||||
constraints.maxWidth * progress.clamp(0.0, 1.0);
|
||||
return Stack(
|
||||
children: [
|
||||
Container(
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.border,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
width: fillWidth,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
209
lib/features/generate_video/generate_video_screen.dart
Normal file
@ -0,0 +1,209 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_spacing.dart';
|
||||
import '../../core/theme/app_typography.dart';
|
||||
import '../../shared/widgets/top_nav_bar.dart';
|
||||
|
||||
/// Generate Video screen - matches Pencil mmLB5
|
||||
class GenerateVideoScreen extends StatelessWidget {
|
||||
const GenerateVideoScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(56),
|
||||
child: TopNavBar(
|
||||
title: 'Generate Video',
|
||||
showBackButton: true,
|
||||
onBack: () => Navigator.of(context).pop(),
|
||||
),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(AppSpacing.screenPaddingLarge),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_CreditsCard(credits: '1,280'),
|
||||
const SizedBox(height: AppSpacing.xxl),
|
||||
_UploadArea(onUpload: () {}),
|
||||
const SizedBox(height: AppSpacing.xxl),
|
||||
_GenerateButton(
|
||||
onGenerate: () =>
|
||||
Navigator.of(context).pushReplacementNamed('/progress'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CreditsCard extends StatelessWidget {
|
||||
const _CreditsCard({required this.credits});
|
||||
|
||||
final String credits;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.xxl,
|
||||
vertical: AppSpacing.xl,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: AppColors.primary.withValues(alpha: 0.5),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.primaryShadow.withValues(alpha: 0.25),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(LucideIcons.sparkles, size: 28, color: AppColors.surface),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Available Credits',
|
||||
style: AppTypography.bodyRegular.copyWith(
|
||||
color: AppColors.surface.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
credits,
|
||||
style: AppTypography.bodyLarge.copyWith(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.surface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _UploadArea extends StatelessWidget {
|
||||
const _UploadArea({required this.onUpload});
|
||||
|
||||
final VoidCallback onUpload;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onUpload,
|
||||
child: Container(
|
||||
height: 280,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceAlt,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: AppColors.border,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.image_plus,
|
||||
size: 48,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
SizedBox(
|
||||
width: 280,
|
||||
child: Text(
|
||||
'Please upload an image as the base for generation',
|
||||
textAlign: TextAlign.center,
|
||||
style: AppTypography.bodyRegular.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GenerateButton extends StatelessWidget {
|
||||
const _GenerateButton({required this.onGenerate});
|
||||
|
||||
final VoidCallback onGenerate;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onGenerate,
|
||||
child: Container(
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.primaryShadow.withValues(alpha: 0.25),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'Generate Video',
|
||||
style: AppTypography.bodyMedium.copyWith(
|
||||
color: AppColors.surface,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: AppSpacing.xs,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.sparkles,
|
||||
size: 16,
|
||||
color: AppColors.surface,
|
||||
),
|
||||
const SizedBox(width: AppSpacing.xs),
|
||||
Text(
|
||||
'50',
|
||||
style: AppTypography.bodyRegular.copyWith(
|
||||
color: AppColors.surface,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
151
lib/features/generate_video/generation_result_screen.dart
Normal file
@ -0,0 +1,151 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_spacing.dart';
|
||||
import '../../core/theme/app_typography.dart';
|
||||
import '../../shared/widgets/top_nav_bar.dart';
|
||||
|
||||
/// Video Generation Result screen - matches Pencil cFA4T
|
||||
class GenerationResultScreen extends StatelessWidget {
|
||||
const GenerationResultScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(56),
|
||||
child: TopNavBar(
|
||||
title: 'Video Ready',
|
||||
showBackButton: true,
|
||||
onBack: () => Navigator.of(context).pop(),
|
||||
),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(AppSpacing.screenPaddingLarge),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_VideoDisplay(),
|
||||
const SizedBox(height: AppSpacing.xxl),
|
||||
_DownloadButton(onDownload: () {}),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
_ShareButton(onShare: () {}),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _VideoDisplay extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 360,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.textPrimary,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border, width: 1),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.play,
|
||||
size: 72,
|
||||
color: AppColors.surface.withValues(alpha: 0.5),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Text(
|
||||
'Your video is ready',
|
||||
style: AppTypography.bodyRegular.copyWith(
|
||||
color: AppColors.surface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DownloadButton extends StatelessWidget {
|
||||
const _DownloadButton({required this.onDownload});
|
||||
|
||||
final VoidCallback onDownload;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onDownload,
|
||||
child: Container(
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.primaryShadow.withValues(alpha: 0.25),
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(LucideIcons.download, size: 20, color: AppColors.surface),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Text(
|
||||
'Download',
|
||||
style: AppTypography.bodyMedium.copyWith(
|
||||
color: AppColors.surface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ShareButton extends StatelessWidget {
|
||||
const _ShareButton({required this.onShare});
|
||||
|
||||
final VoidCallback onShare;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onShare,
|
||||
child: Container(
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.shadowLight,
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(LucideIcons.share_2, size: 20, color: AppColors.primary),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Text(
|
||||
'Share',
|
||||
style: AppTypography.bodyMedium.copyWith(
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
90
lib/features/home/home_screen.dart
Normal file
@ -0,0 +1,90 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../core/theme/app_spacing.dart';
|
||||
import '../../shared/widgets/top_nav_bar.dart';
|
||||
import 'widgets/home_tab_row.dart';
|
||||
import 'widgets/video_card.dart';
|
||||
|
||||
/// AI Video App home screen - matches Pencil bi8Au
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
State<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> {
|
||||
HomeTab _selectedTab = HomeTab.all;
|
||||
|
||||
static const _placeholderImages = [
|
||||
'https://images.unsplash.com/photo-1763929272543-0df093e4f659?w=400',
|
||||
'https://images.unsplash.com/photo-1703592819695-ea63799b7315?w=400',
|
||||
'https://images.unsplash.com/photo-1764787435677-1321e12559e3?w=400',
|
||||
'https://images.unsplash.com/photo-1759264244741-7175af0b7e75?w=400',
|
||||
'https://images.unsplash.com/photo-1574717024653-61fd2cf4d44d?w=400',
|
||||
'https://images.unsplash.com/photo-1611162617474-5b21e879e113?w=400',
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFFAFAFA),
|
||||
appBar: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(56),
|
||||
child: TopNavBar(
|
||||
title: 'AI Video',
|
||||
credits: '1,280',
|
||||
onCreditsTap: () => Navigator.of(context).pushNamed('/recharge'),
|
||||
),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.screenPadding,
|
||||
vertical: AppSpacing.xs,
|
||||
),
|
||||
child: HomeTabRow(
|
||||
selectedTab: _selectedTab,
|
||||
onTabChanged: (tab) => setState(() => _selectedTab = tab),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: 390,
|
||||
),
|
||||
child: GridView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.screenPadding,
|
||||
AppSpacing.xl,
|
||||
AppSpacing.screenPadding,
|
||||
AppSpacing.screenPaddingLarge,
|
||||
),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
childAspectRatio: 165 / 248,
|
||||
mainAxisSpacing: AppSpacing.xl,
|
||||
crossAxisSpacing: AppSpacing.xl,
|
||||
),
|
||||
itemCount: _placeholderImages.length,
|
||||
itemBuilder: (context, index) => VideoCard(
|
||||
imageUrl: _placeholderImages[index],
|
||||
onGenerateSimilar: () =>
|
||||
Navigator.of(context).pushNamed('/generate'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
98
lib/features/home/widgets/home_tab_row.dart
Normal file
@ -0,0 +1,98 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_spacing.dart';
|
||||
import '../../../core/theme/app_typography.dart';
|
||||
|
||||
enum HomeTab { all, trending, newTab }
|
||||
|
||||
/// Tab row for home screen - matches Pencil tabRow
|
||||
class HomeTabRow extends StatelessWidget {
|
||||
const HomeTabRow({
|
||||
super.key,
|
||||
required this.selectedTab,
|
||||
required this.onTabChanged,
|
||||
});
|
||||
|
||||
final HomeTab selectedTab;
|
||||
final ValueChanged<HomeTab> onTabChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 40,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
_TabChip(
|
||||
label: 'All',
|
||||
isSelected: selectedTab == HomeTab.all,
|
||||
onTap: () => onTabChanged(HomeTab.all),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
_TabChip(
|
||||
label: 'Trending',
|
||||
isSelected: selectedTab == HomeTab.trending,
|
||||
onTap: () => onTabChanged(HomeTab.trending),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
_TabChip(
|
||||
label: 'New',
|
||||
isSelected: selectedTab == HomeTab.newTab,
|
||||
onTap: () => onTabChanged(HomeTab.newTab),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TabChip extends StatelessWidget {
|
||||
const _TabChip({
|
||||
required this.label,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
height: 32,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? AppColors.primary : AppColors.surfaceAlt,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: isSelected ? null : Border.all(color: AppColors.border),
|
||||
boxShadow: isSelected
|
||||
? [
|
||||
BoxShadow(
|
||||
color: AppColors.primaryShadow.withValues(alpha: 0.19),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
label,
|
||||
style: AppTypography.bodySmall.copyWith(
|
||||
color: isSelected ? AppColors.surface : AppColors.textSecondary,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
157
lib/features/home/widgets/video_card.dart
Normal file
@ -0,0 +1,157 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_spacing.dart';
|
||||
|
||||
/// Video card for home grid - matches Pencil card1
|
||||
class VideoCard extends StatelessWidget {
|
||||
const VideoCard({
|
||||
super.key,
|
||||
required this.imageUrl,
|
||||
this.credits = '50',
|
||||
this.onTap,
|
||||
this.onGenerateSimilar,
|
||||
});
|
||||
|
||||
final String imageUrl;
|
||||
final String credits;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onGenerateSimilar;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return Container(
|
||||
width: constraints.maxWidth,
|
||||
height: constraints.maxHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceAlt,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(color: AppColors.border, width: 1),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.shadowMedium,
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => Container(
|
||||
color: AppColors.surfaceAlt,
|
||||
),
|
||||
errorWidget: (_, __, ___) => Container(
|
||||
color: AppColors.surfaceAlt,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 12,
|
||||
right: 12,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.md,
|
||||
vertical: AppSpacing.xs,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.overlayDark,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.sparkles,
|
||||
size: 12,
|
||||
color: AppColors.surface,
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Text(
|
||||
credits,
|
||||
style: const TextStyle(
|
||||
color: AppColors.surface,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: 'Inter',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: Center(
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface.withValues(alpha: 0.9),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.13),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.play,
|
||||
size: 24,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 12,
|
||||
left: 12,
|
||||
right: 12,
|
||||
child: GestureDetector(
|
||||
onTap: onGenerateSimilar,
|
||||
child: Container(
|
||||
height: 44,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.primaryShadow.withValues(alpha: 0.25),
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const Text(
|
||||
'Generate Similar',
|
||||
style: TextStyle(
|
||||
color: AppColors.surface,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: 'Inter',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
275
lib/features/profile/profile_screen.dart
Normal file
@ -0,0 +1,275 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_spacing.dart';
|
||||
import '../../core/theme/app_typography.dart';
|
||||
import '../../shared/widgets/top_nav_bar.dart';
|
||||
|
||||
/// Profile screen - matches Pencil KXeow
|
||||
class ProfileScreen extends StatelessWidget {
|
||||
const ProfileScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(56),
|
||||
child: TopNavBar(
|
||||
title: 'Profile',
|
||||
credits: '1,280',
|
||||
onCreditsTap: () => Navigator.of(context).pushNamed('/recharge'),
|
||||
),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.screenPadding,
|
||||
AppSpacing.xxl,
|
||||
AppSpacing.screenPadding,
|
||||
AppSpacing.screenPaddingLarge,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_ProfileHeader(
|
||||
userName: 'Alex Johnson',
|
||||
uid: 'UID 84920133',
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
_BalanceCard(
|
||||
balance: '1,280',
|
||||
onRecharge: () => Navigator.of(context).pushNamed('/recharge'),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xxl),
|
||||
_MenuSection(
|
||||
items: [
|
||||
_MenuItem(
|
||||
title: 'Credit Store',
|
||||
icon: LucideIcons.chevron_right,
|
||||
onTap: () => Navigator.of(context).pushNamed('/recharge'),
|
||||
),
|
||||
_MenuItem(
|
||||
title: 'Settings',
|
||||
icon: LucideIcons.chevron_right,
|
||||
onTap: () {},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProfileHeader extends StatelessWidget {
|
||||
const _ProfileHeader({
|
||||
required this.userName,
|
||||
required this.uid,
|
||||
});
|
||||
|
||||
final String userName;
|
||||
final String uid;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 220,
|
||||
alignment: Alignment.center,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.border,
|
||||
borderRadius: BorderRadius.circular(40),
|
||||
border: Border.all(
|
||||
color: AppColors.primaryLight,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.user,
|
||||
size: 40,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Text(
|
||||
userName,
|
||||
style: AppTypography.bodyLarge.copyWith(
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.lg,
|
||||
vertical: AppSpacing.sm,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceAlt,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Text(
|
||||
uid,
|
||||
style: AppTypography.caption.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BalanceCard extends StatelessWidget {
|
||||
const _BalanceCard({
|
||||
required this.balance,
|
||||
required this.onRecharge,
|
||||
});
|
||||
|
||||
final String balance;
|
||||
final VoidCallback onRecharge;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.xxl,
|
||||
vertical: AppSpacing.xl,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.shadowLight,
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'AVAILABLE BALANCE',
|
||||
style: AppTypography.label.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Text(
|
||||
balance,
|
||||
style: AppTypography.bodyLarge.copyWith(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: onRecharge,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: AppSpacing.md,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'Recharge',
|
||||
style: AppTypography.bodyRegular.copyWith(
|
||||
color: AppColors.surface,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MenuSection extends StatelessWidget {
|
||||
const _MenuSection({required this.items});
|
||||
|
||||
final List<_MenuItem> items;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: items
|
||||
.map(
|
||||
(item) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppSpacing.md),
|
||||
child: _MenuItem(
|
||||
title: item.title,
|
||||
icon: item.icon,
|
||||
onTap: item.onTap,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MenuItem extends StatelessWidget {
|
||||
const _MenuItem({
|
||||
required this.title,
|
||||
required this.icon,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final IconData icon;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.xl,
|
||||
vertical: 14,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.shadowLight,
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: AppTypography.bodyRegular.copyWith(
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
Icon(icon, size: 20, color: AppColors.textMuted),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
383
lib/features/recharge/recharge_screen.dart
Normal file
@ -0,0 +1,383 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_spacing.dart';
|
||||
import '../../core/theme/app_typography.dart';
|
||||
import '../../shared/widgets/top_nav_bar.dart';
|
||||
|
||||
/// Recharge screen - matches Pencil tPjdN
|
||||
class RechargeScreen extends StatelessWidget {
|
||||
const RechargeScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(56),
|
||||
child: TopNavBar(
|
||||
title: 'Recharge',
|
||||
showBackButton: true,
|
||||
onBack: () => Navigator.of(context).pop(),
|
||||
),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(bottom: AppSpacing.screenPaddingLarge),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_CreditsSection(currentCredits: '1,280'),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.screenPaddingLarge,
|
||||
vertical: AppSpacing.xxl,
|
||||
),
|
||||
child: Text(
|
||||
'Select Tier',
|
||||
style: AppTypography.bodyMedium.copyWith(
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
_TierCard(
|
||||
credits: '100 Credits',
|
||||
price: '¥6',
|
||||
onBuy: () {},
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
_TierCardRecommended(
|
||||
credits: '500 Credits',
|
||||
price: '¥25 Save ¥5',
|
||||
onBuy: () {},
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
_TierCardPopular(
|
||||
credits: '1,000 Credits',
|
||||
price: '¥45 Save ¥15',
|
||||
onBuy: () {},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CreditsSection extends StatelessWidget {
|
||||
const _CreditsSection({required this.currentCredits});
|
||||
|
||||
final String currentCredits;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(AppSpacing.screenPadding),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.xxl,
|
||||
vertical: AppSpacing.xl,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: AppColors.primary.withValues(alpha: 0.5),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.primaryShadow.withValues(alpha: 0.25),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(LucideIcons.sparkles, size: 28, color: AppColors.surface),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Text(
|
||||
currentCredits,
|
||||
style: AppTypography.bodyLarge.copyWith(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.surface,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'Current Credits',
|
||||
style: AppTypography.caption.copyWith(
|
||||
color: AppColors.surface.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TierCard extends StatelessWidget {
|
||||
const _TierCard({
|
||||
required this.credits,
|
||||
required this.price,
|
||||
required this.onBuy,
|
||||
});
|
||||
|
||||
final String credits;
|
||||
final String price;
|
||||
final VoidCallback onBuy;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.screenPadding),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.xxl,
|
||||
vertical: AppSpacing.xl,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.shadowLight,
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
credits,
|
||||
style: AppTypography.bodyLarge.copyWith(
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
price,
|
||||
style: AppTypography.bodyRegular.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
_BuyButton(onTap: onBuy),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TierCardRecommended extends StatelessWidget {
|
||||
const _TierCardRecommended({
|
||||
required this.credits,
|
||||
required this.price,
|
||||
required this.onBuy,
|
||||
});
|
||||
|
||||
final String credits;
|
||||
final String price;
|
||||
final VoidCallback onBuy;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Container(
|
||||
margin:
|
||||
const EdgeInsets.symmetric(horizontal: AppSpacing.screenPadding),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.xxl,
|
||||
vertical: AppSpacing.xl,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.shadowLight,
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
credits,
|
||||
style: AppTypography.bodyLarge.copyWith(
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Text(
|
||||
price,
|
||||
style: AppTypography.bodyRegular.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
_BuyButton(onTap: onBuy),
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: AppSpacing.screenPadding,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.md,
|
||||
vertical: AppSpacing.xs,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryLight,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topRight: Radius.circular(16),
|
||||
bottomLeft: Radius.circular(16),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'Recommended',
|
||||
style: AppTypography.label.copyWith(
|
||||
color: AppColors.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TierCardPopular extends StatelessWidget {
|
||||
const _TierCardPopular({
|
||||
required this.credits,
|
||||
required this.price,
|
||||
required this.onBuy,
|
||||
});
|
||||
|
||||
final String credits;
|
||||
final String price;
|
||||
final VoidCallback onBuy;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Container(
|
||||
margin:
|
||||
const EdgeInsets.symmetric(horizontal: AppSpacing.screenPadding),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.xxl,
|
||||
vertical: AppSpacing.xl,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.shadowLight,
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
credits,
|
||||
style: AppTypography.bodyLarge.copyWith(
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Text(
|
||||
price,
|
||||
style: AppTypography.bodyRegular.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
_BuyButton(onTap: onBuy),
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: AppSpacing.screenPadding,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.md,
|
||||
vertical: AppSpacing.xs,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.accentOrangeLight,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topRight: Radius.circular(16),
|
||||
bottomLeft: Radius.circular(16),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'Most Popular',
|
||||
style: AppTypography.label.copyWith(
|
||||
color: AppColors.accentOrange,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BuyButton extends StatelessWidget {
|
||||
const _BuyButton({required this.onTap});
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.xl,
|
||||
vertical: AppSpacing.md,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'Buy',
|
||||
style: AppTypography.bodyRegular.copyWith(
|
||||
color: AppColors.surface,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
11
lib/main.dart
Normal file
@ -0,0 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'app.dart';
|
||||
import 'core/auth/auth_service.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
runApp(const App());
|
||||
// APP 打开时后台执行快速登录
|
||||
AuthService.init();
|
||||
}
|
||||
122
lib/shared/widgets/bottom_nav_bar.dart
Normal file
@ -0,0 +1,122 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_spacing.dart';
|
||||
import '../../core/theme/app_typography.dart';
|
||||
|
||||
enum NavTab { home, gallery, profile }
|
||||
|
||||
/// Bottom navigation bar - matches Pencil bottomNav
|
||||
class BottomNavBar extends StatelessWidget {
|
||||
const BottomNavBar({
|
||||
super.key,
|
||||
required this.currentTab,
|
||||
required this.onTabSelected,
|
||||
});
|
||||
|
||||
final NavTab currentTab;
|
||||
final ValueChanged<NavTab> onTabSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: 56,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.screenPadding,
|
||||
vertical: 7.5,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.shadowSoft,
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, -4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _NavTabItem(
|
||||
icon: LucideIcons.house,
|
||||
label: 'HOME',
|
||||
isSelected: currentTab == NavTab.home,
|
||||
onTap: () => onTabSelected(NavTab.home),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _NavTabItem(
|
||||
icon: LucideIcons.images,
|
||||
label: 'GALLERY',
|
||||
isSelected: currentTab == NavTab.gallery,
|
||||
onTap: () => onTabSelected(NavTab.gallery),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _NavTabItem(
|
||||
icon: LucideIcons.user,
|
||||
label: 'PROFILE',
|
||||
isSelected: currentTab == NavTab.profile,
|
||||
onTap: () => onTabSelected(NavTab.profile),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NavTabItem extends StatelessWidget {
|
||||
const _NavTabItem({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
height: 41,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? AppColors.primary : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(26),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 18,
|
||||
color: isSelected ? AppColors.surface : AppColors.textMuted,
|
||||
),
|
||||
const SizedBox(width: AppSpacing.xs),
|
||||
Text(
|
||||
label,
|
||||
style: (isSelected
|
||||
? AppTypography.tabLabel
|
||||
: AppTypography.tabLabelInactive)
|
||||
.copyWith(
|
||||
color: isSelected ? AppColors.surface : AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
56
lib/shared/widgets/credits_badge.dart
Normal file
@ -0,0 +1,56 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_spacing.dart';
|
||||
import '../../core/theme/app_typography.dart';
|
||||
|
||||
/// Credits badge with sparkles icon - matches Pencil creditsBadge
|
||||
class CreditsBadge extends StatelessWidget {
|
||||
const CreditsBadge({
|
||||
super.key,
|
||||
required this.credits,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final String credits;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.lg,
|
||||
vertical: AppSpacing.sm,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryLight,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.primaryShadow.withValues(alpha: 0.13),
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(LucideIcons.sparkles, size: 16, color: AppColors.primary),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Text(
|
||||
credits,
|
||||
style: AppTypography.bodyRegular.copyWith(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
76
lib/shared/widgets/top_nav_bar.dart
Normal file
@ -0,0 +1,76 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_spacing.dart';
|
||||
import '../../core/theme/app_typography.dart';
|
||||
import 'credits_badge.dart';
|
||||
|
||||
/// Top navigation bar - matches Pencil topNav
|
||||
class TopNavBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
const TopNavBar({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.credits,
|
||||
this.showBackButton = false,
|
||||
this.onBack,
|
||||
this.onCreditsTap,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String? credits;
|
||||
final bool showBackButton;
|
||||
final VoidCallback? onBack;
|
||||
final VoidCallback? onCreditsTap;
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(56);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 56,
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.screenPadding),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.shadowLight,
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
if (showBackButton)
|
||||
GestureDetector(
|
||||
onTap: onBack ?? () => Navigator.of(context).pop(),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
LucideIcons.arrow_left,
|
||||
size: 24,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox(width: 40),
|
||||
Text(
|
||||
title,
|
||||
style: AppTypography.navTitle,
|
||||
),
|
||||
if (credits != null)
|
||||
CreditsBadge(credits: credits!, onTap: onCreditsTap)
|
||||
else
|
||||
const SizedBox(width: 40),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
27
pubspec.yaml
Normal file
@ -0,0 +1,27 @@
|
||||
name: app_client
|
||||
description: A new Flutter project.
|
||||
publish_to: 'none'
|
||||
version: 1.0.0+1
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
cupertino_icons: ^1.0.6
|
||||
flutter_lucide: ^1.8.2
|
||||
google_fonts: ^6.2.1
|
||||
cached_network_image: ^3.3.1
|
||||
crypto: ^3.0.3
|
||||
device_info_plus: ^11.1.0
|
||||
encrypt: ^5.0.3
|
||||
http: ^1.2.2
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^3.0.0
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
51
scripts/dev_proxy.js
Normal file
@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 本地转发代理:当手机无法直连 pre-ai.petsheroai.xyz 时使用
|
||||
* 电脑能访问域名,手机连电脑同一 WiFi,通过此代理转发请求
|
||||
*
|
||||
* 使用:
|
||||
* 1. 手机和电脑连同一 WiFi
|
||||
* 2. 运行: node scripts/dev_proxy.js
|
||||
* 3. 在 api_config.dart 中设置 debugBaseUrlOverride = 'http://<电脑IP>:8010'
|
||||
* 4. 手机运行 app
|
||||
*
|
||||
* 获取电脑 IP: ifconfig (Mac/Linux) 或 ipconfig (Windows)
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
|
||||
const TARGET = 'https://pre-ai.petsheroai.xyz';
|
||||
const PORT = 8010;
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const url = TARGET + req.url;
|
||||
console.log(`[${new Date().toISOString()}] ${req.method} ${req.url} -> ${url}`);
|
||||
|
||||
const options = {
|
||||
hostname: 'pre-ai.petsheroai.xyz',
|
||||
port: 443,
|
||||
path: req.url,
|
||||
method: req.method,
|
||||
headers: { ...req.headers, host: 'pre-ai.petsheroai.xyz' },
|
||||
};
|
||||
|
||||
const proxy = https.request(options, (proxyRes) => {
|
||||
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
||||
proxyRes.pipe(res);
|
||||
});
|
||||
|
||||
proxy.on('error', (e) => {
|
||||
console.error('Proxy error:', e.message);
|
||||
res.writeHead(502);
|
||||
res.end('Proxy error: ' + e.message);
|
||||
});
|
||||
|
||||
req.pipe(proxy);
|
||||
});
|
||||
|
||||
server.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`Dev proxy listening on http://0.0.0.0:${PORT}`);
|
||||
console.log(`Forwarding to ${TARGET}`);
|
||||
console.log('Set debugBaseUrlOverride = "http://<YOUR_IP>:8010" in api_config.dart');
|
||||
});
|
||||
11
test/widget_test.dart
Normal file
@ -0,0 +1,11 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:app_client/app.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('App launches and shows AI Video home content', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(const App());
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('AI Video'), findsOneWidget);
|
||||
expect(find.text('HOME'), findsOneWidget);
|
||||
});
|
||||
}
|
||||