feat(tsk_iRa5iBC4v7Tu): Flutter 客户端工程搭建(演示态)
基于 design/flutter/ 起步包在 client/ 下建立 Flutter 工程:
- pubspec.yaml:flutter_svg / lucide_icons / google_fonts 依赖;
SVG 资产注册;字体由 google_fonts 运行时加载,无需本地 ttf
- lib/pangolin_theme.dart:完整设计令牌(色阶 / 间距 / 圆角 /
动效 / 文字阶),PangolinText 改用 GoogleFonts getter,
PangolinTheme.light / .dark 开箱即用
- lib/main.dart:runApp + 明暗主题 + 登录 → 引导 → 主框架 RootFlow
- lib/widgets/:全套组件雏形
pangolin_icons Lucide 图标映射
pangolin_button 胶囊按钮四态
country_code 国家码块 + 信号条
status_pill 色点胶囊
connect_button 核心连接键三态(off/connecting/on + 动画)
server_tile 服务器列表行
plan_card 套餐卡(专业版渐变高亮)
auth_screen 登录 / 注册(邮箱验证码 + 设密码)
onboarding_screen 首次引导 PageView(3 屏可滑动 + 进度点)
home_shell 底部 4 Tab(连接/节点/统计/账户 + 状态机)
account_screens 套餐选择 / 设备管理 / 兑换 / 联系我们
pangolin_logo PangolinMark / BrandLockup / AppIcon(SVG)
- assets/:logo-mark / logo-mark-white / logo-wordmark / app-icon(SVG)
- android/:Kotlin 主 Activity + Manifest + Gradle 配置
- ios/:Runner.xcodeproj + xcscheme + Podfile + AppDelegate + storyboard
运行方式(cd client/):
flutter pub get && flutter run
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
**/doc/api/
|
||||
**/ios/Flutter/.last_build_id
|
||||
.dart_tool/
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
.packages
|
||||
.pub-cache/
|
||||
.pub/
|
||||
/build/
|
||||
|
||||
# Symbolication related
|
||||
app.*.symbols
|
||||
|
||||
# Obfuscation related
|
||||
app.*.map.json
|
||||
|
||||
# Android Studio will place build artifacts here
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
|
||||
# iOS generated
|
||||
ios/Flutter/Generated.xcconfig
|
||||
ios/Flutter/flutter_export_environment.sh
|
||||
ios/ServiceDefinitions.json
|
||||
ios/Runner/GeneratedPluginRegistrant.*
|
||||
|
||||
# Exceptions to above rules.
|
||||
!**/ios/**/default.mode1v3
|
||||
!**/ios/**/default.mode2v3
|
||||
!**/ios/**/default.pbxuser
|
||||
!**/ios/**/default.perspectivev3
|
||||
!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages
|
||||
!/dev/ci/**/Makefile
|
||||
|
||||
# Gradle wrapper binary (downloaded by `gradle wrapper` or flutter create)
|
||||
/android/gradle/wrapper/gradle-wrapper.jar
|
||||
/android/gradlew
|
||||
/android/gradlew.bat
|
||||
@@ -0,0 +1,33 @@
|
||||
# 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: "a14f74ff3a1cbd521163c5f03d68113f6cfc50e6"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: a14f74ff3a1cbd521163c5f03d68113f6cfc50e6
|
||||
base_revision: a14f74ff3a1cbd521163c5f03d68113f6cfc50e6
|
||||
- platform: android
|
||||
create_revision: a14f74ff3a1cbd521163c5f03d68113f6cfc50e6
|
||||
base_revision: a14f74ff3a1cbd521163c5f03d68113f6cfc50e6
|
||||
- platform: ios
|
||||
create_revision: a14f74ff3a1cbd521163c5f03d68113f6cfc50e6
|
||||
base_revision: a14f74ff3a1cbd521163c5f03d68113f6cfc50e6
|
||||
|
||||
# User provided section
|
||||
|
||||
# List of Local paths (relative to this file) that should be
|
||||
# ignored by the migrate tool.
|
||||
#
|
||||
# Files that are not part of the templates will be ignored by default.
|
||||
unmanaged_files:
|
||||
- 'lib/main.dart'
|
||||
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||
@@ -0,0 +1,6 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
linter:
|
||||
rules:
|
||||
prefer_const_constructors: false
|
||||
prefer_const_literals_to_create_immutables: false
|
||||
@@ -0,0 +1,67 @@
|
||||
def localProperties = new Properties()
|
||||
def localPropertiesFile = rootProject.file('local.properties')
|
||||
if (localPropertiesFile.exists()) {
|
||||
localPropertiesFile.withReader('UTF-8') { reader ->
|
||||
localProperties.load(reader)
|
||||
}
|
||||
}
|
||||
|
||||
def flutterRoot = localProperties.getProperty('flutter.sdk')
|
||||
if (flutterRoot == null) {
|
||||
throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.")
|
||||
}
|
||||
|
||||
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
|
||||
if (flutterVersionCode == null) {
|
||||
flutterVersionCode = '1'
|
||||
}
|
||||
|
||||
def flutterVersionName = localProperties.getProperty('flutter.versionName')
|
||||
if (flutterVersionName == null) {
|
||||
flutterVersionName = '1.0'
|
||||
}
|
||||
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
|
||||
|
||||
android {
|
||||
namespace "com.pangolin.pangolin_vpn"
|
||||
compileSdkVersion flutter.compileSdkVersion
|
||||
ndkVersion flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '1.8'
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main.java.srcDirs += 'src/main/kotlin'
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.pangolin.pangolin_vpn"
|
||||
minSdkVersion flutter.minSdkVersion
|
||||
targetSdkVersion flutter.targetSdkVersion
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig signingConfigs.debug
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source '../..'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application
|
||||
android:label="穿山甲"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<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.pangolin.pangolin_vpn
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity: FlutterActivity()
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Customize this file to match your native splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
</layer-list>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Customize this file to match your native splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
</layer-list>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
|
||||
<!-- Theme applied to the Android Window once your Flutter app starts to render.
|
||||
The transition from the LaunchTheme to this theme is handled automatically.
|
||||
You should set this to a theme that matches your normal app theme. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,31 @@
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.9.0'
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.1.0'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.buildDir = '../build'
|
||||
subprojects {
|
||||
project.buildDir = "${rootProject.buildDir}/${project.name}"
|
||||
}
|
||||
subprojects {
|
||||
project.evaluationDependsOn(':app')
|
||||
}
|
||||
|
||||
tasks.register("clean", Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# Copy/rename this file to gradle/wrapper/gradle-wrapper.properties
|
||||
# (Flutter's flutter create generates this automatically)
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-all.zip
|
||||
@@ -0,0 +1,3 @@
|
||||
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
@@ -0,0 +1,11 @@
|
||||
include ':app'
|
||||
|
||||
def localPropertiesFile = new File(rootProject.projectDir, "local.properties")
|
||||
def properties = new Properties()
|
||||
|
||||
assert localPropertiesFile.exists()
|
||||
localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) }
|
||||
|
||||
def flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
|
||||
apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle"
|
||||
@@ -0,0 +1,17 @@
|
||||
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="穿山甲 app icon">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="96" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#C8794A"></stop><stop offset="1" stop-color="#9E5630"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="96" height="96" rx="22" fill="url(#bg)"></rect>
|
||||
<g transform="translate(96,0) scale(-1,1)">
|
||||
<path d="M16 59 Q17 51 26 50 Q34 31 45 30 Q55 30 62 37 Q69 41 75 45 Q85 49 89 44 Q92 50 84 52 Q75 53 67 53 Q66 62 60 62 L56 62 Q54 55 49 55 Q47 62 37 62 L33 62 Q31 56 26 56 Q20 59 16 59 Z" fill="#FFFFFF"></path>
|
||||
<g stroke="#B96A3D" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" fill="none" opacity="0.9">
|
||||
<path d="M26 50 Q34 43 42 50"></path>
|
||||
<path d="M36 48 Q44 41 52 48"></path>
|
||||
<path d="M46 48 Q54 41 62 49"></path>
|
||||
</g>
|
||||
<circle cx="23" cy="55" r="2.3" fill="#9E5630"></circle>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1023 B |
@@ -0,0 +1,11 @@
|
||||
<svg width="48" height="48" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="穿山甲 mark white">
|
||||
<g transform="translate(96,0) scale(-1,1)">
|
||||
<path d="M16 59 Q17 51 26 50 Q34 31 45 30 Q55 30 62 37 Q69 41 75 45 Q85 49 89 44 Q92 50 84 52 Q75 53 67 53 Q66 62 60 62 L56 62 Q54 55 49 55 Q47 62 37 62 L33 62 Q31 56 26 56 Q20 59 16 59 Z" fill="#F4EFE8"></path>
|
||||
<g stroke="#9E5630" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" fill="none" opacity="0.7">
|
||||
<path d="M26 50 Q34 43 42 50"></path>
|
||||
<path d="M36 48 Q44 41 52 48"></path>
|
||||
<path d="M46 48 Q54 41 62 49"></path>
|
||||
</g>
|
||||
<circle cx="23" cy="55" r="2.2" fill="#9E5630"></circle>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 735 B |
@@ -0,0 +1,11 @@
|
||||
<svg width="48" height="48" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="穿山甲 mark">
|
||||
<g transform="translate(96,0) scale(-1,1)">
|
||||
<path d="M16 59 Q17 51 26 50 Q34 31 45 30 Q55 30 62 37 Q69 41 75 45 Q85 49 89 44 Q92 50 84 52 Q75 53 67 53 Q66 62 60 62 L56 62 Q54 55 49 55 Q47 62 37 62 L33 62 Q31 56 26 56 Q20 59 16 59 Z" fill="#B96A3D"></path>
|
||||
<g stroke="#FAF3ED" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" fill="none" opacity="0.85">
|
||||
<path d="M26 50 Q34 43 42 50"></path>
|
||||
<path d="M36 48 Q44 41 52 48"></path>
|
||||
<path d="M46 48 Q54 41 62 49"></path>
|
||||
</g>
|
||||
<circle cx="23" cy="55" r="2.2" fill="#FAF3ED"></circle>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 730 B |
@@ -0,0 +1,13 @@
|
||||
<svg width="240" height="56" viewBox="0 0 240 56" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="穿山甲 · Pangolin">
|
||||
<g transform="translate(4,4) scale(0.5)">
|
||||
<g transform="translate(96,0) scale(-1,1)">
|
||||
<path d="M16 59 Q17 51 26 50 Q34 31 45 30 Q55 30 62 37 Q69 41 75 45 Q85 49 89 44 Q92 50 84 52 Q75 53 67 53 Q66 62 60 62 L56 62 Q54 55 49 55 Q47 62 37 62 L33 62 Q31 56 26 56 Q20 59 16 59 Z" fill="#B96A3D"></path>
|
||||
<g stroke="#FAF3ED" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" fill="none" opacity="0.85">
|
||||
<path d="M26 50 Q34 43 42 50"></path><path d="M36 48 Q44 41 52 48"></path><path d="M46 48 Q54 41 62 49"></path>
|
||||
</g>
|
||||
<circle cx="23" cy="55" r="2.2" fill="#FAF3ED"></circle>
|
||||
</g>
|
||||
</g>
|
||||
<text x="60" y="28" font-family="Sora, sans-serif" font-size="21" font-weight="700" letter-spacing="-0.01em" fill="#1F1C18">穿山甲</text>
|
||||
<text x="60" y="45" font-family="Sora, sans-serif" font-size="11" font-weight="600" letter-spacing="0.22em" fill="#B96A3D">PANGOLIN</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>App</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>io.flutter.flutter.app</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>App</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>FMWK</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>arm64</string>
|
||||
</array>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>12.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,2 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
@@ -0,0 +1,2 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
@@ -0,0 +1,44 @@
|
||||
# Uncomment this line to define a global platform for your project
|
||||
platform :ios, '12.0'
|
||||
|
||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||
|
||||
project 'Runner', {
|
||||
'Debug' => :debug,
|
||||
'Profile' => :release,
|
||||
'Release' => :release,
|
||||
}
|
||||
|
||||
def flutter_root
|
||||
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
|
||||
unless File.exist?(generated_xcode_build_settings_path)
|
||||
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
|
||||
end
|
||||
|
||||
File.foreach(generated_xcode_build_settings_path) do |line|
|
||||
matches = line.match(/FLUTTER_ROOT\=(.*)/)
|
||||
return matches[1].strip if matches
|
||||
end
|
||||
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig and running flutter pub get"
|
||||
end
|
||||
|
||||
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
|
||||
|
||||
flutter_ios_podfile_setup
|
||||
|
||||
target 'Runner' do
|
||||
use_frameworks!
|
||||
use_modular_headers!
|
||||
|
||||
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
|
||||
target 'RunnerTests' do
|
||||
inherit! :search_paths
|
||||
end
|
||||
end
|
||||
|
||||
post_install do |installer|
|
||||
installer.pods_project.targets.each do |target|
|
||||
flutter_additional_ios_build_settings(target)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,464 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 54;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2311E8E86230040F4C2 /* GeneratedPluginRegistrant.m */; };
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 10;
|
||||
files = (
|
||||
);
|
||||
name = "Embed Frameworks";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
1498D2311E8E86230040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
97C146F11CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
97C146FA1CF9000F007C117D /* Main.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Main.storyboard; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
1771E259F7494EBC8BB1F97A /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
97C146EB1CF9000F007C117D /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
||||
9740EEB31CF90195004384FC /* Generated.xcconfig */,
|
||||
);
|
||||
name = Flutter;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
97C146E51CF9000F007C117D = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9740EEB11CF90186004384FC /* Flutter */,
|
||||
97C146F01CF9000F007C117D /* Runner */,
|
||||
97C146EF1CF9000F007C117D /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
97C146EF1CF9000F007C117D /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
97C146EE1CF9000F007C117D /* Runner.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
97C146F01CF9000F007C117D /* Runner */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
97C146FA1CF9000F007C117D /* Main.storyboard */,
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */,
|
||||
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
|
||||
97C146F11CF9000F007C117D /* Info.plist */,
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||
1498D2311E8E86230040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||
1771E259F7494EBC8BB1F97A /* GeneratedPluginRegistrant.h */,
|
||||
);
|
||||
path = Runner;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
97C146ED1CF9000F007C117D /* Runner */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||
buildPhases = (
|
||||
9740EEB61CF901F6004384FC /* Run Script */,
|
||||
97C146EA1CF9000F007C117D /* Sources */,
|
||||
97C146EB1CF9000F007C117D /* Frameworks */,
|
||||
97C146EC1CF9000F007C117D /* Resources */,
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||
3B06F39B1EFE8EE800ECD283 /* Thin Binary */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = Runner;
|
||||
productName = Runner;
|
||||
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
97C146E61CF9000F007C117D /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
LastUpgradeCheck = 1510;
|
||||
ORGANIZATIONNAME = "com.pangolin";
|
||||
TargetAttributes = {
|
||||
97C146ED1CF9000F007C117D = {
|
||||
CreatedOnToolsVersion = 7.3.1;
|
||||
LastSwiftMigration = 0900;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
|
||||
compatibilityVersion = "Xcode 9.3";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 97C146E51CF9000F007C117D;
|
||||
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
97C146ED1CF9000F007C117D /* Runner */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
97C146EC1CF9000F007C117D /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
3B06F39B1EFE8EE800ECD283 /* Thin Binary */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
"$(TARGET_BUILD_DIR)/$(INFOPLIST_PATH)",
|
||||
);
|
||||
name = "Thin Binary";
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
|
||||
};
|
||||
9740EEB61CF901F6004384FC /* Run Script */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "Run Script";
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
97C146EA1CF9000F007C117D /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
249021D3217E4FDB00AE95B9 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
249021D4217E4FDB00AE95B9 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolinVpn;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
97C147031CF9000F007C117D /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
97C147041CF9000F007C117D /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule";
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
97C147061CF9000F007C117D /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolinVpn;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
97C147071CF9000F007C117D /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolinVpn;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
97C147031CF9000F007C117D /* Debug */,
|
||||
97C147041CF9000F007C117D /* Release */,
|
||||
249021D3217E4FDB00AE95B9 /* Profile */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
97C147061CF9000F007C117D /* Debug */,
|
||||
97C147071CF9000F007C117D /* Release */,
|
||||
249021D4217E4FDB00AE95B9 /* Profile */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = 97C146E61CF9000F007C117D /* Project object */;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1510"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<Testables>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Profile"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "group:Runner.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-20x20@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-20x20@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-40x40@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-40x40@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-60x60@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-60x60@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "1024x1024",
|
||||
"idiom" : "ios-marketing",
|
||||
"filename" : "Icon-App-1024x1024@1x.png",
|
||||
"scale" : "1x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "LaunchImage.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "LaunchImage@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "LaunchImage@3x.png",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--View Controller-->
|
||||
<scene sceneID="EHf-IW-A2E">
|
||||
<objects>
|
||||
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||
<layoutGuides>
|
||||
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
|
||||
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
|
||||
</layoutGuides>
|
||||
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
|
||||
</imageView>
|
||||
</subviews>
|
||||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="53" y="375"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="LaunchImage" width="168" height="185"/>
|
||||
</resources>
|
||||
</document>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--Flutter View Controller-->
|
||||
<scene sceneID="tne-QT-ifu">
|
||||
<objects>
|
||||
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
|
||||
<layoutGuides>
|
||||
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
|
||||
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
|
||||
</layoutGuides>
|
||||
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
|
||||
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>
|
||||
@@ -0,0 +1,51 @@
|
||||
<?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>穿山甲</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>pangolin_vpn</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>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<false/>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1 @@
|
||||
#import "GeneratedPluginRegistrant.h"
|
||||
@@ -0,0 +1,56 @@
|
||||
// main.dart — 入口(演示态)
|
||||
// 串接:登录 → 引导 → 主框架(4 Tab)
|
||||
import 'package:flutter/material.dart';
|
||||
import 'pangolin_theme.dart';
|
||||
import 'widgets/pangolin_widgets.dart';
|
||||
|
||||
void main() => runApp(const PangolinApp());
|
||||
|
||||
class PangolinApp extends StatefulWidget {
|
||||
const PangolinApp({super.key});
|
||||
@override
|
||||
State<PangolinApp> createState() => _PangolinAppState();
|
||||
}
|
||||
|
||||
class _PangolinAppState extends State<PangolinApp> {
|
||||
bool zh = true;
|
||||
ThemeMode mode = ThemeMode.system;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: '穿山甲',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: PangolinTheme.light,
|
||||
darkTheme: PangolinTheme.dark,
|
||||
themeMode: mode,
|
||||
home: RootFlow(zh: zh),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 应用阶段:登录 → 引导 → 主应用
|
||||
enum AppStage { auth, onboarding, app }
|
||||
|
||||
class RootFlow extends StatefulWidget {
|
||||
const RootFlow({super.key, this.zh = true});
|
||||
final bool zh;
|
||||
@override
|
||||
State<RootFlow> createState() => _RootFlowState();
|
||||
}
|
||||
|
||||
class _RootFlowState extends State<RootFlow> {
|
||||
AppStage stage = AppStage.auth;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
switch (stage) {
|
||||
case AppStage.auth:
|
||||
return AuthScreen(zh: widget.zh, onDone: () => setState(() => stage = AppStage.onboarding));
|
||||
case AppStage.onboarding:
|
||||
return OnboardingScreen(onDone: () => setState(() => stage = AppStage.app));
|
||||
case AppStage.app:
|
||||
return HomeShell(zh: widget.zh);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
// pangolin_theme.dart
|
||||
// 穿山甲 VPN · Pangolin VPN — Flutter design tokens
|
||||
//
|
||||
// Single Dart source mirroring colors_and_type.css.
|
||||
// Fonts are loaded at runtime via the google_fonts package (no local ttf needed).
|
||||
//
|
||||
// Usage:
|
||||
// MaterialApp(
|
||||
// theme: PangolinTheme.light,
|
||||
// darkTheme: PangolinTheme.dark,
|
||||
// themeMode: ThemeMode.system,
|
||||
// );
|
||||
// // tokens: PangolinColors.clay500, PangolinSpacing.x4, PangolinRadius.lg ...
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
/// ── Primitive color ramps ───────────────────────────────────────────
|
||||
class PangolinColors {
|
||||
PangolinColors._();
|
||||
|
||||
// Clay / Copper — pangolin-armor primary (warm earth)
|
||||
static const clay50 = Color(0xFFFAF3ED);
|
||||
static const clay100 = Color(0xFFF2E2D4);
|
||||
static const clay200 = Color(0xFFE6C7AC);
|
||||
static const clay300 = Color(0xFFD9A982);
|
||||
static const clay400 = Color(0xFFCC8B5C);
|
||||
static const clay500 = Color(0xFFB96A3D); // brand primary
|
||||
static const clay600 = Color(0xFF9E5630);
|
||||
static const clay700 = Color(0xFF7E4426);
|
||||
static const clay800 = Color(0xFF5E331D);
|
||||
static const clay900 = Color(0xFF3D2213);
|
||||
|
||||
// Sand / Taupe — warm neutral ramp
|
||||
static const sand50 = Color(0xFFFAF8F4);
|
||||
static const sand100 = Color(0xFFF2EEE7);
|
||||
static const sand200 = Color(0xFFE6DFD3);
|
||||
static const sand300 = Color(0xFFD2C8B8);
|
||||
static const sand400 = Color(0xFFB0A491);
|
||||
static const sand500 = Color(0xFF8C8270);
|
||||
static const sand600 = Color(0xFF6B6253);
|
||||
static const sand700 = Color(0xFF4E4940);
|
||||
static const sand800 = Color(0xFF2E2A24);
|
||||
static const sand900 = Color(0xFF1F1C18);
|
||||
static const sand950 = Color(0xFF14110E); // warm espresso near-black
|
||||
|
||||
// Semantic hues (earth-tuned)
|
||||
static const green400 = Color(0xFF7FB07A);
|
||||
static const green500 = Color(0xFF5B8C5A); // connected / secure
|
||||
static const green600 = Color(0xFF467046);
|
||||
static const amber400 = Color(0xFFE2B05A);
|
||||
static const amber500 = Color(0xFFD69A3C); // connecting / warning
|
||||
static const amber600 = Color(0xFFB47E29);
|
||||
static const red400 = Color(0xFFD4715A);
|
||||
static const red500 = Color(0xFFC0533B); // error / disconnect
|
||||
static const red600 = Color(0xFF9E4230);
|
||||
|
||||
static const white = Color(0xFFFFFFFF);
|
||||
}
|
||||
|
||||
/// ── Semantic tokens, resolved per brightness ───────────────────────
|
||||
@immutable
|
||||
class PangolinScheme extends ThemeExtension<PangolinScheme> {
|
||||
const PangolinScheme({
|
||||
required this.bg,
|
||||
required this.bgSubtle,
|
||||
required this.surface,
|
||||
required this.surface2,
|
||||
required this.overlay,
|
||||
required this.fg1,
|
||||
required this.fg2,
|
||||
required this.fg3,
|
||||
required this.fgOnAccent,
|
||||
required this.accent,
|
||||
required this.accentHover,
|
||||
required this.accentPress,
|
||||
required this.accentSubtle,
|
||||
required this.accentBorder,
|
||||
required this.border,
|
||||
required this.borderStrong,
|
||||
required this.ring,
|
||||
required this.success,
|
||||
required this.successSubtle,
|
||||
required this.warning,
|
||||
required this.warningSubtle,
|
||||
required this.danger,
|
||||
required this.dangerSubtle,
|
||||
});
|
||||
|
||||
final Color bg, bgSubtle, surface, surface2, overlay;
|
||||
final Color fg1, fg2, fg3, fgOnAccent;
|
||||
final Color accent, accentHover, accentPress, accentSubtle, accentBorder;
|
||||
final Color border, borderStrong, ring;
|
||||
final Color success, successSubtle, warning, warningSubtle, danger, dangerSubtle;
|
||||
|
||||
static const light = PangolinScheme(
|
||||
bg: PangolinColors.sand50,
|
||||
bgSubtle: PangolinColors.sand100,
|
||||
surface: PangolinColors.white,
|
||||
surface2: PangolinColors.sand50,
|
||||
overlay: Color(0x731F1C18),
|
||||
fg1: PangolinColors.sand900,
|
||||
fg2: PangolinColors.sand600,
|
||||
fg3: PangolinColors.sand500,
|
||||
fgOnAccent: PangolinColors.white,
|
||||
accent: PangolinColors.clay500,
|
||||
accentHover: PangolinColors.clay600,
|
||||
accentPress: PangolinColors.clay700,
|
||||
accentSubtle: PangolinColors.clay50,
|
||||
accentBorder: PangolinColors.clay200,
|
||||
border: PangolinColors.sand200,
|
||||
borderStrong: PangolinColors.sand300,
|
||||
ring: Color(0x59B96A3D),
|
||||
success: PangolinColors.green500,
|
||||
successSubtle: Color(0xFFE9F0E6),
|
||||
warning: PangolinColors.amber500,
|
||||
warningSubtle: Color(0xFFF8EED6),
|
||||
danger: PangolinColors.red500,
|
||||
dangerSubtle: Color(0xFFF6E1DA),
|
||||
);
|
||||
|
||||
static const dark = PangolinScheme(
|
||||
bg: PangolinColors.sand950,
|
||||
bgSubtle: PangolinColors.sand900,
|
||||
surface: Color(0xFF221E19),
|
||||
surface2: Color(0xFF2A251F),
|
||||
overlay: Color(0x99000000),
|
||||
fg1: Color(0xFFF4EFE8),
|
||||
fg2: Color(0xFFB6AC9C),
|
||||
fg3: Color(0xFF897F6F),
|
||||
fgOnAccent: PangolinColors.sand900,
|
||||
accent: PangolinColors.clay400,
|
||||
accentHover: PangolinColors.clay300,
|
||||
accentPress: PangolinColors.clay500,
|
||||
accentSubtle: Color(0x24CC8B5C),
|
||||
accentBorder: Color(0x4DCC8B5C),
|
||||
border: Color(0x1AF2EEE7),
|
||||
borderStrong: Color(0x2EF2EEE7),
|
||||
ring: Color(0x73CC8B5C),
|
||||
success: PangolinColors.green400,
|
||||
successSubtle: Color(0x297FB07A),
|
||||
warning: PangolinColors.amber400,
|
||||
warningSubtle: Color(0x29E2B05A),
|
||||
danger: PangolinColors.red400,
|
||||
dangerSubtle: Color(0x29D4715A),
|
||||
);
|
||||
|
||||
@override
|
||||
PangolinScheme copyWith({Color? bg, Color? fg1, Color? accent}) => PangolinScheme(
|
||||
bg: bg ?? this.bg, bgSubtle: bgSubtle, surface: surface, surface2: surface2,
|
||||
overlay: overlay, fg1: fg1 ?? this.fg1, fg2: fg2, fg3: fg3, fgOnAccent: fgOnAccent,
|
||||
accent: accent ?? this.accent, accentHover: accentHover, accentPress: accentPress,
|
||||
accentSubtle: accentSubtle, accentBorder: accentBorder, border: border,
|
||||
borderStrong: borderStrong, ring: ring, success: success, successSubtle: successSubtle,
|
||||
warning: warning, warningSubtle: warningSubtle, danger: danger, dangerSubtle: dangerSubtle,
|
||||
);
|
||||
|
||||
@override
|
||||
PangolinScheme lerp(ThemeExtension<PangolinScheme>? other, double t) {
|
||||
if (other is! PangolinScheme) return this;
|
||||
Color c(Color a, Color b) => Color.lerp(a, b, t)!;
|
||||
return PangolinScheme(
|
||||
bg: c(bg, other.bg), bgSubtle: c(bgSubtle, other.bgSubtle), surface: c(surface, other.surface),
|
||||
surface2: c(surface2, other.surface2), overlay: c(overlay, other.overlay),
|
||||
fg1: c(fg1, other.fg1), fg2: c(fg2, other.fg2), fg3: c(fg3, other.fg3), fgOnAccent: c(fgOnAccent, other.fgOnAccent),
|
||||
accent: c(accent, other.accent), accentHover: c(accentHover, other.accentHover), accentPress: c(accentPress, other.accentPress),
|
||||
accentSubtle: c(accentSubtle, other.accentSubtle), accentBorder: c(accentBorder, other.accentBorder),
|
||||
border: c(border, other.border), borderStrong: c(borderStrong, other.borderStrong), ring: c(ring, other.ring),
|
||||
success: c(success, other.success), successSubtle: c(successSubtle, other.successSubtle),
|
||||
warning: c(warning, other.warning), warningSubtle: c(warningSubtle, other.warningSubtle),
|
||||
danger: c(danger, other.danger), dangerSubtle: c(dangerSubtle, other.dangerSubtle),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension PangolinContext on BuildContext {
|
||||
PangolinScheme get pangolin => Theme.of(this).extension<PangolinScheme>()!;
|
||||
}
|
||||
|
||||
/// ── Spacing (4px base) ──────────────────────────────────────────────
|
||||
class PangolinSpacing {
|
||||
PangolinSpacing._();
|
||||
static const double x1 = 4, x2 = 8, x3 = 12, x4 = 16, x5 = 20,
|
||||
x6 = 24, x8 = 32, x10 = 40, x12 = 48, x16 = 64;
|
||||
}
|
||||
|
||||
/// ── Radii ───────────────────────────────────────────────────────────
|
||||
class PangolinRadius {
|
||||
PangolinRadius._();
|
||||
static const double sm = 6, md = 10, lg = 14, xl = 20, xxl = 28, full = 999;
|
||||
static const rSm = Radius.circular(sm);
|
||||
static const rMd = Radius.circular(md);
|
||||
static const rLg = Radius.circular(lg);
|
||||
static const rXl = Radius.circular(xl);
|
||||
static const rXxl = Radius.circular(xxl);
|
||||
}
|
||||
|
||||
/// ── Motion ──────────────────────────────────────────────────────────
|
||||
class PangolinMotion {
|
||||
PangolinMotion._();
|
||||
static const fast = Duration(milliseconds: 140);
|
||||
static const base = Duration(milliseconds: 220);
|
||||
static const slow = Duration(milliseconds: 360);
|
||||
static const easeOut = Cubic(0.22, 1, 0.36, 1);
|
||||
static const easeInOut = Cubic(0.65, 0, 0.35, 1);
|
||||
}
|
||||
|
||||
/// ── Font families (via google_fonts at runtime) ─────────────────────
|
||||
class PangolinFonts {
|
||||
PangolinFonts._();
|
||||
// These string constants are used as display labels only.
|
||||
// Actual font loading is done via google_fonts in PangolinText getters.
|
||||
static const display = 'Sora';
|
||||
static const sans = 'Manrope';
|
||||
static const cjk = 'Noto Sans SC';
|
||||
static const mono = 'JetBrains Mono';
|
||||
}
|
||||
|
||||
/// ── Type scale ──────────────────────────────────────────────────────
|
||||
/// Uses google_fonts for runtime font loading (no local ttf required).
|
||||
/// All PangolinText members are static getters (not const) so they can
|
||||
/// reference google_fonts, which registers fonts dynamically.
|
||||
class PangolinText {
|
||||
PangolinText._();
|
||||
|
||||
static TextStyle get displayXl => GoogleFonts.sora(
|
||||
fontSize: 48, fontWeight: FontWeight.w700, height: 1.15, letterSpacing: -0.96);
|
||||
static TextStyle get display => GoogleFonts.sora(
|
||||
fontSize: 36, fontWeight: FontWeight.w700, height: 1.15, letterSpacing: -0.72);
|
||||
static TextStyle get h1 => GoogleFonts.sora(
|
||||
fontSize: 30, fontWeight: FontWeight.w600, height: 1.3, letterSpacing: -0.3);
|
||||
static TextStyle get h2 => GoogleFonts.sora(
|
||||
fontSize: 24, fontWeight: FontWeight.w600, height: 1.3, letterSpacing: -0.24);
|
||||
static TextStyle get h3 => GoogleFonts.manrope(
|
||||
fontSize: 20, fontWeight: FontWeight.w600, height: 1.3);
|
||||
static TextStyle get bodyLg => GoogleFonts.manrope(
|
||||
fontSize: 18, fontWeight: FontWeight.w400, height: 1.65);
|
||||
static TextStyle get body => GoogleFonts.manrope(
|
||||
fontSize: 16, fontWeight: FontWeight.w400, height: 1.5);
|
||||
static TextStyle get sm => GoogleFonts.manrope(
|
||||
fontSize: 14, fontWeight: FontWeight.w400, height: 1.5);
|
||||
static TextStyle get caption => GoogleFonts.manrope(
|
||||
fontSize: 12, fontWeight: FontWeight.w500, height: 1.5);
|
||||
static TextStyle get overline => GoogleFonts.manrope(
|
||||
fontSize: 12, fontWeight: FontWeight.w600, letterSpacing: 0.96);
|
||||
static TextStyle get mono => GoogleFonts.jetBrainsMono(
|
||||
fontSize: 14, fontWeight: FontWeight.w400,
|
||||
fontFeatures: const [FontFeature.tabularFigures()]);
|
||||
}
|
||||
|
||||
/// ── Soft warm-tinted shadows ────────────────────────────────────────
|
||||
class PangolinShadow {
|
||||
PangolinShadow._();
|
||||
static const _tint = Color(0xFF2D1E14);
|
||||
static List<BoxShadow> sm = [BoxShadow(color: _tint.withOpacity(0.06), blurRadius: 2, offset: const Offset(0, 1))];
|
||||
static List<BoxShadow> md = [BoxShadow(color: _tint.withOpacity(0.08), blurRadius: 14, offset: const Offset(0, 4))];
|
||||
static List<BoxShadow> lg = [BoxShadow(color: _tint.withOpacity(0.12), blurRadius: 32, offset: const Offset(0, 12))];
|
||||
static List<BoxShadow> xl = [BoxShadow(color: _tint.withOpacity(0.16), blurRadius: 60, offset: const Offset(0, 24))];
|
||||
}
|
||||
|
||||
/// ── Ready-to-use ThemeData ──────────────────────────────────────────
|
||||
class PangolinTheme {
|
||||
PangolinTheme._();
|
||||
|
||||
static ThemeData get light => _build(PangolinScheme.light, Brightness.light);
|
||||
static ThemeData get dark => _build(PangolinScheme.dark, Brightness.dark);
|
||||
|
||||
static ThemeData _build(PangolinScheme s, Brightness b) {
|
||||
final scheme = ColorScheme(
|
||||
brightness: b,
|
||||
primary: s.accent,
|
||||
onPrimary: s.fgOnAccent,
|
||||
secondary: s.accent,
|
||||
onSecondary: s.fgOnAccent,
|
||||
error: s.danger,
|
||||
onError: PangolinColors.white,
|
||||
surface: s.surface,
|
||||
onSurface: s.fg1,
|
||||
);
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: b,
|
||||
colorScheme: scheme,
|
||||
scaffoldBackgroundColor: s.bg,
|
||||
// Use google_fonts Manrope as app-wide base font
|
||||
textTheme: GoogleFonts.manropeTextTheme(
|
||||
ThemeData(brightness: b).textTheme,
|
||||
),
|
||||
extensions: [s],
|
||||
filledButtonTheme: FilledButtonThemeData(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: s.accent,
|
||||
foregroundColor: s.fgOnAccent,
|
||||
textStyle: GoogleFonts.manrope(fontWeight: FontWeight.w600, fontSize: 15),
|
||||
shape: const StadiumBorder(),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 14),
|
||||
),
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
color: s.surface,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
side: BorderSide(color: s.border),
|
||||
borderRadius: BorderRadius.circular(PangolinRadius.lg),
|
||||
),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: s.bg,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(PangolinRadius.md),
|
||||
borderSide: BorderSide(color: s.borderStrong, width: 1.5),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(PangolinRadius.md),
|
||||
borderSide: BorderSide(color: s.accent, width: 1.5),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
// account_screens.dart — 账户子页(套餐选择 / 设备管理 / 兑换 & 购买 / 联系我们)
|
||||
import 'package:flutter/material.dart';
|
||||
import '../pangolin_theme.dart';
|
||||
import 'pangolin_icons.dart';
|
||||
import 'plan_card.dart';
|
||||
import 'pangolin_button.dart';
|
||||
|
||||
/// 通用:带返回箭头的子页骨架
|
||||
class _SubScaffold extends StatelessWidget {
|
||||
const _SubScaffold({required this.title, required this.child, this.onBack});
|
||||
final String title; final Widget child; final VoidCallback? onBack;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
return Scaffold(
|
||||
backgroundColor: c.bg,
|
||||
body: SafeArea(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 6, 16, 10),
|
||||
child: Row(children: [
|
||||
IconButton(onPressed: onBack ?? () => Navigator.of(context).maybePop(), icon: Icon(PangolinIcons.arrowLeft, size: 22, color: c.fg1)),
|
||||
Text(title, style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700)),
|
||||
]),
|
||||
),
|
||||
Expanded(child: child),
|
||||
])),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// ── 套餐选择 ──
|
||||
class PlansScreen extends StatelessWidget {
|
||||
const PlansScreen({super.key, this.zh = true, this.onChoose, this.onBack});
|
||||
final bool zh; final ValueChanged<String>? onChoose; final VoidCallback? onBack;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final plans = [
|
||||
(
|
||||
id: 'free', name: zh ? '免费版' : 'Free', price: '¥0', cta: zh ? '当前' : 'Current', featured: false, current: true, pop: null as String?,
|
||||
feats: zh ? ['3 个基础节点', '每日 1GB 流量', '1 台设备'] : ['3 basic locations', '1GB / day', '1 device'],
|
||||
),
|
||||
(
|
||||
id: 'pro', name: zh ? '专业版' : 'Pro', price: '¥25', cta: zh ? '立即升级' : 'Upgrade', featured: true, current: false, pop: zh ? '最受欢迎' : 'Popular',
|
||||
feats: zh ? ['80+ 全球节点', '无限流量 · 极速', '5 台设备同时在线', '流媒体优化'] : ['80+ locations', 'Unlimited · fast', '5 devices', 'Streaming optimized'],
|
||||
),
|
||||
(
|
||||
id: 'team', name: zh ? '团队版' : 'Team', price: '¥99', cta: zh ? '选择' : 'Choose', featured: false, current: false, pop: null as String?,
|
||||
feats: zh ? ['Pro 全部功能', '10 个成员席位', '集中计费与管理', '优先客服'] : ['Everything in Pro', '10 seats', 'Central billing', 'Priority support'],
|
||||
),
|
||||
];
|
||||
return _SubScaffold(
|
||||
title: zh ? '选择套餐' : 'Choose plan',
|
||||
onBack: onBack,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 14, 20, 24),
|
||||
children: [
|
||||
for (final p in plans) Padding(
|
||||
padding: EdgeInsets.only(bottom: 14, top: p.pop != null ? 12 : 0),
|
||||
child: PlanCard(
|
||||
name: p.name, price: p.price, period: zh ? '/月' : '/mo',
|
||||
features: p.feats, ctaLabel: p.cta, featured: p.featured, isCurrent: p.current,
|
||||
popularLabel: p.pop, onPressed: () => onChoose?.call(p.id),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// ── 设备管理 ──
|
||||
class DeviceItem {
|
||||
const DeviceItem({required this.id, required this.icon, required this.name, required this.os, required this.active, this.me = false});
|
||||
final String id, name, os, active; final IconData icon; final bool me;
|
||||
}
|
||||
|
||||
class DevicesScreen extends StatefulWidget {
|
||||
const DevicesScreen({super.key, this.zh = true, this.onBack});
|
||||
final bool zh; final VoidCallback? onBack;
|
||||
@override
|
||||
State<DevicesScreen> createState() => _DevicesScreenState();
|
||||
}
|
||||
|
||||
class _DevicesScreenState extends State<DevicesScreen> {
|
||||
late List<DeviceItem> _devices = [
|
||||
DeviceItem(id: '1', icon: PangolinIcons.laptop, name: 'MacBook Pro', os: 'macOS 26', active: widget.zh ? '当前设备' : 'This device', me: true),
|
||||
DeviceItem(id: '2', icon: PangolinIcons.smartphone, name: 'iPhone 17', os: 'iOS 26', active: '2 min'),
|
||||
DeviceItem(id: '3', icon: PangolinIcons.monitorSmartphone, name: 'iPad Air', os: 'iPadOS 26', active: widget.zh ? '3 小时前' : '3h ago'),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
return _SubScaffold(
|
||||
title: widget.zh ? '我的设备' : 'My devices',
|
||||
onBack: widget.onBack,
|
||||
child: ListView(padding: const EdgeInsets.fromLTRB(20, 4, 20, 24), children: [
|
||||
Text(widget.zh ? 'PRO 套餐最多 5 台设备同时在线' : 'Up to 5 devices on PRO', style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
decoration: BoxDecoration(color: c.surface, borderRadius: BorderRadius.circular(PangolinRadius.lg), border: Border.all(color: c.border), boxShadow: PangolinShadow.sm),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(children: [
|
||||
for (var i = 0; i < _devices.length; i++) _row(c, _devices[i], i < _devices.length - 1),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _row(PangolinScheme c, DeviceItem d, bool divider) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(border: divider ? Border(bottom: BorderSide(color: c.border)) : null),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
child: Row(children: [
|
||||
Container(width: 38, height: 38, decoration: BoxDecoration(color: c.accentSubtle, borderRadius: BorderRadius.circular(PangolinRadius.md)),
|
||||
child: Icon(d.icon, size: 19, color: c.accent)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
Flexible(child: Text(d.name, style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600, fontSize: 14.5))),
|
||||
if (d.me) Container(margin: const EdgeInsets.only(left: 7), padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 1),
|
||||
decoration: BoxDecoration(color: c.successSubtle, borderRadius: BorderRadius.circular(PangolinRadius.full)),
|
||||
child: Text(d.active, style: PangolinText.caption.copyWith(color: c.success, fontWeight: FontWeight.w600, fontSize: 10.5))),
|
||||
]),
|
||||
const SizedBox(height: 1),
|
||||
Text(d.me ? d.os : '${d.os} · ${d.active}', style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)),
|
||||
])),
|
||||
if (!d.me) OutlinedButton(
|
||||
onPressed: () => setState(() => _devices.removeWhere((x) => x.id == d.id)),
|
||||
style: OutlinedButton.styleFrom(foregroundColor: c.fg2, side: BorderSide(color: c.borderStrong, width: 1.5), shape: const StadiumBorder(), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), minimumSize: Size.zero),
|
||||
child: Text(widget.zh ? '移除' : 'Remove', style: PangolinText.caption.copyWith(fontWeight: FontWeight.w600)),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// ── 兑换 & 购买 ──
|
||||
class RedeemScreen extends StatefulWidget {
|
||||
const RedeemScreen({super.key, this.zh = true, this.onBack});
|
||||
final bool zh; final VoidCallback? onBack;
|
||||
@override
|
||||
State<RedeemScreen> createState() => _RedeemScreenState();
|
||||
}
|
||||
|
||||
class _RedeemScreenState extends State<RedeemScreen> {
|
||||
final _code = TextEditingController();
|
||||
bool _ok = false;
|
||||
|
||||
@override
|
||||
void dispose() { _code.dispose(); super.dispose(); }
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
final zh = widget.zh;
|
||||
final channels = [
|
||||
(icon: PangolinIcons.shoppingBag, name: zh ? '自助发卡商店' : 'Self-serve store', sub: 'shop.pangolin.vpn', accent: true),
|
||||
(icon: PangolinIcons.send, name: 'Telegram', sub: '@PangolinVPN_bot', accent: false),
|
||||
(icon: PangolinIcons.messageCircle, name: 'LINE', sub: '@pangolinvpn', accent: false),
|
||||
(icon: PangolinIcons.mail, name: zh ? '邮箱客服' : 'Email support', sub: 'buy@pangolin.vpn', accent: false),
|
||||
];
|
||||
return _SubScaffold(
|
||||
title: zh ? '兑换 & 购买' : 'Redeem & buy',
|
||||
onBack: widget.onBack,
|
||||
child: ListView(padding: const EdgeInsets.fromLTRB(20, 4, 20, 24), children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(color: c.surface, borderRadius: BorderRadius.circular(PangolinRadius.xl), border: Border.all(color: c.border), boxShadow: PangolinShadow.sm),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(zh ? '兑换激活码' : 'Redeem a code', style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w700, fontSize: 14.5)),
|
||||
const SizedBox(height: 12),
|
||||
if (_ok) Row(children: [
|
||||
Icon(PangolinIcons.checkCircle, size: 20, color: c.success), const SizedBox(width: 9),
|
||||
Text(zh ? '激活成功 · PRO 已开通' : 'Activated · PRO unlocked', style: PangolinText.body.copyWith(color: c.success, fontWeight: FontWeight.w600)),
|
||||
]) else Row(children: [
|
||||
Expanded(child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
decoration: BoxDecoration(color: c.bg, borderRadius: BorderRadius.circular(PangolinRadius.md), border: Border.all(color: c.borderStrong, width: 1.5)),
|
||||
child: TextField(controller: _code,
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
style: PangolinText.mono.copyWith(color: c.fg1, letterSpacing: 1.5),
|
||||
decoration: InputDecoration(border: InputBorder.none, isCollapsed: true, contentPadding: const EdgeInsets.symmetric(vertical: 13),
|
||||
hintText: zh ? '输入激活码' : 'Enter code', hintStyle: PangolinText.sm.copyWith(color: c.fg3)),
|
||||
onChanged: (_) => setState(() {})),
|
||||
)),
|
||||
const SizedBox(width: 10),
|
||||
PangolinButton(label: zh ? '激活' : 'Redeem', onPressed: _code.text.trim().length >= 4 ? () => setState(() => _ok = true) : null),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
Text(zh ? '购买渠道' : 'Where to buy', style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w700, fontSize: 14.5)),
|
||||
const SizedBox(height: 6),
|
||||
Text(zh ? 'App 内不支持直接支付。请通过以下渠道获取激活码:' : 'In-app payment is unavailable. Get a code via:',
|
||||
style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400, height: 1.5)),
|
||||
const SizedBox(height: 14),
|
||||
for (final ch in channels) Padding(padding: const EdgeInsets.only(bottom: 10), child: _channel(c, ch.icon, ch.name, ch.sub, ch.accent)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _channel(PangolinScheme c, IconData icon, String name, String sub, bool accent) {
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(PangolinRadius.lg),
|
||||
onTap: () {},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: accent ? c.accentSubtle : c.surface,
|
||||
borderRadius: BorderRadius.circular(PangolinRadius.lg),
|
||||
border: Border.all(color: accent ? c.accentBorder : c.border),
|
||||
boxShadow: PangolinShadow.sm,
|
||||
),
|
||||
child: Row(children: [
|
||||
Container(width: 40, height: 40, decoration: BoxDecoration(color: accent ? c.accent : c.bgSubtle, borderRadius: BorderRadius.circular(PangolinRadius.md)),
|
||||
child: Icon(icon, size: 20, color: accent ? PangolinColors.white : c.accent)),
|
||||
const SizedBox(width: 13),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(name, style: PangolinText.body.copyWith(color: c.fg1, fontWeight: FontWeight.w600)),
|
||||
Text(sub, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)),
|
||||
])),
|
||||
Icon(PangolinIcons.externalLink, size: 16, color: c.fg3),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// ── 联系我们 ──
|
||||
class ContactScreen extends StatelessWidget {
|
||||
const ContactScreen({super.key, this.zh = true, this.onBack});
|
||||
final bool zh; final VoidCallback? onBack;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
final channels = [
|
||||
(icon: PangolinIcons.send, name: 'Telegram', sub: '@PangolinVPN_bot', accent: true),
|
||||
(icon: PangolinIcons.messageCircle, name: 'LINE', sub: '@pangolinvpn', accent: false),
|
||||
(icon: PangolinIcons.mail, name: zh ? '邮箱客服' : 'Email support', sub: 'support@pangolin.vpn', accent: false),
|
||||
(icon: PangolinIcons.shoppingBag, name: zh ? '自助发卡商店' : 'Self-serve store', sub: 'shop.pangolin.vpn', accent: false),
|
||||
];
|
||||
return _SubScaffold(
|
||||
title: zh ? '联系我们' : 'Contact us',
|
||||
onBack: onBack,
|
||||
child: ListView(padding: const EdgeInsets.fromLTRB(20, 4, 20, 24), children: [
|
||||
Text(zh ? '遇到问题?通过以下任一渠道联系我们,通常数分钟内回复。' : 'Need help? Reach us via any channel below — usually replies in minutes.',
|
||||
style: PangolinText.sm.copyWith(color: c.fg2, height: 1.6)),
|
||||
const SizedBox(height: 16),
|
||||
for (final ch in channels) Padding(padding: const EdgeInsets.only(bottom: 10), child: _contact(c, ch.icon, ch.name, ch.sub, ch.accent)),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(color: c.surface, borderRadius: BorderRadius.circular(PangolinRadius.lg), border: Border.all(color: c.border), boxShadow: PangolinShadow.sm),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(zh ? '服务时间' : 'Support hours', style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 5),
|
||||
Text(zh ? '每日 9:00 – 24:00 (GMT+8)' : 'Daily 9:00 – 24:00 (GMT+8)', style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600)),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _contact(PangolinScheme c, IconData icon, String name, String sub, bool accent) {
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(PangolinRadius.lg),
|
||||
onTap: () {},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: accent ? c.accentSubtle : c.surface,
|
||||
borderRadius: BorderRadius.circular(PangolinRadius.lg),
|
||||
border: Border.all(color: accent ? c.accentBorder : c.border),
|
||||
boxShadow: PangolinShadow.sm,
|
||||
),
|
||||
child: Row(children: [
|
||||
Container(width: 40, height: 40, decoration: BoxDecoration(color: accent ? c.accent : c.bgSubtle, borderRadius: BorderRadius.circular(PangolinRadius.md)),
|
||||
child: Icon(icon, size: 20, color: accent ? PangolinColors.white : c.accent)),
|
||||
const SizedBox(width: 13),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(name, style: PangolinText.body.copyWith(color: c.fg1, fontWeight: FontWeight.w600)),
|
||||
Text(sub, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)),
|
||||
])),
|
||||
Icon(PangolinIcons.externalLink, size: 16, color: c.fg3),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// auth_screen.dart — 登录 / 注册页
|
||||
import 'package:flutter/material.dart';
|
||||
import '../pangolin_theme.dart';
|
||||
import 'pangolin_icons.dart';
|
||||
import 'pangolin_button.dart';
|
||||
import 'pangolin_logo.dart';
|
||||
|
||||
enum _AuthMode { login, register }
|
||||
|
||||
class AuthScreen extends StatefulWidget {
|
||||
const AuthScreen({super.key, required this.onDone, this.zh = true});
|
||||
final VoidCallback onDone;
|
||||
final bool zh;
|
||||
|
||||
@override
|
||||
State<AuthScreen> createState() => _AuthScreenState();
|
||||
}
|
||||
|
||||
class _AuthScreenState extends State<AuthScreen> {
|
||||
_AuthMode _mode = _AuthMode.login;
|
||||
int _step = 0;
|
||||
bool _sent = false;
|
||||
final _email = TextEditingController();
|
||||
final _code = TextEditingController();
|
||||
final _pw = TextEditingController();
|
||||
|
||||
bool get _emailValid => RegExp(r'\S+@\S+\.\S+').hasMatch(_email.text);
|
||||
|
||||
String _t(String zh, String en) => widget.zh ? zh : en;
|
||||
|
||||
@override
|
||||
void dispose() { _email.dispose(); _code.dispose(); _pw.dispose(); super.dispose(); }
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
return Scaffold(
|
||||
backgroundColor: c.bg,
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 18),
|
||||
Column(children: [
|
||||
const PangolinMark(size: 52),
|
||||
const SizedBox(height: 12),
|
||||
Text(widget.zh ? '穿山甲' : 'Pangolin', style: PangolinText.h1.copyWith(color: c.fg1)),
|
||||
const SizedBox(height: 4),
|
||||
Text(_t('极速 · 稳定 · 省心', 'Fast · Stable · Effortless'),
|
||||
style: PangolinText.sm.copyWith(color: c.accent, fontWeight: FontWeight.w600)),
|
||||
]),
|
||||
const SizedBox(height: 28),
|
||||
Row(children: [
|
||||
_tab(_AuthMode.login, _t('登录', 'Log in'), c),
|
||||
_tab(_AuthMode.register, _t('注册', 'Sign up'), c),
|
||||
]),
|
||||
Divider(height: 1, color: c.border),
|
||||
const SizedBox(height: 22),
|
||||
Expanded(child: _mode == _AuthMode.login ? _login(c) : _register(c)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20),
|
||||
child: Text(
|
||||
_t('继续即代表同意《服务条款》与《隐私政策》', 'By continuing you agree to our Terms & Privacy Policy'),
|
||||
textAlign: TextAlign.center,
|
||||
style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tab(_AuthMode m, String label, PangolinScheme c) {
|
||||
final on = _mode == m;
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => setState(() { _mode = m; _step = 0; _sent = false; }),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: on ? c.accent : Colors.transparent, width: 2))),
|
||||
child: Center(child: Text(label, style: PangolinText.body.copyWith(color: on ? c.fg1 : c.fg3, fontWeight: on ? FontWeight.w700 : FontWeight.w500))),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _field(PangolinScheme c, {required IconData icon, required String label, required Widget child}) {
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(label, style: PangolinText.caption.copyWith(color: c.fg2, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 7),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 2),
|
||||
decoration: BoxDecoration(color: c.surface, borderRadius: BorderRadius.circular(PangolinRadius.md), border: Border.all(color: c.borderStrong, width: 1.5)),
|
||||
child: Row(children: [Icon(icon, size: 18, color: c.fg3), const SizedBox(width: 10), Expanded(child: child)]),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
InputDecoration get _bare => const InputDecoration(border: InputBorder.none, isCollapsed: true, contentPadding: EdgeInsets.symmetric(vertical: 12));
|
||||
|
||||
Widget _login(PangolinScheme c) {
|
||||
return Column(children: [
|
||||
_field(c, icon: PangolinIcons.mail, label: _t('邮箱', 'Email'),
|
||||
child: TextField(controller: _email, decoration: _bare.copyWith(hintText: 'your@email.com'), onChanged: (_) => setState(() {}))),
|
||||
const SizedBox(height: 16),
|
||||
_field(c, icon: PangolinIcons.lock, label: _t('密码', 'Password'),
|
||||
child: TextField(controller: _pw, obscureText: true, decoration: _bare.copyWith(hintText: _t('输入密码', 'Enter password')), onChanged: (_) => setState(() {}))),
|
||||
Align(alignment: Alignment.centerRight, child: Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(_t('忘记密码?', 'Forgot password?'), style: PangolinText.sm.copyWith(color: c.accent, fontWeight: FontWeight.w600)),
|
||||
)),
|
||||
const SizedBox(height: 18),
|
||||
PangolinButton(label: _t('登录', 'Log in'), expand: true, onPressed: (_emailValid && _pw.text.isNotEmpty) ? widget.onDone : null),
|
||||
]);
|
||||
}
|
||||
|
||||
Widget _register(PangolinScheme c) {
|
||||
if (_step == 0) {
|
||||
return Column(children: [
|
||||
_field(c, icon: PangolinIcons.mail, label: _t('邮箱', 'Email'),
|
||||
child: Row(children: [
|
||||
Expanded(child: TextField(controller: _email, decoration: _bare.copyWith(hintText: 'your@email.com'), onChanged: (_) => setState(() {}))),
|
||||
GestureDetector(
|
||||
onTap: _emailValid ? () => setState(() => _sent = true) : null,
|
||||
child: Text(_sent ? _t('重新发送', 'Resend') : _t('发送验证码', 'Send code'),
|
||||
style: PangolinText.caption.copyWith(color: _emailValid ? c.accent : c.fg3, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
])),
|
||||
if (_sent) Padding(padding: const EdgeInsets.only(top: 8), child: Row(children: [
|
||||
Icon(PangolinIcons.checkCircle, size: 14, color: c.success), const SizedBox(width: 6),
|
||||
Text('${_t('验证码已发送至', 'Code sent to')} ${_email.text}', style: PangolinText.caption.copyWith(color: c.success, fontWeight: FontWeight.w400)),
|
||||
])),
|
||||
const SizedBox(height: 16),
|
||||
_field(c, icon: PangolinIcons.shieldCheck, label: _t('邮箱验证码', 'Verification code'),
|
||||
child: TextField(controller: _code, keyboardType: TextInputType.number, maxLength: 6,
|
||||
style: PangolinText.mono.copyWith(letterSpacing: 6, color: c.fg1),
|
||||
decoration: _bare.copyWith(counterText: '', hintText: '······'), onChanged: (_) => setState(() {}))),
|
||||
const SizedBox(height: 8),
|
||||
PangolinButton(label: _t('下一步', 'Next'), expand: true, onPressed: (_sent && _code.text.length == 6) ? () => setState(() => _step = 1) : null),
|
||||
]);
|
||||
}
|
||||
return Column(children: [
|
||||
Row(children: [Icon(PangolinIcons.checkCircle, size: 15, color: c.success), const SizedBox(width: 7), Text(_email.text, style: PangolinText.sm.copyWith(color: c.fg2))]),
|
||||
const SizedBox(height: 16),
|
||||
_field(c, icon: PangolinIcons.lock, label: _t('密码', 'Password'),
|
||||
child: TextField(controller: _pw, obscureText: true,
|
||||
decoration: _bare.copyWith(hintText: _t('设置登录密码(用于多端登录)', 'Set a password (multi-device)')), onChanged: (_) => setState(() {}))),
|
||||
const SizedBox(height: 18),
|
||||
PangolinButton(label: _t('创建账户', 'Create account'), expand: true, onPressed: _pw.text.length >= 6 ? widget.onDone : null),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// connect_button.dart — 核心连接键(三态:off / connecting / on)
|
||||
import 'package:flutter/material.dart';
|
||||
import '../pangolin_theme.dart';
|
||||
import 'pangolin_icons.dart';
|
||||
|
||||
enum VpnStatus { off, connecting, on }
|
||||
|
||||
class ConnectButton extends StatefulWidget {
|
||||
const ConnectButton({
|
||||
super.key,
|
||||
required this.status,
|
||||
required this.onTap,
|
||||
this.elapsed = Duration.zero,
|
||||
this.size = 208,
|
||||
});
|
||||
|
||||
final VpnStatus status;
|
||||
final VoidCallback onTap;
|
||||
final Duration elapsed;
|
||||
final double size;
|
||||
|
||||
@override
|
||||
State<ConnectButton> createState() => _ConnectButtonState();
|
||||
}
|
||||
|
||||
class _ConnectButtonState extends State<ConnectButton> with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _spin =
|
||||
AnimationController(vsync: this, duration: const Duration(milliseconds: 1400))..repeat();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_spin.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String _fmt(Duration d) {
|
||||
String two(int n) => n.toString().padLeft(2, '0');
|
||||
return '${two(d.inHours)}:${two(d.inMinutes % 60)}:${two(d.inSeconds % 60)}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
final s = widget.status;
|
||||
|
||||
final Color fill = switch (s) {
|
||||
VpnStatus.off => c.bgSubtle,
|
||||
VpnStatus.connecting => c.accent,
|
||||
VpnStatus.on => c.success,
|
||||
};
|
||||
final Color fg = s == VpnStatus.off ? c.accent : PangolinColors.white;
|
||||
final IconData icon = switch (s) {
|
||||
VpnStatus.off => PangolinIcons.power,
|
||||
VpnStatus.connecting => PangolinIcons.loader,
|
||||
VpnStatus.on => PangolinIcons.shieldCheck,
|
||||
};
|
||||
|
||||
final List<BoxShadow> glow = s == VpnStatus.off
|
||||
? PangolinShadow.md
|
||||
: [
|
||||
BoxShadow(
|
||||
color: (s == VpnStatus.on ? c.success : c.accent).withOpacity(0.18),
|
||||
blurRadius: 0,
|
||||
spreadRadius: 9,
|
||||
),
|
||||
...PangolinShadow.lg,
|
||||
];
|
||||
|
||||
return GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: PangolinMotion.slow,
|
||||
curve: PangolinMotion.easeOut,
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
decoration: BoxDecoration(color: fill, shape: BoxShape.circle, boxShadow: glow),
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: AnimatedBuilder(
|
||||
animation: _spin,
|
||||
builder: (_, __) => CustomPaint(
|
||||
painter: _RingPainter(
|
||||
status: s,
|
||||
track: c.sand200,
|
||||
progress: PangolinColors.white,
|
||||
turns: s == VpnStatus.connecting ? _spin.value : 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 50, color: fg),
|
||||
const SizedBox(height: 6),
|
||||
if (s == VpnStatus.on)
|
||||
Text(_fmt(widget.elapsed), style: PangolinText.mono.copyWith(color: fg, fontSize: 18, fontWeight: FontWeight.w600))
|
||||
else
|
||||
Text(s == VpnStatus.connecting ? '···' : 'TAP',
|
||||
style: PangolinText.mono.copyWith(color: fg, fontSize: 14, fontWeight: FontWeight.w700, letterSpacing: 1.1)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RingPainter extends CustomPainter {
|
||||
_RingPainter({required this.status, required this.track, required this.progress, required this.turns});
|
||||
final VpnStatus status;
|
||||
final Color track;
|
||||
final Color progress;
|
||||
final double turns;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final center = size.center(Offset.zero);
|
||||
final radius = size.width / 2 - 8;
|
||||
final rect = Rect.fromCircle(center: center, radius: radius);
|
||||
|
||||
if (status == VpnStatus.off) {
|
||||
final p = Paint()..style = PaintingStyle.stroke..strokeWidth = 3..strokeCap = StrokeCap.round..color = track;
|
||||
const dash = 0.045, gap = 0.11;
|
||||
for (double a = 0; a < 6.283; a += dash + gap) {
|
||||
canvas.drawArc(rect, a, dash, false, p);
|
||||
}
|
||||
} else if (status == VpnStatus.connecting) {
|
||||
final base = Paint()..style = PaintingStyle.stroke..strokeWidth = 4..color = progress.withOpacity(0.3);
|
||||
canvas.drawArc(rect, 0, 6.283, false, base);
|
||||
final arc = Paint()..style = PaintingStyle.stroke..strokeWidth = 4..strokeCap = StrokeCap.round..color = progress;
|
||||
canvas.drawArc(rect, turns * 6.283, 1.6, false, arc);
|
||||
} else {
|
||||
final p = Paint()..style = PaintingStyle.stroke..strokeWidth = 4..strokeCap = StrokeCap.round..color = progress.withOpacity(0.85);
|
||||
canvas.drawArc(rect, 0, 6.283, false, p);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_RingPainter old) => old.turns != turns || old.status != status;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// country_code.dart — 国家码方块 + 信号条
|
||||
import 'package:flutter/material.dart';
|
||||
import '../pangolin_theme.dart';
|
||||
|
||||
class CountryCode extends StatelessWidget {
|
||||
const CountryCode({super.key, required this.code, this.active = false, this.size = 38});
|
||||
final String code;
|
||||
final bool active;
|
||||
final double size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: active ? c.accent : c.bgSubtle,
|
||||
borderRadius: BorderRadius.circular(PangolinRadius.md),
|
||||
),
|
||||
child: Text(
|
||||
code,
|
||||
style: PangolinText.mono.copyWith(
|
||||
fontSize: size * 0.31,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: active ? PangolinColors.white : c.fg2,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 三格信号条,依据延迟 ping 显示强度。
|
||||
class SignalBars extends StatelessWidget {
|
||||
const SignalBars({super.key, required this.ping});
|
||||
final int ping;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
final level = ping < 40 ? 3 : ping < 90 ? 2 : 1;
|
||||
const heights = [4.0, 8.0, 13.0];
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: List.generate(3, (i) {
|
||||
return Container(
|
||||
width: 3,
|
||||
height: heights[i],
|
||||
margin: EdgeInsets.only(left: i == 0 ? 0 : 2),
|
||||
decoration: BoxDecoration(
|
||||
color: i < level ? c.success : c.borderStrong,
|
||||
borderRadius: BorderRadius.circular(1),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
// home_shell.dart — 主框架(底部 4 Tab:连接 / 节点 / 统计 / 账户)
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../pangolin_theme.dart';
|
||||
import 'pangolin_icons.dart';
|
||||
import 'connect_button.dart';
|
||||
import 'server_tile.dart';
|
||||
import 'country_code.dart';
|
||||
import 'account_screens.dart';
|
||||
import 'pangolin_logo.dart';
|
||||
|
||||
class HomeShell extends StatefulWidget {
|
||||
const HomeShell({super.key, this.zh = true});
|
||||
final bool zh;
|
||||
@override
|
||||
State<HomeShell> createState() => _HomeShellState();
|
||||
}
|
||||
|
||||
class _HomeShellState extends State<HomeShell> {
|
||||
int _tab = 0;
|
||||
VpnStatus _status = VpnStatus.off;
|
||||
ServerInfo _server = const ServerInfo(code: 'HK', name: '香港 · 流媒体', sub: 'Hong Kong', ping: 18);
|
||||
Timer? _timer;
|
||||
int _elapsed = 0;
|
||||
|
||||
String _t(String zh, String en) => widget.zh ? zh : en;
|
||||
|
||||
void _toggle() {
|
||||
if (_status == VpnStatus.off) {
|
||||
setState(() => _status = VpnStatus.connecting);
|
||||
Future.delayed(const Duration(milliseconds: 1500), () {
|
||||
if (!mounted) return;
|
||||
setState(() { _status = VpnStatus.on; _elapsed = 0; });
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (_) => setState(() => _elapsed++));
|
||||
});
|
||||
} else {
|
||||
_timer?.cancel();
|
||||
setState(() => _status = VpnStatus.off);
|
||||
}
|
||||
}
|
||||
|
||||
void _pick(ServerInfo s) {
|
||||
setState(() { _server = s; _tab = 0; });
|
||||
if (_status == VpnStatus.on) {
|
||||
_timer?.cancel();
|
||||
setState(() => _status = VpnStatus.connecting);
|
||||
Future.delayed(const Duration(milliseconds: 1200), () {
|
||||
if (!mounted) return;
|
||||
setState(() { _status = VpnStatus.on; _elapsed = 0; });
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (_) => setState(() => _elapsed++));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() { _timer?.cancel(); super.dispose(); }
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
final pages = [
|
||||
_ConnectPage(zh: widget.zh, status: _status, server: _server, elapsedSec: _elapsed, onToggle: _toggle, onOpenServers: () => setState(() => _tab = 1)),
|
||||
_ServersPage(zh: widget.zh, current: _server.code, onPick: _pick),
|
||||
_StatsPage(zh: widget.zh),
|
||||
_AccountPage(zh: widget.zh),
|
||||
];
|
||||
return Scaffold(
|
||||
backgroundColor: c.bg,
|
||||
body: SafeArea(bottom: false, child: pages[_tab]),
|
||||
bottomNavigationBar: _BottomTab(zh: widget.zh, index: _tab, onTap: (i) => setState(() => _tab = i)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// ── Bottom tab bar ──
|
||||
class _BottomTab extends StatelessWidget {
|
||||
const _BottomTab({required this.zh, required this.index, required this.onTap});
|
||||
final bool zh; final int index; final ValueChanged<int> onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
final items = [
|
||||
(PangolinIcons.power, zh ? '连接' : 'Connect'),
|
||||
(PangolinIcons.globe, zh ? '节点' : 'Servers'),
|
||||
(PangolinIcons.barChart, zh ? '统计' : 'Stats'),
|
||||
(PangolinIcons.user, zh ? '账户' : 'Account'),
|
||||
];
|
||||
return Container(
|
||||
decoration: BoxDecoration(color: c.surface, border: Border(top: BorderSide(color: c.border))),
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 22),
|
||||
child: Row(children: [
|
||||
for (var i = 0; i < items.length; i++)
|
||||
Expanded(child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => onTap(i),
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
Icon(items[i].$1, size: 22, color: i == index ? c.accent : c.fg3),
|
||||
const SizedBox(height: 4),
|
||||
Text(items[i].$2, style: PangolinText.caption.copyWith(color: i == index ? c.accent : c.fg3, fontWeight: i == index ? FontWeight.w700 : FontWeight.w500)),
|
||||
]),
|
||||
)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// ── Connect page ──
|
||||
class _ConnectPage extends StatelessWidget {
|
||||
const _ConnectPage({required this.zh, required this.status, required this.server, required this.elapsedSec, required this.onToggle, required this.onOpenServers});
|
||||
final bool zh; final VpnStatus status; final ServerInfo server; final int elapsedSec; final VoidCallback onToggle, onOpenServers;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
final cap = switch (status) {
|
||||
VpnStatus.off => zh ? '未连接 · 轻点连接' : 'Tap to connect',
|
||||
VpnStatus.connecting => zh ? '连接中…' : 'Connecting…',
|
||||
VpnStatus.on => zh ? '已连接 · 网络已加密' : 'Connected · Encrypted',
|
||||
};
|
||||
return Column(children: [
|
||||
_TopBar(zh: zh, trailing: Text(status == VpnStatus.on ? (zh ? '● 在线' : '● Online') : (zh ? '○ 离线' : '○ Offline'),
|
||||
style: PangolinText.mono.copyWith(fontSize: 12, color: status == VpnStatus.on ? c.success : c.fg3, fontWeight: FontWeight.w600))),
|
||||
Expanded(child: Center(child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
ConnectButton(status: status, elapsed: Duration(seconds: elapsedSec), onTap: onToggle),
|
||||
const SizedBox(height: 24),
|
||||
Text(cap, style: PangolinText.body.copyWith(color: c.fg2, fontWeight: FontWeight.w600)),
|
||||
]))),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 18),
|
||||
child: GestureDetector(
|
||||
onTap: onOpenServers,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(color: c.surface, borderRadius: BorderRadius.circular(PangolinRadius.lg), border: Border.all(color: c.border), boxShadow: PangolinShadow.sm),
|
||||
child: Row(children: [
|
||||
CountryCode(code: server.code, active: true),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(server.name, style: PangolinText.body.copyWith(color: c.fg1, fontWeight: FontWeight.w600)),
|
||||
Text('${server.sub} · ${server.ping}ms', style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)),
|
||||
])),
|
||||
Icon(PangolinIcons.chevronRight, size: 20, color: c.fg3),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/// ── Servers page ──
|
||||
class _ServersPage extends StatelessWidget {
|
||||
const _ServersPage({required this.zh, required this.current, required this.onPick});
|
||||
final bool zh; final String current; final ValueChanged<ServerInfo> onPick;
|
||||
|
||||
static const _servers = [
|
||||
ServerInfo(code: 'HK', name: '香港 · 流媒体', sub: 'Hong Kong', ping: 18),
|
||||
ServerInfo(code: 'JP', name: '日本 东京', sub: 'Tokyo', ping: 32),
|
||||
ServerInfo(code: 'TW', name: '台湾 台北', sub: 'Taipei', ping: 28),
|
||||
ServerInfo(code: 'SG', name: '新加坡', sub: 'Singapore · P2P', ping: 54),
|
||||
ServerInfo(code: 'KR', name: '韩国 首尔', sub: 'Seoul', ping: 41),
|
||||
ServerInfo(code: 'US', name: '美国 洛杉矶', sub: 'Los Angeles', ping: 146),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
_TopBar(zh: zh),
|
||||
Padding(padding: const EdgeInsets.fromLTRB(20, 0, 20, 12),
|
||||
child: Text(zh ? '选择节点' : 'Choose server', style: PangolinText.h2.copyWith(color: c.fg1))),
|
||||
Expanded(child: ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
|
||||
itemCount: _servers.length,
|
||||
itemBuilder: (_, i) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: ServerTile(server: _servers[i], active: _servers[i].code == current, onTap: () => onPick(_servers[i])),
|
||||
),
|
||||
)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/// ── Stats page ──
|
||||
class _StatsPage extends StatelessWidget {
|
||||
const _StatsPage({required this.zh});
|
||||
final bool zh;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
final vals = [2.1, 3.4, 1.8, 4.6, 5.2, 6.1, 3.0];
|
||||
final labels = zh ? ['一','二','三','四','五','六','日'] : ['M','T','W','T','F','S','S'];
|
||||
const maxV = 6.1;
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
_TopBar(zh: zh),
|
||||
Padding(padding: const EdgeInsets.fromLTRB(20, 0, 20, 16),
|
||||
child: Text(zh ? '使用统计' : 'Statistics', style: PangolinText.h2.copyWith(color: c.fg1))),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(children: [
|
||||
for (final m in [(zh ? '本月流量' : 'Traffic', '42.6', 'GB'), (zh ? '平均延迟' : 'Ping', '29', 'ms'), (zh ? '本月时长' : 'Time', '86.4', 'h')])
|
||||
Expanded(child: Container(
|
||||
margin: const EdgeInsets.only(right: 12),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(color: c.surface, borderRadius: BorderRadius.circular(PangolinRadius.lg), border: Border.all(color: c.border), boxShadow: PangolinShadow.sm),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(m.$1, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 6),
|
||||
Text.rich(TextSpan(text: m.$2, style: PangolinText.mono.copyWith(fontSize: 20, color: c.fg1, fontWeight: FontWeight.w500),
|
||||
children: [TextSpan(text: ' ${m.$3}', style: PangolinText.caption.copyWith(color: c.fg3))])),
|
||||
]),
|
||||
)),
|
||||
]),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(color: c.surface, borderRadius: BorderRadius.circular(PangolinRadius.lg), border: Border.all(color: c.border), boxShadow: PangolinShadow.sm),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(zh ? '本周流量 (GB)' : 'This week (GB)', style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 18),
|
||||
Expanded(child: Row(crossAxisAlignment: CrossAxisAlignment.end, children: [
|
||||
for (var i = 0; i < vals.length; i++)
|
||||
Expanded(child: Column(mainAxisAlignment: MainAxisAlignment.end, children: [
|
||||
Text('${vals[i]}', style: PangolinText.mono.copyWith(fontSize: 12, color: c.fg3)),
|
||||
const SizedBox(height: 6),
|
||||
Container(height: 90 * (vals[i] / maxV), margin: const EdgeInsets.symmetric(horizontal: 6),
|
||||
decoration: BoxDecoration(color: c.accent.withOpacity(.85), borderRadius: const BorderRadius.vertical(top: Radius.circular(6)))),
|
||||
const SizedBox(height: 6),
|
||||
Text(labels[i], style: PangolinText.caption.copyWith(color: c.fg3)),
|
||||
])),
|
||||
])),
|
||||
]),
|
||||
),
|
||||
)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/// ── Account page ──
|
||||
class _AccountPage extends StatelessWidget {
|
||||
const _AccountPage({required this.zh});
|
||||
final bool zh;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
_TopBar(zh: zh),
|
||||
Padding(padding: const EdgeInsets.fromLTRB(20, 0, 20, 14),
|
||||
child: Text(zh ? '我的' : 'Account', style: PangolinText.h2.copyWith(color: c.fg1))),
|
||||
Expanded(child: ListView(padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [PangolinColors.clay600, PangolinColors.clay800]),
|
||||
borderRadius: BorderRadius.circular(PangolinRadius.xl), boxShadow: PangolinShadow.md,
|
||||
),
|
||||
child: Row(children: [
|
||||
Container(width: 44, height: 44, decoration: BoxDecoration(color: PangolinColors.white.withOpacity(.18), shape: BoxShape.circle),
|
||||
child: const Icon(PangolinIcons.crown, size: 22, color: PangolinColors.white)),
|
||||
const SizedBox(width: 11),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const Text('me@pangolin.vpn', style: TextStyle(color: PangolinColors.white, fontWeight: FontWeight.w700, fontSize: 16)),
|
||||
const SizedBox(height: 4),
|
||||
Text(zh ? 'PRO 会员 · 至 2026-12-31' : 'PRO · until 2026-12-31', style: TextStyle(color: PangolinColors.white.withOpacity(.85), fontSize: 12)),
|
||||
])),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => PlansScreen(zh: zh, onChoose: (_) => Navigator.of(context).push(MaterialPageRoute(builder: (_) => RedeemScreen(zh: zh)))))),
|
||||
style: FilledButton.styleFrom(backgroundColor: PangolinColors.white, foregroundColor: PangolinColors.clay700, shape: const StadiumBorder(), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10)),
|
||||
child: Text(zh ? '续费 / 升级' : 'Renew', style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 13)),
|
||||
),
|
||||
]),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
_accountRow(c, PangolinIcons.mail, zh ? '邮箱' : 'Email', 'me@pangolin.vpn'),
|
||||
_accountRow(c, PangolinIcons.lock, zh ? '密码' : 'Password', '••••••••••'),
|
||||
_accountRow(c, PangolinIcons.monitorSmartphone, zh ? '我的设备' : 'My devices', '',
|
||||
onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => DevicesScreen(zh: zh)))),
|
||||
_accountRow(c, PangolinIcons.shoppingBag, zh ? '兑换 & 购买' : 'Redeem & buy', '',
|
||||
onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => RedeemScreen(zh: zh)))),
|
||||
_accountRow(c, PangolinIcons.messageCircle, zh ? '联系我们' : 'Contact us', '',
|
||||
onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => ContactScreen(zh: zh)))),
|
||||
_accountRow(c, PangolinIcons.logOut, zh ? '退出登录' : 'Sign out', '', danger: true),
|
||||
])),
|
||||
]);
|
||||
}
|
||||
|
||||
Widget _accountRow(PangolinScheme c, IconData icon, String title, String value, {bool danger = false, VoidCallback? onTap}) {
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
decoration: BoxDecoration(color: c.surface, borderRadius: BorderRadius.circular(PangolinRadius.lg), border: Border.all(color: c.border), boxShadow: PangolinShadow.sm),
|
||||
child: Row(children: [
|
||||
Icon(icon, size: 20, color: danger ? c.danger : c.accent),
|
||||
const SizedBox(width: 13),
|
||||
Expanded(child: Text(title, style: PangolinText.body.copyWith(color: danger ? c.danger : c.fg1, fontWeight: FontWeight.w500))),
|
||||
if (value.isNotEmpty) Text(value, style: PangolinText.sm.copyWith(color: c.fg3))
|
||||
else Icon(PangolinIcons.chevronRight, size: 18, color: c.fg3),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// ── Shared top bar ──
|
||||
class _TopBar extends StatelessWidget {
|
||||
const _TopBar({required this.zh, this.trailing});
|
||||
final bool zh; final Widget? trailing;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 6, 20, 14),
|
||||
child: Row(children: [
|
||||
PangolinBrandLockup(zh: zh, markSize: 24),
|
||||
const Spacer(),
|
||||
if (trailing != null) trailing!,
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// onboarding_screen.dart — 首次引导页(3 屏可滑动)
|
||||
import 'package:flutter/material.dart';
|
||||
import '../pangolin_theme.dart';
|
||||
import 'pangolin_icons.dart';
|
||||
import 'pangolin_button.dart';
|
||||
|
||||
class OnboardingStep {
|
||||
const OnboardingStep({required this.icon, required this.title, required this.body, required this.cta, this.tint, this.tintBg});
|
||||
final IconData icon;
|
||||
final String title, body, cta;
|
||||
final Color? tint, tintBg;
|
||||
}
|
||||
|
||||
class OnboardingScreen extends StatefulWidget {
|
||||
const OnboardingScreen({super.key, required this.onDone, this.steps});
|
||||
final VoidCallback onDone;
|
||||
final List<OnboardingStep>? steps;
|
||||
|
||||
@override
|
||||
State<OnboardingScreen> createState() => _OnboardingScreenState();
|
||||
}
|
||||
|
||||
class _OnboardingScreenState extends State<OnboardingScreen> {
|
||||
int _i = 0;
|
||||
final _pager = PageController();
|
||||
|
||||
List<OnboardingStep> _defaults(PangolinScheme c) => [
|
||||
OnboardingStep(icon: PangolinIcons.globe, title: '一键连接全球', body: '80+ 节点,智能选择最快线路,稳定不掉线。', cta: '下一步'),
|
||||
OnboardingStep(icon: PangolinIcons.shield, title: '授权网络配置', body: '系统会请求添加一个网络配置,用于建立加密隧道。', cta: '允许并继续'),
|
||||
OnboardingStep(icon: PangolinIcons.shieldCheck, title: '安全 · 无日志', body: '端到端加密,我们不记录你的任何浏览数据。', cta: '开始使用',
|
||||
tint: c.success, tintBg: c.successSubtle),
|
||||
];
|
||||
|
||||
@override
|
||||
void dispose() { _pager.dispose(); super.dispose(); }
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
final steps = widget.steps ?? _defaults(c);
|
||||
final last = _i == steps.length - 1;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: c.bg,
|
||||
body: SafeArea(
|
||||
child: Column(children: [
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: TextButton(onPressed: widget.onDone, child: Text('跳过', style: PangolinText.sm.copyWith(color: c.fg3, fontWeight: FontWeight.w600))),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: PageView.builder(
|
||||
controller: _pager,
|
||||
itemCount: steps.length,
|
||||
onPageChanged: (v) => setState(() => _i = v),
|
||||
itemBuilder: (_, idx) {
|
||||
final s = steps[idx];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
Container(
|
||||
width: 120, height: 120,
|
||||
decoration: BoxDecoration(color: s.tintBg ?? c.accentSubtle, shape: BoxShape.circle),
|
||||
child: Icon(s.icon, size: 52, color: s.tint ?? c.accent),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
Text(s.title, textAlign: TextAlign.center, style: PangolinText.h1.copyWith(color: c.fg1, fontSize: 26)),
|
||||
const SizedBox(height: 12),
|
||||
Text(s.body, textAlign: TextAlign.center, style: PangolinText.body.copyWith(color: c.fg2, height: 1.6)),
|
||||
]),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Row(mainAxisAlignment: MainAxisAlignment.center, children: List.generate(steps.length, (k) {
|
||||
final on = k == _i;
|
||||
return AnimatedContainer(
|
||||
duration: PangolinMotion.base, curve: PangolinMotion.easeOut,
|
||||
width: on ? 22 : 7, height: 7, margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
decoration: BoxDecoration(color: on ? c.accent : c.borderStrong, borderRadius: BorderRadius.circular(PangolinRadius.full)),
|
||||
);
|
||||
})),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(28, 22, 28, 26),
|
||||
child: PangolinButton(
|
||||
label: steps[_i].cta, expand: true,
|
||||
onPressed: () {
|
||||
if (last) { widget.onDone(); }
|
||||
else { _pager.nextPage(duration: PangolinMotion.base, curve: PangolinMotion.easeOut); }
|
||||
},
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// pangolin_button.dart — 胶囊按钮(primary / secondary / ghost / danger)
|
||||
import 'package:flutter/material.dart';
|
||||
import '../pangolin_theme.dart';
|
||||
|
||||
enum PangolinButtonVariant { primary, secondary, ghost, danger }
|
||||
|
||||
class PangolinButton extends StatelessWidget {
|
||||
const PangolinButton({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.onPressed,
|
||||
this.icon,
|
||||
this.variant = PangolinButtonVariant.primary,
|
||||
this.expand = false,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final VoidCallback? onPressed;
|
||||
final IconData? icon;
|
||||
final PangolinButtonVariant variant;
|
||||
final bool expand;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
final disabled = onPressed == null;
|
||||
|
||||
late final Color bg, fg;
|
||||
Border? border;
|
||||
switch (variant) {
|
||||
case PangolinButtonVariant.primary:
|
||||
bg = c.accent; fg = c.fgOnAccent; break;
|
||||
case PangolinButtonVariant.secondary:
|
||||
bg = c.surface; fg = c.fg1; border = Border.all(color: c.borderStrong, width: 1.5); break;
|
||||
case PangolinButtonVariant.ghost:
|
||||
bg = Colors.transparent; fg = c.accent; break;
|
||||
case PangolinButtonVariant.danger:
|
||||
bg = c.dangerSubtle; fg = c.danger; break;
|
||||
}
|
||||
|
||||
final child = Row(
|
||||
mainAxisSize: expand ? MainAxisSize.max : MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (icon != null) ...[Icon(icon, size: 18, color: fg), const SizedBox(width: 8)],
|
||||
Text(label, style: PangolinText.sm.copyWith(color: fg, fontWeight: FontWeight.w600, fontSize: 15)),
|
||||
],
|
||||
);
|
||||
|
||||
return Opacity(
|
||||
opacity: disabled ? 0.45 : 1,
|
||||
child: Material(
|
||||
color: bg,
|
||||
shape: StadiumBorder(side: border?.top ?? BorderSide.none),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onPressed,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 13),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// pangolin_icons.dart — Lucide 图标映射表
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
|
||||
class PangolinIcons {
|
||||
PangolinIcons._();
|
||||
|
||||
// ── 连接 / 状态 ──
|
||||
static const power = LucideIcons.power;
|
||||
static const shieldCheck = LucideIcons.shieldCheck;
|
||||
static const shield = LucideIcons.shield;
|
||||
static const loader = LucideIcons.loader;
|
||||
static const zap = LucideIcons.zap;
|
||||
static const globe = LucideIcons.globe;
|
||||
|
||||
// ── 导航 / 操作 ──
|
||||
static const settings = LucideIcons.settings;
|
||||
static const user = LucideIcons.user;
|
||||
static const search = LucideIcons.search;
|
||||
static const check = LucideIcons.check;
|
||||
static const checkCircle = LucideIcons.checkCircle;
|
||||
static const x = LucideIcons.x;
|
||||
static const chevronRight = LucideIcons.chevronRight;
|
||||
static const chevronDown = LucideIcons.chevronDown;
|
||||
static const arrowLeft = LucideIcons.arrowLeft;
|
||||
static const arrowUp = LucideIcons.arrowUp;
|
||||
static const arrowDown = LucideIcons.arrowDown;
|
||||
static const refreshCw = LucideIcons.refreshCw;
|
||||
static const compass = LucideIcons.compass;
|
||||
static const barChart = LucideIcons.barChart;
|
||||
static const mapPin = LucideIcons.mapPin;
|
||||
|
||||
// ── 主题 ──
|
||||
static const moon = LucideIcons.moon;
|
||||
static const sun = LucideIcons.sun;
|
||||
|
||||
// ── 账户 / 套餐 / 兑换 ──
|
||||
static const crown = LucideIcons.crown;
|
||||
static const ticket = LucideIcons.ticket;
|
||||
static const creditCard = LucideIcons.creditCard;
|
||||
static const shoppingBag = LucideIcons.shoppingBag;
|
||||
static const mail = LucideIcons.mail;
|
||||
static const lock = LucideIcons.lock;
|
||||
static const logOut = LucideIcons.logOut;
|
||||
static const send = LucideIcons.send;
|
||||
static const messageCircle= LucideIcons.messageCircle;
|
||||
static const externalLink = LucideIcons.externalLink;
|
||||
|
||||
// ── 设备 ──
|
||||
static const laptop = LucideIcons.laptop;
|
||||
static const smartphone = LucideIcons.smartphone;
|
||||
static const monitorSmartphone = LucideIcons.smartphone; // 回退
|
||||
|
||||
static IconData? byName(String name) => _byName[name];
|
||||
|
||||
static const Map<String, IconData> _byName = {
|
||||
'power': power, 'shield-check': shieldCheck, 'shield': shield, 'loader-circle': loader,
|
||||
'zap': zap, 'globe': globe, 'settings': settings, 'user': user, 'search': search,
|
||||
'check': check, 'check-circle': checkCircle, 'x': x, 'chevron-right': chevronRight,
|
||||
'chevron-down': chevronDown, 'arrow-left': arrowLeft, 'arrow-up': arrowUp, 'arrow-down': arrowDown,
|
||||
'refresh-cw': refreshCw, 'compass': compass, 'chart-no-axes-column': barChart, 'chart': barChart,
|
||||
'map-pin': mapPin, 'moon': moon, 'sun': sun, 'crown': crown, 'ticket': ticket,
|
||||
'credit-card': creditCard, 'shopping-bag': shoppingBag, 'mail': mail, 'lock': lock,
|
||||
'log-out': logOut, 'send': send, 'message-circle': messageCircle, 'external-link': externalLink,
|
||||
'laptop': laptop, 'smartphone': smartphone, 'monitor-smartphone': monitorSmartphone,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// pangolin_logo.dart — 品牌标 / 字标 / App 图标(SVG)
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import '../pangolin_theme.dart';
|
||||
|
||||
const String _base = 'assets';
|
||||
|
||||
/// 仅标记(行走穿山甲)。深色主题自动用反白版。
|
||||
class PangolinMark extends StatelessWidget {
|
||||
const PangolinMark({super.key, this.size = 28, this.forceLight, this.forceDark});
|
||||
final double size;
|
||||
final bool? forceLight;
|
||||
final bool? forceDark;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = forceDark ?? (forceLight == true ? false : Theme.of(context).brightness == Brightness.dark);
|
||||
final asset = isDark ? '$_base/logo-mark-white.svg' : '$_base/logo-mark.svg';
|
||||
return SvgPicture.asset(asset, width: size, height: size);
|
||||
}
|
||||
}
|
||||
|
||||
/// 横版字标锁版(浅背景)
|
||||
class PangolinWordmark extends StatelessWidget {
|
||||
const PangolinWordmark({super.key, this.height = 28});
|
||||
final double height;
|
||||
@override
|
||||
Widget build(BuildContext context) => SvgPicture.asset('$_base/logo-wordmark.svg', height: height);
|
||||
}
|
||||
|
||||
/// App 图标(clay 渐变圆角方 + 白色穿山甲)
|
||||
class PangolinAppIcon extends StatelessWidget {
|
||||
const PangolinAppIcon({super.key, this.size = 64});
|
||||
final double size;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(size * 0.22),
|
||||
child: SvgPicture.asset('$_base/app-icon.svg', width: size, height: size),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 顶栏品牌锁版:标记 + 「穿山甲 / Pangolin」
|
||||
class PangolinBrandLockup extends StatelessWidget {
|
||||
const PangolinBrandLockup({super.key, this.zh = true, this.markSize = 26});
|
||||
final bool zh; final double markSize;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
return Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
PangolinMark(size: markSize),
|
||||
const SizedBox(width: 9),
|
||||
Text(zh ? '穿山甲' : 'Pangolin',
|
||||
style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// pangolin_widgets.dart — 统一导出穿山甲 VPN 核心组件。
|
||||
export 'pangolin_icons.dart';
|
||||
export 'pangolin_button.dart';
|
||||
export 'country_code.dart';
|
||||
export 'status_pill.dart';
|
||||
export 'connect_button.dart';
|
||||
export 'server_tile.dart';
|
||||
export 'plan_card.dart';
|
||||
export 'auth_screen.dart';
|
||||
export 'onboarding_screen.dart';
|
||||
export 'home_shell.dart';
|
||||
export 'account_screens.dart';
|
||||
export 'pangolin_logo.dart';
|
||||
@@ -0,0 +1,110 @@
|
||||
// plan_card.dart — 套餐卡(免费 / 专业渐变高亮 / 团队)
|
||||
import 'package:flutter/material.dart';
|
||||
import '../pangolin_theme.dart';
|
||||
import 'pangolin_icons.dart';
|
||||
|
||||
class PlanCard extends StatelessWidget {
|
||||
const PlanCard({
|
||||
super.key,
|
||||
required this.name,
|
||||
required this.price,
|
||||
required this.period,
|
||||
required this.features,
|
||||
required this.ctaLabel,
|
||||
this.featured = false,
|
||||
this.isCurrent = false,
|
||||
this.popularLabel,
|
||||
this.onPressed,
|
||||
});
|
||||
|
||||
final String name, price, period, ctaLabel;
|
||||
final List<String> features;
|
||||
final bool featured, isCurrent;
|
||||
final String? popularLabel;
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
final onCard = featured ? PangolinColors.white : c.fg1;
|
||||
final subColor = featured ? PangolinColors.white.withOpacity(0.92) : c.fg2;
|
||||
|
||||
final card = Container(
|
||||
padding: const EdgeInsets.fromLTRB(20, 22, 20, 20),
|
||||
decoration: BoxDecoration(
|
||||
gradient: featured
|
||||
? const LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [PangolinColors.clay600, PangolinColors.clay800])
|
||||
: null,
|
||||
color: featured ? null : c.surface,
|
||||
border: featured ? null : Border.all(color: c.border),
|
||||
borderRadius: BorderRadius.circular(PangolinRadius.xl),
|
||||
boxShadow: featured ? PangolinShadow.md : PangolinShadow.sm,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(name, style: PangolinText.sm.copyWith(color: featured ? PangolinColors.white.withOpacity(0.85) : c.fg2, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(price, style: PangolinText.display.copyWith(color: onCard, fontSize: 30)),
|
||||
Text(period, style: PangolinText.caption.copyWith(color: subColor, fontSize: 13)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
...features.map((f) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 9),
|
||||
child: Row(children: [
|
||||
Icon(PangolinIcons.check, size: 15, color: featured ? PangolinColors.white : c.success),
|
||||
const SizedBox(width: 9),
|
||||
Expanded(child: Text(f, style: PangolinText.sm.copyWith(color: subColor, fontSize: 13))),
|
||||
]),
|
||||
)),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: Material(
|
||||
color: featured ? PangolinColors.white : (isCurrent ? Colors.transparent : c.accent),
|
||||
shape: StadiumBorder(side: isCurrent ? BorderSide(color: c.borderStrong, width: 1.5) : BorderSide.none),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: isCurrent ? null : onPressed,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Center(
|
||||
child: Text(ctaLabel,
|
||||
style: PangolinText.sm.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 14,
|
||||
color: featured ? PangolinColors.clay700 : (isCurrent ? c.fg2 : PangolinColors.white),
|
||||
)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (popularLabel == null) return card;
|
||||
return Stack(clipBehavior: Clip.none, children: [
|
||||
card,
|
||||
Positioned(
|
||||
top: -10,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 3),
|
||||
decoration: BoxDecoration(color: PangolinColors.white, borderRadius: BorderRadius.circular(PangolinRadius.full), boxShadow: PangolinShadow.sm),
|
||||
child: Text(popularLabel!, style: PangolinText.caption.copyWith(color: PangolinColors.clay700, fontWeight: FontWeight.w700, fontSize: 11)),
|
||||
),
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// server_tile.dart — 服务器列表行
|
||||
import 'package:flutter/material.dart';
|
||||
import '../pangolin_theme.dart';
|
||||
import 'country_code.dart';
|
||||
import 'pangolin_icons.dart';
|
||||
|
||||
class ServerInfo {
|
||||
const ServerInfo({required this.code, required this.name, required this.sub, required this.ping});
|
||||
final String code, name, sub;
|
||||
final int ping;
|
||||
}
|
||||
|
||||
class ServerTile extends StatelessWidget {
|
||||
const ServerTile({super.key, required this.server, this.active = false, this.onTap});
|
||||
final ServerInfo server;
|
||||
final bool active;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
color: active ? c.accentSubtle : Colors.transparent,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
CountryCode(code: server.code, active: active),
|
||||
const SizedBox(width: 13),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(server.name, style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600, fontSize: 15)),
|
||||
const SizedBox(height: 2),
|
||||
Text(server.sub, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text('${server.ping}ms', style: PangolinText.mono.copyWith(color: c.fg2, fontSize: 12)),
|
||||
const SizedBox(width: 8),
|
||||
SignalBars(ping: server.ping),
|
||||
if (active) ...[const SizedBox(width: 10), Icon(PangolinIcons.check, size: 18, color: c.accent)],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// status_pill.dart — 状态胶囊(色点 + 文字)
|
||||
import 'package:flutter/material.dart';
|
||||
import '../pangolin_theme.dart';
|
||||
|
||||
enum PangolinStatus { connected, connecting, error, neutral, pro }
|
||||
|
||||
class StatusPill extends StatelessWidget {
|
||||
const StatusPill({super.key, required this.label, this.status = PangolinStatus.neutral});
|
||||
final String label;
|
||||
final PangolinStatus status;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
late final Color bg, fg, dot;
|
||||
bool showDot = true;
|
||||
switch (status) {
|
||||
case PangolinStatus.connected:
|
||||
bg = c.successSubtle; fg = c.success; dot = c.success; break;
|
||||
case PangolinStatus.connecting:
|
||||
bg = c.warningSubtle; fg = c.warning; dot = c.warning; break;
|
||||
case PangolinStatus.error:
|
||||
bg = c.dangerSubtle; fg = c.danger; dot = c.danger; break;
|
||||
case PangolinStatus.pro:
|
||||
bg = c.accent; fg = c.fgOnAccent; dot = c.fgOnAccent; showDot = false; break;
|
||||
case PangolinStatus.neutral:
|
||||
bg = c.bgSubtle; fg = c.fg2; dot = c.fg3; break;
|
||||
}
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(PangolinRadius.full)),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (showDot) ...[
|
||||
Container(width: 7, height: 7, decoration: BoxDecoration(color: dot, shape: BoxShape.circle)),
|
||||
const SizedBox(width: 7),
|
||||
],
|
||||
Text(label, style: PangolinText.caption.copyWith(color: fg, fontWeight: FontWeight.w600)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
name: pangolin_vpn
|
||||
description: 穿山甲 · Pangolin — 极简、稳定、跨平台网络加速客户端。
|
||||
publish_to: "none"
|
||||
version: 1.0.0+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.5.0
|
||||
flutter: ">=3.24.0"
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_svg: ^2.0.10 # 加载 assets/ 下的矢量 logo
|
||||
lucide_icons: ^0.257.0 # Lucide 细线条图标
|
||||
google_fonts: ^6.2.1 # 运行时加载 Sora / Manrope / Noto Sans SC / JetBrains Mono
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^4.0.0
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
|
||||
# ── 资产(SVG logo)──
|
||||
assets:
|
||||
- assets/logo-mark.svg
|
||||
- assets/logo-mark-white.svg
|
||||
- assets/logo-wordmark.svg
|
||||
- assets/app-icon.svg
|
||||
|
||||
# ── 字体(由 google_fonts 运行时加载,无需本地 ttf)──
|
||||
# 若需离线/打包字体,下载 ttf 放 fonts/,并按 pubspec 模板恢复 fonts: 段。
|
||||
Reference in New Issue
Block a user