新增:基础UI加登录接口

This commit is contained in:
ivan 2026-03-09 11:41:49 +08:00
commit 0cff1e509d
68 changed files with 9186 additions and 0 deletions

52
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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 '../..'
}

View 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>

View File

@ -0,0 +1,5 @@
package com.petsheroai.app
import io.flutter.embedding.android.FlutterActivity
class MainActivity: FlutterActivity()

View 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>

View 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
View 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
}

View 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=

View 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
View 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
View File

20
design/README.md Normal file
View 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` 文件即可编辑和预览设计。

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

3408
design/pencil-app-client.pen Normal file

File diff suppressed because it is too large Load Diff

165
docs/api_flow_summary.md Normal file
View 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, // helm0=成功
"msg": "", // rampart消息
"data": {} // sidekick业务数据
}
```
## 六、错误码
| code | 说明 |
|------|------|
| 0 | 成功 |
| -1 | 系统错误 |
| -2 | 未登录 |
| -3 | 无权限 |
| -4 | 请求过于频繁 |
| -5 | 参数错误 |
| 1001 | 积分不足 |
| 1002 | 免费次数已用完 |
| 1003 | 免费次数和积分均已用完 |

File diff suppressed because it is too large Load Diff

View 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

View 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 --")

View File

@ -0,0 +1,5 @@
#
# Generated file, do not edit.
#
command script import --relative-to-command-file flutter_lldb_helper.py

View 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"

View 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)
}
}

View 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 */

View 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
View 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
View 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
View 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';

View 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;
}

View 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';
}

View 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();
}
}

View 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 portalknight
/// [queryParams] 使 V2 sentinelasset
/// [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;
}

View 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,
},
);
}
}

View 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,
},
);
}
}

View 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,
},
);
}
}

View 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');
}
/// IDAndroid: 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}';
}
}
/// signMD5(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');
}
}
}

View 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
}

View 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;
}

View 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',
);
}

View 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,
);
}

View 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,
),
),
),
);
},
);
}
}

View 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),
),
),
),
],
);
},
),
],
);
}
}

View 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,
),
),
],
),
),
],
),
),
);
}
}

View 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,
),
),
],
),
),
);
}
}

View 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'),
),
),
),
);
},
),
),
],
),
);
}
}

View 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,
),
),
),
),
);
}
}

View 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',
),
),
),
),
),
],
),
),
);
},
);
}
}

View 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),
],
),
),
);
}
}

View 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
View 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();
}

View 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,
),
),
],
),
),
);
}
}

View 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,
),
),
],
),
),
);
}
}

View 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
View 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
View 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
View 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);
});
}