新增:基础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);
|
||||||
|
});
|
||||||
|
}
|
||||||