Compare commits
2 Commits
05fd2ec61a
...
3c97eb7326
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c97eb7326 | |||
| 54f13da9e3 |
BIN
airhub_app/assets/fonts/Inter-Bold.ttf
Normal file
BIN
airhub_app/assets/fonts/Inter-Medium.ttf
Normal file
BIN
airhub_app/assets/fonts/Inter-Regular.ttf
Normal file
BIN
airhub_app/assets/fonts/Inter-SemiBold.ttf
Normal file
@ -1,32 +1,27 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="128" height="128">
|
||||
<!-- Pixel Badge AI - 8-bit style circular screen device -->
|
||||
<style>
|
||||
.frame { fill: #1E3A5F; }
|
||||
.screen { fill: #3B82F6; }
|
||||
.glow { fill: #60A5FA; }
|
||||
.pixel { fill: #FFFFFF; }
|
||||
</style>
|
||||
|
||||
|
||||
<!-- Outer frame -->
|
||||
<rect class="frame" x="6" y="6" width="20" height="20"/>
|
||||
<rect class="frame" x="4" y="8" width="2" height="16"/>
|
||||
<rect class="frame" x="26" y="8" width="2" height="16"/>
|
||||
<rect class="frame" x="8" y="4" width="16" height="2"/>
|
||||
<rect class="frame" x="8" y="26" width="16" height="2"/>
|
||||
<rect fill="#1E3A5F" x="6" y="6" width="20" height="20"/>
|
||||
<rect fill="#1E3A5F" x="4" y="8" width="2" height="16"/>
|
||||
<rect fill="#1E3A5F" x="26" y="8" width="2" height="16"/>
|
||||
<rect fill="#1E3A5F" x="8" y="4" width="16" height="2"/>
|
||||
<rect fill="#1E3A5F" x="8" y="26" width="16" height="2"/>
|
||||
|
||||
<!-- Screen glow -->
|
||||
<rect class="glow" x="8" y="8" width="16" height="16" opacity="0.3"/>
|
||||
<rect fill="#60A5FA" x="8" y="8" width="16" height="16" opacity="0.3"/>
|
||||
|
||||
<!-- Screen -->
|
||||
<rect class="screen" x="10" y="10" width="12" height="12"/>
|
||||
<rect fill="#3B82F6" x="10" y="10" width="12" height="12"/>
|
||||
|
||||
<!-- Pixel face on screen -->
|
||||
<rect class="pixel" x="12" y="12" width="2" height="2"/>
|
||||
<rect class="pixel" x="18" y="12" width="2" height="2"/>
|
||||
<rect class="pixel" x="12" y="18" width="2" height="2"/>
|
||||
<rect class="pixel" x="14" y="20" width="4" height="2"/>
|
||||
<rect class="pixel" x="18" y="18" width="2" height="2"/>
|
||||
<rect fill="#FFFFFF" x="12" y="12" width="2" height="2"/>
|
||||
<rect fill="#FFFFFF" x="18" y="12" width="2" height="2"/>
|
||||
<rect fill="#FFFFFF" x="12" y="18" width="2" height="2"/>
|
||||
<rect fill="#FFFFFF" x="14" y="20" width="4" height="2"/>
|
||||
<rect fill="#FFFFFF" x="18" y="18" width="2" height="2"/>
|
||||
|
||||
<!-- Corner highlights -->
|
||||
<rect class="glow" x="6" y="6" width="2" height="2" opacity="0.5"/>
|
||||
<rect fill="#60A5FA" x="6" y="6" width="2" height="2" opacity="0.5"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.1 KiB |
@ -1,28 +1,23 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="128" height="128">
|
||||
<!-- Pixel Badge Basic - 8-bit style circular screen device (gray) -->
|
||||
<style>
|
||||
.frame { fill: #475569; }
|
||||
.screen { fill: #94A3B8; }
|
||||
.light { fill: #CBD5E1; }
|
||||
.pixel { fill: #F1F5F9; }
|
||||
</style>
|
||||
|
||||
|
||||
<!-- Outer frame -->
|
||||
<rect class="frame" x="6" y="6" width="20" height="20"/>
|
||||
<rect class="frame" x="4" y="8" width="2" height="16"/>
|
||||
<rect class="frame" x="26" y="8" width="2" height="16"/>
|
||||
<rect class="frame" x="8" y="4" width="16" height="2"/>
|
||||
<rect class="frame" x="8" y="26" width="16" height="2"/>
|
||||
<rect fill="#475569" x="6" y="6" width="20" height="20"/>
|
||||
<rect fill="#475569" x="4" y="8" width="2" height="16"/>
|
||||
<rect fill="#475569" x="26" y="8" width="2" height="16"/>
|
||||
<rect fill="#475569" x="8" y="4" width="16" height="2"/>
|
||||
<rect fill="#475569" x="8" y="26" width="16" height="2"/>
|
||||
|
||||
<!-- Screen -->
|
||||
<rect class="screen" x="10" y="10" width="12" height="12"/>
|
||||
<rect fill="#94A3B8" x="10" y="10" width="12" height="12"/>
|
||||
|
||||
<!-- Simple pixel pattern on screen -->
|
||||
<rect class="pixel" x="12" y="12" width="2" height="2"/>
|
||||
<rect class="pixel" x="18" y="12" width="2" height="2"/>
|
||||
<rect class="pixel" x="14" y="16" width="4" height="2"/>
|
||||
<rect class="pixel" x="12" y="18" width="8" height="2"/>
|
||||
<rect fill="#F1F5F9" x="12" y="12" width="2" height="2"/>
|
||||
<rect fill="#F1F5F9" x="18" y="12" width="2" height="2"/>
|
||||
<rect fill="#F1F5F9" x="14" y="16" width="4" height="2"/>
|
||||
<rect fill="#F1F5F9" x="12" y="18" width="8" height="2"/>
|
||||
|
||||
<!-- Corner highlights -->
|
||||
<rect class="light" x="6" y="6" width="2" height="2" opacity="0.3"/>
|
||||
<rect fill="#CBD5E1" x="6" y="6" width="2" height="2" opacity="0.3"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 965 B |
@ -1,39 +1,33 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="128" height="128">
|
||||
<!-- Pixel Capybara - 8-bit style -->
|
||||
<style>
|
||||
.body { fill: #D4A574; }
|
||||
.dark { fill: #A67B5B; }
|
||||
.nose { fill: #8B5A3C; }
|
||||
.eye { fill: #2D2D2D; }
|
||||
.cheek { fill: #FFCCCB; }
|
||||
</style>
|
||||
|
||||
|
||||
<!-- Body -->
|
||||
<rect class="body" x="8" y="14" width="16" height="12"/>
|
||||
<rect class="body" x="6" y="16" width="2" height="8"/>
|
||||
<rect class="body" x="24" y="16" width="2" height="8"/>
|
||||
<rect fill="#D4A574" x="8" y="14" width="16" height="12"/>
|
||||
<rect fill="#D4A574" x="6" y="16" width="2" height="8"/>
|
||||
<rect fill="#D4A574" x="24" y="16" width="2" height="8"/>
|
||||
|
||||
<!-- Head -->
|
||||
<rect class="body" x="10" y="8" width="12" height="8"/>
|
||||
<rect class="body" x="8" y="10" width="2" height="4"/>
|
||||
<rect class="body" x="22" y="10" width="2" height="4"/>
|
||||
<rect fill="#D4A574" x="10" y="8" width="12" height="8"/>
|
||||
<rect fill="#D4A574" x="8" y="10" width="2" height="4"/>
|
||||
<rect fill="#D4A574" x="22" y="10" width="2" height="4"/>
|
||||
|
||||
<!-- Ears -->
|
||||
<rect class="dark" x="8" y="6" width="4" height="4"/>
|
||||
<rect class="dark" x="20" y="6" width="4" height="4"/>
|
||||
<rect fill="#A67B5B" x="8" y="6" width="4" height="4"/>
|
||||
<rect fill="#A67B5B" x="20" y="6" width="4" height="4"/>
|
||||
|
||||
<!-- Eyes -->
|
||||
<rect class="eye" x="12" y="10" width="2" height="2"/>
|
||||
<rect class="eye" x="18" y="10" width="2" height="2"/>
|
||||
<rect fill="#2D2D2D" x="12" y="10" width="2" height="2"/>
|
||||
<rect fill="#2D2D2D" x="18" y="10" width="2" height="2"/>
|
||||
|
||||
<!-- Nose -->
|
||||
<rect class="nose" x="14" y="12" width="4" height="2"/>
|
||||
<rect fill="#8B5A3C" x="14" y="12" width="4" height="2"/>
|
||||
|
||||
<!-- Cheeks -->
|
||||
<rect class="cheek" x="10" y="12" width="2" height="2" opacity="0.6"/>
|
||||
<rect class="cheek" x="20" y="12" width="2" height="2" opacity="0.6"/>
|
||||
<rect fill="#FFCCCB" x="10" y="12" width="2" height="2" opacity="0.6"/>
|
||||
<rect fill="#FFCCCB" x="20" y="12" width="2" height="2" opacity="0.6"/>
|
||||
|
||||
<!-- Legs -->
|
||||
<rect class="dark" x="10" y="24" width="4" height="4"/>
|
||||
<rect class="dark" x="18" y="24" width="4" height="4"/>
|
||||
<rect fill="#A67B5B" x="10" y="24" width="4" height="4"/>
|
||||
<rect fill="#A67B5B" x="18" y="24" width="4" height="4"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.2 KiB |
@ -1,38 +1,32 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="120" height="120">
|
||||
<!-- Pixel Mystery Box - 8-bit Mario style -->
|
||||
<style>
|
||||
.gold-light { fill: #FCD34D; }
|
||||
.gold-main { fill: #F59E0B; }
|
||||
.gold-dark { fill: #D97706; }
|
||||
.gold-shadow { fill: #92400E; }
|
||||
.question { fill: #92400E; }
|
||||
</style>
|
||||
|
||||
|
||||
<!-- Box body -->
|
||||
<rect class="gold-main" x="4" y="4" width="24" height="24"/>
|
||||
<rect fill="#F59E0B" x="4" y="4" width="24" height="24"/>
|
||||
|
||||
<!-- Top highlight -->
|
||||
<rect class="gold-light" x="4" y="4" width="24" height="4"/>
|
||||
<rect class="gold-light" x="4" y="4" width="4" height="24"/>
|
||||
<rect fill="#FCD34D" x="4" y="4" width="24" height="4"/>
|
||||
<rect fill="#FCD34D" x="4" y="4" width="4" height="24"/>
|
||||
|
||||
<!-- Bottom shadow -->
|
||||
<rect class="gold-dark" x="4" y="24" width="24" height="4"/>
|
||||
<rect class="gold-dark" x="24" y="4" width="4" height="24"/>
|
||||
<rect fill="#D97706" x="4" y="24" width="24" height="4"/>
|
||||
<rect fill="#D97706" x="24" y="4" width="4" height="24"/>
|
||||
|
||||
<!-- Corner details -->
|
||||
<rect class="gold-shadow" x="24" y="24" width="4" height="4"/>
|
||||
<rect class="gold-light" x="4" y="4" width="4" height="4"/>
|
||||
<rect fill="#92400E" x="24" y="24" width="4" height="4"/>
|
||||
<rect fill="#FCD34D" x="4" y="4" width="4" height="4"/>
|
||||
|
||||
<!-- Inner border -->
|
||||
<rect class="gold-dark" x="6" y="6" width="20" height="2"/>
|
||||
<rect class="gold-dark" x="6" y="24" width="20" height="2"/>
|
||||
<rect class="gold-dark" x="6" y="6" width="2" height="20"/>
|
||||
<rect class="gold-dark" x="24" y="6" width="2" height="20"/>
|
||||
<rect fill="#D97706" x="6" y="6" width="20" height="2"/>
|
||||
<rect fill="#D97706" x="6" y="24" width="20" height="2"/>
|
||||
<rect fill="#D97706" x="6" y="6" width="2" height="20"/>
|
||||
<rect fill="#D97706" x="24" y="6" width="2" height="20"/>
|
||||
|
||||
<!-- Question mark - pixel style -->
|
||||
<rect class="question" x="12" y="10" width="8" height="2"/>
|
||||
<rect class="question" x="18" y="10" width="2" height="6"/>
|
||||
<rect class="question" x="14" y="14" width="4" height="2"/>
|
||||
<rect class="question" x="14" y="16" width="2" height="2"/>
|
||||
<rect class="question" x="14" y="20" width="2" height="2"/>
|
||||
<rect fill="#92400E" x="12" y="10" width="8" height="2"/>
|
||||
<rect fill="#92400E" x="18" y="10" width="2" height="6"/>
|
||||
<rect fill="#92400E" x="14" y="14" width="4" height="2"/>
|
||||
<rect fill="#92400E" x="14" y="16" width="2" height="2"/>
|
||||
<rect fill="#92400E" x="14" y="20" width="2" height="2"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.2 KiB |
75
airhub_app/fix_svg_styles.py
Normal file
@ -0,0 +1,75 @@
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
def fix_svg_file(filepath):
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Find style block
|
||||
style_match = re.search(r'<style>(.*?)</style>', content, re.DOTALL)
|
||||
if not style_match:
|
||||
print(f"Skipping {os.path.basename(filepath)}: No style block found")
|
||||
return False
|
||||
|
||||
style_content = style_match.group(1)
|
||||
|
||||
# Parse class -> fill mappings
|
||||
# Matches .classname { fill: #color; }
|
||||
# Also handles formatting variations
|
||||
mappings = {}
|
||||
|
||||
# Regex for class definition: .name { ... }
|
||||
# We look for fill: ... inside
|
||||
class_pattern = re.compile(r'\.([\w-]+)\s*\{([^}]+)\}')
|
||||
|
||||
for match in class_pattern.finditer(style_content):
|
||||
class_name = match.group(1)
|
||||
body = match.group(2)
|
||||
|
||||
# Extract fill color
|
||||
fill_match = re.search(r'fill:\s*(#[0-9a-fA-F]{3,6})', body)
|
||||
if fill_match:
|
||||
mappings[class_name] = fill_match.group(1)
|
||||
|
||||
if not mappings:
|
||||
print(f"Skipping {os.path.basename(filepath)}: No fill mappings found in style")
|
||||
return False
|
||||
|
||||
# Remove style block
|
||||
new_content = re.sub(r'<style>.*?</style>', '', content, flags=re.DOTALL)
|
||||
|
||||
# Replace class="name" with fill="color"
|
||||
# Note: We keep other attributes. If class is the only one, we replace it.
|
||||
# If other attributes exist, we should append fill and remove class?
|
||||
# Simplest approach: Replace `class="name"` with `fill="color"`
|
||||
|
||||
changed = False
|
||||
for cls, color in mappings.items():
|
||||
# Match class="name" or class='name'
|
||||
# Be careful not to replace partial matches (e.g. class="name-suffix")
|
||||
pattern = re.compile(r'class=["\']' + re.escape(cls) + r'["\']')
|
||||
if pattern.search(new_content):
|
||||
new_content = pattern.sub(f'fill="{color}"', new_content)
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
f.write(new_content)
|
||||
print(f"Fixed {os.path.basename(filepath)}")
|
||||
return True
|
||||
else:
|
||||
print(f"No class usages found for {os.path.basename(filepath)}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
target_dir = '/Users/maidong/Desktop/zyc/qiyuan_gitea/rtc_prd/airhub_app/assets/www/icons'
|
||||
count = 0
|
||||
for filename in os.listdir(target_dir):
|
||||
if filename.endswith('.svg'):
|
||||
if fix_svg_file(os.path.join(target_dir, filename)):
|
||||
count += 1
|
||||
print(f"Total files fixed: {count}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@ -1,5 +1,10 @@
|
||||
PODS:
|
||||
- Flutter (1.0.0)
|
||||
- flutter_blue_plus_darwin (0.0.2):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- image_picker_ios (0.0.1):
|
||||
- Flutter
|
||||
- permission_handler_apple (9.3.0):
|
||||
- Flutter
|
||||
- webview_flutter_wkwebview (0.0.1):
|
||||
@ -8,12 +13,18 @@ PODS:
|
||||
|
||||
DEPENDENCIES:
|
||||
- Flutter (from `Flutter`)
|
||||
- flutter_blue_plus_darwin (from `.symlinks/plugins/flutter_blue_plus_darwin/darwin`)
|
||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`)
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
Flutter:
|
||||
:path: Flutter
|
||||
flutter_blue_plus_darwin:
|
||||
:path: ".symlinks/plugins/flutter_blue_plus_darwin/darwin"
|
||||
image_picker_ios:
|
||||
:path: ".symlinks/plugins/image_picker_ios/ios"
|
||||
permission_handler_apple:
|
||||
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
||||
webview_flutter_wkwebview:
|
||||
@ -21,6 +32,8 @@ EXTERNAL SOURCES:
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3
|
||||
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
|
||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
||||
webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d
|
||||
|
||||
|
||||
@ -1,90 +1,44 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
// Import for Android features.
|
||||
import 'package:webview_flutter_android/webview_flutter_android.dart';
|
||||
// Import for iOS features.
|
||||
import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart';
|
||||
import 'pages/login_page.dart';
|
||||
import 'pages/webview_page.dart';
|
||||
import 'pages/home_page.dart';
|
||||
import 'pages/bluetooth_page.dart';
|
||||
import 'pages/wifi_config_page.dart';
|
||||
import 'pages/device_control_page.dart';
|
||||
import 'theme/app_theme.dart';
|
||||
|
||||
import 'pages/profile/profile_page.dart'; // Import ProfilePage
|
||||
|
||||
void main() {
|
||||
runApp(const MaterialApp(home: AirhubWebView()));
|
||||
runApp(const AirhubApp());
|
||||
}
|
||||
|
||||
class AirhubWebView extends StatefulWidget {
|
||||
const AirhubWebView({super.key});
|
||||
|
||||
@override
|
||||
State<AirhubWebView> createState() => _AirhubWebViewState();
|
||||
}
|
||||
|
||||
class _AirhubWebViewState extends State<AirhubWebView> {
|
||||
late final WebViewController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// #docregion platform_features
|
||||
late final PlatformWebViewControllerCreationParams params;
|
||||
if (WebViewPlatform.instance is WebKitWebViewPlatform) {
|
||||
params = WebKitWebViewControllerCreationParams(
|
||||
allowsInlineMediaPlayback: true,
|
||||
mediaTypesRequiringUserAction: const <PlaybackMediaTypes>{},
|
||||
);
|
||||
} else {
|
||||
params = const PlatformWebViewControllerCreationParams();
|
||||
}
|
||||
|
||||
final WebViewController controller =
|
||||
WebViewController.fromPlatformCreationParams(params);
|
||||
// #enddocregion platform_features
|
||||
|
||||
controller
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..setBackgroundColor(const Color(0x00000000))
|
||||
..setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onProgress: (int progress) {
|
||||
debugPrint('WebView is loading (progress : $progress%)');
|
||||
},
|
||||
onPageStarted: (String url) {
|
||||
debugPrint('Page started loading: $url');
|
||||
},
|
||||
onPageFinished: (String url) {
|
||||
debugPrint('Page finished loading: $url');
|
||||
},
|
||||
onWebResourceError: (WebResourceError error) {
|
||||
debugPrint('''
|
||||
Page resource error:
|
||||
code: ${error.errorCode}
|
||||
description: ${error.description}
|
||||
errorType: ${error.errorType}
|
||||
isForMainFrame: ${error.isForMainFrame}
|
||||
''');
|
||||
},
|
||||
),
|
||||
)
|
||||
..loadFlutterAsset('assets/www/login.html');
|
||||
|
||||
// #docregion platform_features
|
||||
if (controller.platform is AndroidWebViewController) {
|
||||
AndroidWebViewController.enableDebugging(true);
|
||||
(controller.platform as AndroidWebViewController)
|
||||
.setMediaPlaybackRequiresUserGesture(false);
|
||||
}
|
||||
// #enddocregion platform_features
|
||||
|
||||
_controller = controller;
|
||||
}
|
||||
class AirhubApp extends StatelessWidget {
|
||||
const AirhubApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
bottom: false,
|
||||
child: WebViewWidget(controller: _controller),
|
||||
),
|
||||
return MaterialApp(
|
||||
title: 'Airhub',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.lightTheme,
|
||||
// Initial Route
|
||||
home: const DeviceControlPage(),
|
||||
// Named Routes
|
||||
routes: {
|
||||
'/login': (context) => const LoginPage(),
|
||||
'/home': (context) => const HomePage(), // Native Home
|
||||
'/profile': (context) => const ProfilePage(), // Added Profile Route
|
||||
'/webview_fallback': (context) =>
|
||||
const WebViewPage(), // Keep for fallback
|
||||
'/bluetooth': (context) => const BluetoothPage(),
|
||||
'/wifi-config': (context) => const WifiConfigPage(),
|
||||
'/device-control': (context) => const DeviceControlPage(),
|
||||
},
|
||||
// Handle unknown routes
|
||||
onUnknownRoute: (settings) {
|
||||
return MaterialPageRoute(builder: (_) => const WebViewPage());
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
689
airhub_app/lib/pages/bluetooth_page.dart
Normal file
@ -0,0 +1,689 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
|
||||
/// 设备类型
|
||||
enum DeviceType { plush, badgeAi, badge }
|
||||
|
||||
/// 模拟设备数据模型
|
||||
class MockDevice {
|
||||
final String sn;
|
||||
final String name;
|
||||
final DeviceType type;
|
||||
final bool hasAI;
|
||||
|
||||
const MockDevice({
|
||||
required this.sn,
|
||||
required this.name,
|
||||
required this.type,
|
||||
required this.hasAI,
|
||||
});
|
||||
|
||||
String get iconPath {
|
||||
switch (type) {
|
||||
case DeviceType.plush:
|
||||
return 'assets/www/icons/pixel-capybara.svg';
|
||||
case DeviceType.badgeAi:
|
||||
return 'assets/www/icons/pixel-badge-ai.svg';
|
||||
case DeviceType.badge:
|
||||
return 'assets/www/icons/pixel-badge-basic.svg';
|
||||
}
|
||||
}
|
||||
|
||||
String get typeLabel {
|
||||
switch (type) {
|
||||
case DeviceType.plush:
|
||||
return '毛绒机芯';
|
||||
case DeviceType.badgeAi:
|
||||
return 'AI电子吧唧';
|
||||
case DeviceType.badge:
|
||||
return '普通电子吧唧';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 蓝牙搜索页面
|
||||
class BluetoothPage extends StatefulWidget {
|
||||
const BluetoothPage({super.key});
|
||||
|
||||
@override
|
||||
State<BluetoothPage> createState() => _BluetoothPageState();
|
||||
}
|
||||
|
||||
class _BluetoothPageState extends State<BluetoothPage>
|
||||
with TickerProviderStateMixin {
|
||||
// 状态
|
||||
bool _isSearching = true;
|
||||
List<MockDevice> _devices = [];
|
||||
int _currentIndex = 0;
|
||||
bool _isAnimating = false;
|
||||
|
||||
// 动画控制器
|
||||
late AnimationController _searchAnimController;
|
||||
late AnimationController _cardAnimController;
|
||||
late Animation<double> _cardAnimation;
|
||||
|
||||
// 模拟设备数据
|
||||
static const List<MockDevice> _mockDevices = [
|
||||
MockDevice(
|
||||
sn: 'PLUSH_01',
|
||||
name: '卡皮巴拉-001',
|
||||
type: DeviceType.plush,
|
||||
hasAI: true,
|
||||
),
|
||||
MockDevice(
|
||||
sn: 'BADGE_01',
|
||||
name: 'AI电子吧唧-001',
|
||||
type: DeviceType.badgeAi,
|
||||
hasAI: true,
|
||||
),
|
||||
MockDevice(
|
||||
sn: 'BADGE_02',
|
||||
name: '电子吧唧-001',
|
||||
type: DeviceType.badge,
|
||||
hasAI: false,
|
||||
),
|
||||
MockDevice(
|
||||
sn: 'PLUSH_02',
|
||||
name: '卡皮巴拉-002',
|
||||
type: DeviceType.plush,
|
||||
hasAI: true,
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// 搜索动画 (神秘盒子浮动)
|
||||
_searchAnimController = AnimationController(
|
||||
duration: const Duration(seconds: 2),
|
||||
vsync: this,
|
||||
)..repeat(reverse: true);
|
||||
|
||||
// 卡片切换动画
|
||||
_cardAnimController = AnimationController(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
vsync: this,
|
||||
);
|
||||
_cardAnimation = Tween<double>(begin: 0, end: 1).animate(
|
||||
CurvedAnimation(parent: _cardAnimController, curve: Curves.easeOutCubic),
|
||||
);
|
||||
|
||||
// 模拟搜索延迟
|
||||
_startSearch();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchAnimController.dispose();
|
||||
_cardAnimController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 开始搜索 (模拟)
|
||||
Future<void> _startSearch() async {
|
||||
// 请求蓝牙权限
|
||||
await _requestPermissions();
|
||||
|
||||
// 模拟 2 秒搜索延迟
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
|
||||
if (mounted) {
|
||||
// 随机选择 1-4 个设备
|
||||
final count = Random().nextInt(4) + 1;
|
||||
setState(() {
|
||||
_devices = _mockDevices.take(count).toList();
|
||||
_isSearching = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 请求蓝牙权限
|
||||
Future<void> _requestPermissions() async {
|
||||
// 检查蓝牙状态
|
||||
await Permission.bluetooth.request();
|
||||
await Permission.bluetoothScan.request();
|
||||
await Permission.bluetoothConnect.request();
|
||||
await Permission.location.request();
|
||||
}
|
||||
|
||||
/// 切换到下一个设备
|
||||
void _swipeUp() {
|
||||
if (_isAnimating || _devices.length <= 1) return;
|
||||
_animateToIndex((_currentIndex + 1) % _devices.length, isUp: true);
|
||||
}
|
||||
|
||||
/// 切换到上一个设备
|
||||
void _swipeDown() {
|
||||
if (_isAnimating || _devices.length <= 1) return;
|
||||
_animateToIndex(
|
||||
(_currentIndex - 1 + _devices.length) % _devices.length,
|
||||
isUp: false,
|
||||
);
|
||||
}
|
||||
|
||||
/// 动画切换到指定索引
|
||||
void _animateToIndex(int newIndex, {required bool isUp}) {
|
||||
_isAnimating = true;
|
||||
_cardAnimController.forward(from: 0).then((_) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_currentIndex = newIndex;
|
||||
_isAnimating = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 连接设备
|
||||
void _handleConnect() {
|
||||
if (_devices.isEmpty) return;
|
||||
|
||||
final device = _devices[_currentIndex];
|
||||
// TODO: 保存设备信息到本地存储
|
||||
|
||||
if (device.type == DeviceType.badge) {
|
||||
// 普通吧唧 -> 设备控制页
|
||||
Navigator.of(context).pushReplacementNamed('/device-control');
|
||||
} else {
|
||||
// 其他 -> WiFi 配网页
|
||||
Navigator.of(context).pushReplacementNamed('/wifi-config');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: Stack(
|
||||
children: [
|
||||
// 渐变背景
|
||||
_buildGradientBackground(),
|
||||
// 内容
|
||||
SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// Header
|
||||
_buildHeader(),
|
||||
// 设备数量提示
|
||||
_buildCountLabel(),
|
||||
// 主内容区域
|
||||
Expanded(
|
||||
child: _isSearching
|
||||
? _buildSearchingState()
|
||||
: _buildDeviceCards(),
|
||||
),
|
||||
// Footer
|
||||
_buildFooter(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 渐变背景
|
||||
Widget _buildGradientBackground() {
|
||||
final size = MediaQuery.of(context).size;
|
||||
return Positioned.fill(
|
||||
child: Stack(
|
||||
children: [
|
||||
// Layer 1 - Pink
|
||||
Positioned(
|
||||
bottom: -size.width * 0.5,
|
||||
left: -size.width * 0.5,
|
||||
width: size.width * 2,
|
||||
height: size.width * 2,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
const Color(0xFFFFC8DC).withOpacity(0.6),
|
||||
Colors.transparent,
|
||||
],
|
||||
radius: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Layer 2 - Cyan
|
||||
Positioned(
|
||||
top: -size.width * 0.5,
|
||||
right: -size.width * 0.5,
|
||||
width: size.width * 2,
|
||||
height: size.width * 2,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
const Color(0xFFB4F0F0).withOpacity(0.5),
|
||||
Colors.transparent,
|
||||
],
|
||||
radius: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Layer 3 - Lavender
|
||||
Positioned(
|
||||
top: size.height * 0.2,
|
||||
left: size.width * 0.1,
|
||||
width: size.width * 1.2,
|
||||
height: size.width * 1.2,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
const Color(0xFFE6D2FA).withOpacity(0.45),
|
||||
Colors.transparent,
|
||||
],
|
||||
radius: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Header - HTML: padding 16px 20px (vertical horizontal)
|
||||
Widget _buildHeader() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
// 返回按钮 - CSS: border-radius: 12px, bg: rgba(255,255,255,0.6), no border
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12), // Rounded square, not circle
|
||||
color: Colors.white.withOpacity(0.6),
|
||||
// No border per HTML
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.arrow_back_ios_new,
|
||||
size: 18,
|
||||
color: Color(0xFF4B5563), // Gray per HTML, not purple
|
||||
),
|
||||
),
|
||||
),
|
||||
// 标题
|
||||
Expanded(
|
||||
child: Text(
|
||||
'搜索设备',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontFamily: 'Inter',
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF1F2937),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 占位
|
||||
const SizedBox(width: 40),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 设备数量标签
|
||||
Widget _buildCountLabel() {
|
||||
return AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
opacity: _isSearching ? 0 : 1,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20),
|
||||
child: _devices.isEmpty
|
||||
? const SizedBox.shrink()
|
||||
: Text.rich(
|
||||
TextSpan(
|
||||
text: '找到 ',
|
||||
style: TextStyle(fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: const Color(0xFF9CA3AF),
|
||||
),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '${_devices.length}',
|
||||
style: TextStyle(fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF8B5CF6),
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: _devices.length > 1 ? ' 个设备 · 滑动切换' : ' 个设备',
|
||||
),
|
||||
],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 搜索中状态
|
||||
Widget _buildSearchingState() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// 神秘盒子动画
|
||||
AnimatedBuilder(
|
||||
animation: _searchAnimController,
|
||||
builder: (context, child) {
|
||||
return Transform.translate(
|
||||
offset: Offset(0, -15 * _searchAnimController.value),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
// HTML: mystery-box is transparent, icon is 120x120 with amber drop-shadow
|
||||
child: SvgPicture.asset(
|
||||
'assets/www/icons/pixel-mystery-box.svg',
|
||||
width: 120,
|
||||
height: 120,
|
||||
placeholderBuilder: (_) => Text(
|
||||
'?',
|
||||
style: TextStyle(fontFamily: 'Inter',
|
||||
fontSize: 48,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: const Color(0xFFF59E0B), // Amber color per HTML
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
// 搜索状态文字
|
||||
Text(
|
||||
'正在搜索附近设备',
|
||||
style: TextStyle(fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
color: const Color(0xFF4B5563),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 设备卡片区域
|
||||
Widget _buildDeviceCards() {
|
||||
if (_devices.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'未找到设备',
|
||||
style: TextStyle(fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
color: const Color(0xFF9CA3AF),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// 卡片容器 (支持滑动)
|
||||
GestureDetector(
|
||||
onVerticalDragEnd: (details) {
|
||||
if (details.primaryVelocity == null) return;
|
||||
if (details.primaryVelocity! < -50) {
|
||||
_swipeUp();
|
||||
} else if (details.primaryVelocity! > 50) {
|
||||
_swipeDown();
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
color: Colors.transparent,
|
||||
child: Center(child: _buildDeviceCard(_devices[_currentIndex])),
|
||||
),
|
||||
),
|
||||
// 右侧指示器
|
||||
if (_devices.length > 1)
|
||||
Positioned(
|
||||
right: 20,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: List.generate(
|
||||
_devices.length,
|
||||
(index) => _buildDot(index == _currentIndex),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 单个设备卡片
|
||||
Widget _buildDeviceCard(MockDevice device) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 图标 + AI 徽章
|
||||
Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
// 设备图标 - HTML: no background wrapper, icon is 120x120
|
||||
SizedBox(
|
||||
width: 120,
|
||||
height: 120,
|
||||
child: _buildDeviceIcon(device),
|
||||
),
|
||||
// AI 徽章
|
||||
if (device.hasAI)
|
||||
Positioned(
|
||||
top: -4,
|
||||
right: -4,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFF8B5CF6), Color(0xFF6366F1)],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF8B5CF6).withOpacity(0.4),
|
||||
offset: const Offset(0, 4),
|
||||
blurRadius: 10,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Text(
|
||||
'AI',
|
||||
style: TextStyle(fontFamily: 'Inter',
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
// 设备名称
|
||||
Text(
|
||||
device.name,
|
||||
style: TextStyle(fontFamily: 'Inter',
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF1F2937),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// 设备类型
|
||||
Text(
|
||||
device.typeLabel,
|
||||
style: TextStyle(fontFamily: 'Inter',
|
||||
fontSize: 15,
|
||||
color: const Color(0xFF6B7280),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 设备图标 - HTML: 120x120 per CSS .card-icon-img
|
||||
Widget _buildDeviceIcon(MockDevice device) {
|
||||
return SvgPicture.asset(
|
||||
device.iconPath,
|
||||
width: 120,
|
||||
height: 120,
|
||||
fit: BoxFit.contain,
|
||||
placeholderBuilder: (_) {
|
||||
IconData icon;
|
||||
switch (device.type) {
|
||||
case DeviceType.plush:
|
||||
icon = Icons.pets;
|
||||
case DeviceType.badgeAi:
|
||||
icon = Icons.smart_toy;
|
||||
case DeviceType.badge:
|
||||
icon = Icons.badge;
|
||||
}
|
||||
return Icon(icon, size: 80, color: const Color(0xFF8B5CF6));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 指示器圆点
|
||||
Widget _buildDot(bool isActive) {
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOutCubic,
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
width: 6,
|
||||
height: isActive ? 18 : 6,
|
||||
decoration: BoxDecoration(
|
||||
color: isActive
|
||||
? const Color(0xFF8B5CF6)
|
||||
: const Color(0xFF8B5CF6).withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(isActive ? 3 : 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Footer - HTML: padding 20px 20px 60px, gap 16px, centered buttons
|
||||
Widget _buildFooter() {
|
||||
return Container(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
20, // HTML: 20px sides
|
||||
20, // HTML: 20px top
|
||||
20,
|
||||
MediaQuery.of(context).padding.bottom + 60, // HTML: safe-area + 60px
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// 取消按钮 - HTML: frosted glass with border
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.8),
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
border: Border.all(color: const Color(0xFFE5E7EB)),
|
||||
),
|
||||
child: Text(
|
||||
_isSearching ? '取消搜索' : '取消',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF6B7280),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 连接按钮 (搜索完成后显示)
|
||||
if (!_isSearching && _devices.isNotEmpty) ...[
|
||||
const SizedBox(width: 16), // HTML: gap 16px
|
||||
GestureDetector(
|
||||
onTap: _handleConnect,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.btnPrimaryGradient,
|
||||
borderRadius: BorderRadius.circular(29), // HTML: 29px
|
||||
// HTML: 5-layer glow effect
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF22D3EE).withOpacity(0.35),
|
||||
offset: Offset.zero,
|
||||
blurRadius: 15,
|
||||
),
|
||||
BoxShadow(
|
||||
color: const Color(0xFF6366F1).withOpacity(0.25),
|
||||
offset: Offset.zero,
|
||||
blurRadius: 30,
|
||||
),
|
||||
BoxShadow(
|
||||
color: const Color(0xFF6366F1).withOpacity(0.4),
|
||||
offset: const Offset(0, 6),
|
||||
blurRadius: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
// Shine overlay
|
||||
Positioned.fill(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(29),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.white.withOpacity(0.15),
|
||||
Colors.transparent,
|
||||
],
|
||||
stops: const [0.0, 0.5],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'连接设备',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 17, // HTML: 17px
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1081
airhub_app/lib/pages/device_control_page.dart
Normal file
278
airhub_app/lib/pages/home_page.dart
Normal file
@ -0,0 +1,278 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
import '../theme/app_colors.dart';
|
||||
|
||||
class HomePage extends StatefulWidget {
|
||||
const HomePage({super.key});
|
||||
|
||||
@override
|
||||
State<HomePage> createState() => _HomePageState();
|
||||
}
|
||||
|
||||
class _HomePageState extends State<HomePage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _mascotController;
|
||||
late Animation<double> _mascotAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Mascot floating animation
|
||||
_mascotController = AnimationController(
|
||||
duration: const Duration(seconds: 4),
|
||||
vsync: this,
|
||||
)..repeat(reverse: true);
|
||||
|
||||
_mascotAnimation = Tween<double>(begin: -10, end: 10).animate(
|
||||
CurvedAnimation(parent: _mascotController, curve: Curves.easeInOut),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_mascotController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleConnect() {
|
||||
Navigator.of(context).pushNamed('/bluetooth');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: Stack(
|
||||
children: [
|
||||
// Gradient Background
|
||||
_buildGradientBackground(),
|
||||
|
||||
SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// Header (Logo)
|
||||
_buildHeader(),
|
||||
|
||||
// Main Content (Mascot)
|
||||
Expanded(child: _buildBody()),
|
||||
|
||||
// Footer (Button)
|
||||
_buildFooter(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGradientBackground() {
|
||||
final size = MediaQuery.of(context).size;
|
||||
return Positioned.fill(
|
||||
child: Stack(
|
||||
children: [
|
||||
// Layer 1
|
||||
Positioned(
|
||||
top: -size.width * 0.2,
|
||||
left: -size.width * 0.2,
|
||||
width: size.width * 1.5,
|
||||
height: size.width * 1.5,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
const Color(0xFFC4B5FD).withOpacity(0.4), // Violet tinge
|
||||
Colors.transparent,
|
||||
],
|
||||
radius: 0.6,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Layer 2
|
||||
Positioned(
|
||||
bottom: size.height * 0.1,
|
||||
right: -size.width * 0.3,
|
||||
width: size.width * 1.2,
|
||||
height: size.width * 1.2,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
const Color(0xFF67E8F9).withOpacity(0.3), // Cyan tinge
|
||||
Colors.transparent,
|
||||
],
|
||||
radius: 0.6,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Layer 3
|
||||
Positioned(
|
||||
bottom: -size.width * 0.5,
|
||||
left: size.width * 0.1,
|
||||
width: size.width * 1.5,
|
||||
height: size.width * 1.5,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
const Color(0xFFF9A8D4).withOpacity(0.3), // Pink tinge
|
||||
Colors.transparent,
|
||||
],
|
||||
radius: 0.6,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
return Container(
|
||||
height: 80,
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'Airhub',
|
||||
// Use Press Start 2P pixel font per HTML CSS
|
||||
style: GoogleFonts.pressStart2p(
|
||||
fontSize: 28,
|
||||
color: const Color(0xFF4B5563), // gray-600 per HTML
|
||||
letterSpacing: 2,
|
||||
// Crisp pixel-stepped shadows (0 blur) per HTML
|
||||
shadows: const [
|
||||
Shadow(
|
||||
color: Color(0x40A78BFA), // rgba(139, 92, 246, 0.25)
|
||||
offset: Offset(1, 1),
|
||||
blurRadius: 0,
|
||||
),
|
||||
Shadow(
|
||||
color: Color(0x26A78BFA), // rgba(139, 92, 246, 0.15)
|
||||
offset: Offset(2, 2),
|
||||
blurRadius: 0,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
return Center(
|
||||
child: AnimatedBuilder(
|
||||
animation: _mascotAnimation,
|
||||
builder: (context, child) {
|
||||
return Transform.translate(
|
||||
offset: Offset(0, _mascotAnimation.value),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
// Glow effect behind mascot
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF8B5CF6).withOpacity(0.3),
|
||||
blurRadius: 60,
|
||||
spreadRadius: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Image.asset(
|
||||
'assets/www/home_mascot.png',
|
||||
width: 280,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
const Icon(Icons.adb, size: 200, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFooter() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 56),
|
||||
child: Container(
|
||||
height: 58, // HTML: height: 58px
|
||||
constraints: const BoxConstraints(maxWidth: 300), // HTML: width: min(300px, 82vw)
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(29), // HTML: border-radius: 29px
|
||||
gradient: AppColors.btnPrimaryGradient,
|
||||
// 5-layer box-shadow per HTML CSS --btn-primary-glow
|
||||
boxShadow: [
|
||||
// 0 0 15px rgba(34, 211, 238, 0.35) - cyan outer glow
|
||||
BoxShadow(
|
||||
color: const Color(0xFF22D3EE).withOpacity(0.35),
|
||||
offset: Offset.zero,
|
||||
blurRadius: 15,
|
||||
),
|
||||
// 0 0 30px rgba(99, 102, 241, 0.25) - indigo wider glow
|
||||
BoxShadow(
|
||||
color: const Color(0xFF6366F1).withOpacity(0.25),
|
||||
offset: Offset.zero,
|
||||
blurRadius: 30,
|
||||
),
|
||||
// 0 6px 20px rgba(99, 102, 241, 0.4) - bottom shadow
|
||||
BoxShadow(
|
||||
color: const Color(0xFF6366F1).withOpacity(0.4),
|
||||
offset: const Offset(0, 6),
|
||||
blurRadius: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
// Shine overlay (top half gradient)
|
||||
Positioned.fill(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(29),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.white.withOpacity(0.15),
|
||||
Colors.transparent,
|
||||
],
|
||||
stops: const [0.0, 0.5],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Button content
|
||||
Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: _handleConnect,
|
||||
borderRadius: BorderRadius.circular(29),
|
||||
child: Center(
|
||||
// HTML button has NO icon, only text "立即连接"
|
||||
child: Text(
|
||||
'立即连接',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 17, // HTML: font-size: 17px
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
834
airhub_app/lib/pages/login_page.dart
Normal file
@ -0,0 +1,834 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
import '../theme/app_colors.dart';
|
||||
import '../widgets/gradient_button.dart';
|
||||
|
||||
class LoginPage extends StatefulWidget {
|
||||
const LoginPage({super.key});
|
||||
|
||||
@override
|
||||
State<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
|
||||
// State
|
||||
bool _agreed = false;
|
||||
bool _isLoading = false;
|
||||
bool _showSmsView = false;
|
||||
|
||||
// SMS Login State
|
||||
final TextEditingController _phoneController = TextEditingController();
|
||||
final TextEditingController _codeController = TextEditingController();
|
||||
int _countdown = 0;
|
||||
Timer? _countdownTimer;
|
||||
bool _isSmsSubmitting = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_phoneController.dispose();
|
||||
_codeController.dispose();
|
||||
_countdownTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// ========== Agreement Dialog ==========
|
||||
void _showAgreementDialog({required String action}) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierColor: Colors.black.withOpacity(0.5),
|
||||
builder: (context) => _buildAgreementModal(action),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAgreementModal(String action) {
|
||||
return Dialog(
|
||||
backgroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
child: Container(
|
||||
width: 320,
|
||||
padding: const EdgeInsets.fromLTRB(24, 28, 24, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Title
|
||||
Text(
|
||||
'服务协议',
|
||||
style: TextStyle(fontFamily: 'Inter',
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF1F2937),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Content
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
text: '请先阅读并同意',
|
||||
style: TextStyle(fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: const Color(0xFF6B7280),
|
||||
height: 1.6,
|
||||
),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '《用户协议》',
|
||||
style: TextStyle(fontFamily: 'Inter', color: const Color(0xFF6366F1)),
|
||||
),
|
||||
const TextSpan(text: '和'),
|
||||
TextSpan(
|
||||
text: '《隐私政策》',
|
||||
style: TextStyle(fontFamily: 'Inter', color: const Color(0xFF6366F1)),
|
||||
),
|
||||
const TextSpan(text: ',以便为您提供更好的服务。'),
|
||||
],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
// Buttons
|
||||
Row(
|
||||
children: [
|
||||
// Cancel
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => Navigator.pop(context),
|
||||
child: Container(
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF3F4F6),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'再想想',
|
||||
style: TextStyle(fontFamily: 'Inter',
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: const Color(0xFF6B7280),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Confirm
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
setState(() => _agreed = true);
|
||||
Navigator.pop(context);
|
||||
if (action == 'oneclick') {
|
||||
_doOneClickLogin();
|
||||
} else if (action == 'sms') {
|
||||
setState(() => _showSmsView = true);
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFF6366F1), Color(0xFF8B5CF6)],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'同意并继续',
|
||||
style: TextStyle(fontFamily: 'Inter',
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ========== One-Click Login ==========
|
||||
void _handleOneClickLogin() {
|
||||
if (!_agreed) {
|
||||
_showAgreementDialog(action: 'oneclick');
|
||||
return;
|
||||
}
|
||||
_doOneClickLogin();
|
||||
}
|
||||
|
||||
void _doOneClickLogin() {
|
||||
setState(() => _isLoading = true);
|
||||
_showToast('正在获取本机号码...');
|
||||
|
||||
Future.delayed(const Duration(milliseconds: 1500), () {
|
||||
if (mounted) {
|
||||
_showToast('登录成功');
|
||||
Future.delayed(const Duration(seconds: 1), () {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
Navigator.of(context).pushReplacementNamed('/home');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ========== SMS Login ==========
|
||||
void _handleSmsLinkTap() {
|
||||
if (!_agreed) {
|
||||
_showAgreementDialog(action: 'sms');
|
||||
return;
|
||||
}
|
||||
setState(() => _showSmsView = true);
|
||||
}
|
||||
|
||||
bool _isValidPhone(String phone) {
|
||||
return RegExp(r'^1[3-9]\d{9}$').hasMatch(phone);
|
||||
}
|
||||
|
||||
bool get _canSubmitSms {
|
||||
return _isValidPhone(_phoneController.text) &&
|
||||
_codeController.text.length == 6;
|
||||
}
|
||||
|
||||
void _sendCode() {
|
||||
if (!_isValidPhone(_phoneController.text)) {
|
||||
_showToast('请输入正确的手机号');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _countdown = 60);
|
||||
_showToast('验证码已发送');
|
||||
|
||||
_countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
if (_countdown <= 1) {
|
||||
timer.cancel();
|
||||
if (mounted) setState(() => _countdown = 0);
|
||||
} else {
|
||||
if (mounted) setState(() => _countdown--);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _submitSmsLogin() {
|
||||
if (!_canSubmitSms) return;
|
||||
|
||||
setState(() => _isSmsSubmitting = true);
|
||||
|
||||
Future.delayed(const Duration(milliseconds: 1500), () {
|
||||
if (mounted) {
|
||||
_showToast('登录成功');
|
||||
Future.delayed(const Duration(seconds: 1), () {
|
||||
if (mounted) {
|
||||
setState(() => _isSmsSubmitting = false);
|
||||
Navigator.of(context).pushReplacementNamed('/home');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ========== Toast ==========
|
||||
void _showToast(String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
duration: const Duration(seconds: 2),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Build ==========
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
resizeToAvoidBottomInset: true,
|
||||
body: Stack(
|
||||
children: [
|
||||
// Background
|
||||
_buildGradientBackground(),
|
||||
// Main Login View
|
||||
_buildMainLoginView(),
|
||||
// SMS View (overlay)
|
||||
if (_showSmsView) _buildSmsView(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Gradient Background ==========
|
||||
Widget _buildGradientBackground() {
|
||||
final size = MediaQuery.of(context).size;
|
||||
return Positioned.fill(
|
||||
child: Stack(
|
||||
children: [
|
||||
// Layer 1 - Pink (bottom-left)
|
||||
Positioned(
|
||||
bottom: -size.width * 0.5,
|
||||
left: -size.width * 0.5,
|
||||
width: size.width * 2,
|
||||
height: size.width * 2,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
const Color(0xFFFFC8DC).withOpacity(0.6),
|
||||
Colors.transparent,
|
||||
],
|
||||
radius: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Layer 2 - Cyan (top-right)
|
||||
Positioned(
|
||||
top: -size.width * 0.5,
|
||||
right: -size.width * 0.5,
|
||||
width: size.width * 2,
|
||||
height: size.width * 2,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
const Color(0xFFB4F0F0).withOpacity(0.5),
|
||||
Colors.transparent,
|
||||
],
|
||||
radius: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Layer 3 - Lavender (center)
|
||||
Positioned(
|
||||
top: size.height * 0.2,
|
||||
left: size.width * 0.1,
|
||||
width: size.width * 1.2,
|
||||
height: size.width * 1.2,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
const Color(0xFFE6D2FA).withOpacity(0.45),
|
||||
Colors.transparent,
|
||||
],
|
||||
radius: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Main Login View ==========
|
||||
Widget _buildMainLoginView() {
|
||||
final bottomPadding = MediaQuery.of(context).padding.bottom;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// Logo - padding-top: calc(env(safe-area-inset-top) + 60px)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 60),
|
||||
child: Text(
|
||||
'Airhub',
|
||||
style: GoogleFonts.pressStart2p(
|
||||
fontSize: 26,
|
||||
color: const Color(0xFF4B2E83),
|
||||
letterSpacing: 2,
|
||||
shadows: [
|
||||
Shadow(
|
||||
offset: const Offset(0, 2),
|
||||
blurRadius: 10,
|
||||
color: const Color(0xFF8B5CF6).withOpacity(0.3),
|
||||
),
|
||||
Shadow(
|
||||
offset: const Offset(0, 0),
|
||||
blurRadius: 40,
|
||||
color: const Color(0xFF8B5CF6).withOpacity(0.15),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Mascot - flex: 1, centered
|
||||
Expanded(child: Center(child: _FloatingMascot())),
|
||||
|
||||
// Bottom Form
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(32, 0, 32, bottomPadding + 40),
|
||||
child: Column(
|
||||
children: [
|
||||
// Primary Button - height: 56px, border-radius: 28px
|
||||
GradientButton(
|
||||
text: '本机号码一键登录',
|
||||
onPressed: _handleOneClickLogin,
|
||||
isLoading: _isLoading,
|
||||
height: 56,
|
||||
),
|
||||
|
||||
// SMS Link - margin-top: 20px, font-size: 14px
|
||||
const SizedBox(height: 20),
|
||||
GestureDetector(
|
||||
onTap: _handleSmsLinkTap,
|
||||
child: Text(
|
||||
'使用验证码登录',
|
||||
style: TextStyle(fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: const Color(0xFF4B2E83).withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Agreement - margin-top: 28px
|
||||
const SizedBox(height: 28),
|
||||
_buildAgreementCheckbox(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Agreement Checkbox ==========
|
||||
Widget _buildAgreementCheckbox() {
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _agreed = !_agreed),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Checkbox - 18x18, border-radius: 5px
|
||||
Container(
|
||||
width: 18,
|
||||
height: 18,
|
||||
margin: const EdgeInsets.only(top: 1), // Fine-tune alignment
|
||||
decoration: BoxDecoration(
|
||||
gradient: _agreed
|
||||
? const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFF6366F1), Color(0xFF8B5CF6)],
|
||||
)
|
||||
: null,
|
||||
color: _agreed ? null : const Color(0x99FFFFFF),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
border: Border.all(
|
||||
color: _agreed
|
||||
? Colors.transparent
|
||||
: const Color(0xFF4B2E83).withOpacity(0.3),
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
child: _agreed
|
||||
? const Center(
|
||||
child: Text(
|
||||
'✓',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 10), // gap: 10px
|
||||
// Text - font-size: 12px, line-height: 1.6
|
||||
Flexible(
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
text: '我已阅读并同意',
|
||||
style: TextStyle(fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
color: const Color(0xFF4B2E83).withOpacity(0.6),
|
||||
height: 1.6,
|
||||
),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '《用户协议》',
|
||||
style: TextStyle(fontFamily: 'Inter', color: const Color(0xFF6366F1)),
|
||||
),
|
||||
const TextSpan(text: '和'),
|
||||
TextSpan(
|
||||
text: '《隐私政策》',
|
||||
style: TextStyle(fontFamily: 'Inter', color: const Color(0xFF6366F1)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ========== SMS View ==========
|
||||
Widget _buildSmsView() {
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
final bottomPadding = MediaQuery.of(context).padding.bottom;
|
||||
|
||||
return Positioned.fill(
|
||||
child: Container(
|
||||
color: Colors.white,
|
||||
child: Stack(
|
||||
children: [
|
||||
// Background
|
||||
_buildGradientBackground(),
|
||||
// Content
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header - padding-top: 60px (fixed)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 60, 24, 16),
|
||||
child: _buildBackButton(),
|
||||
),
|
||||
|
||||
// Body
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
32,
|
||||
60,
|
||||
32,
|
||||
bottomPadding + 40,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Heading - font-size: 32px, font-weight: 700
|
||||
Text(
|
||||
'欢迎使用 Airhub',
|
||||
style: TextStyle(fontFamily: 'Inter',
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: const Color(0xFF4B2E83),
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Subheading - font-size: 15px
|
||||
Text(
|
||||
'请输入您的手机号验证登录',
|
||||
style: TextStyle(fontFamily: 'Inter',
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: const Color(0xFF4B2E83).withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
|
||||
// Phone Input
|
||||
_buildPhoneInput(),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Code Input
|
||||
_buildCodeInput(),
|
||||
const SizedBox(height: 48),
|
||||
|
||||
// Submit Button - height: 60px, border-radius: 30px
|
||||
_buildSmsSubmitButton(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBackButton() {
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _showSmsView = false),
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: const Color(0x66FFFFFF),
|
||||
border: Border.all(color: const Color(0x99FFFFFF)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.03),
|
||||
offset: const Offset(0, 4),
|
||||
blurRadius: 12,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(Icons.arrow_back, size: 22, color: Color(0xFF4B2E83)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPhoneInput() {
|
||||
return Container(
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x8CFFFFFF),
|
||||
border: Border.all(color: const Color(0xCCFFFFFF)),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF8B5CF6).withOpacity(0.03),
|
||||
offset: const Offset(0, 2),
|
||||
blurRadius: 10,
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Row(
|
||||
children: [
|
||||
// Prefix
|
||||
Container(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
margin: const EdgeInsets.only(right: 16),
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(
|
||||
right: BorderSide(color: Color(0x1A4B2E83), width: 1),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'+86',
|
||||
style: TextStyle(fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF4B2E83),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Input
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _phoneController,
|
||||
keyboardType: TextInputType.phone,
|
||||
maxLength: 11,
|
||||
style: TextStyle(fontFamily: 'Inter',
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: const Color(0xFF1F2937),
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
border: InputBorder.none,
|
||||
hintText: '请输入手机号',
|
||||
hintStyle: TextStyle(fontFamily: 'Inter',
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: const Color(0xFF4B2E83).withOpacity(0.35),
|
||||
),
|
||||
counterText: '',
|
||||
),
|
||||
cursorColor: const Color(0xFF8B5CF6),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCodeInput() {
|
||||
return Container(
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x8CFFFFFF),
|
||||
border: Border.all(color: const Color(0xCCFFFFFF)),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF8B5CF6).withOpacity(0.03),
|
||||
offset: const Offset(0, 2),
|
||||
blurRadius: 10,
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Row(
|
||||
children: [
|
||||
// Input
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _codeController,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 6,
|
||||
style: TextStyle(fontFamily: 'Inter',
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: const Color(0xFF1F2937),
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
border: InputBorder.none,
|
||||
hintText: '输入验证码',
|
||||
hintStyle: TextStyle(fontFamily: 'Inter',
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: const Color(0xFF4B2E83).withOpacity(0.35),
|
||||
),
|
||||
counterText: '',
|
||||
),
|
||||
cursorColor: const Color(0xFF8B5CF6),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
),
|
||||
// Send Button
|
||||
Container(
|
||||
padding: const EdgeInsets.only(left: 14),
|
||||
margin: const EdgeInsets.only(left: 10),
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(
|
||||
left: BorderSide(color: Color(0x1A4B2E83), width: 1),
|
||||
),
|
||||
),
|
||||
child: GestureDetector(
|
||||
onTap: _countdown > 0 ? null : _sendCode,
|
||||
child: Text(
|
||||
_countdown > 0 ? '${_countdown}s' : '获取验证码',
|
||||
style: TextStyle(fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _countdown > 0
|
||||
? const Color(0xFF9CA3AF)
|
||||
: const Color(0xFF6366F1),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSmsSubmitButton() {
|
||||
final bool enabled = _canSubmitSms && !_isSmsSubmitting;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: enabled ? _submitSmsLogin : null,
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
opacity: enabled ? 1.0 : 0.6,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.btnPrimaryGradient,
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
boxShadow: enabled
|
||||
? [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF6366F1).withOpacity(0.3),
|
||||
offset: const Offset(0, 10),
|
||||
blurRadius: 30,
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: _isSmsSubmitting
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
strokeWidth: 2.5,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
'立即登录',
|
||||
style: TextStyle(fontFamily: 'Inter',
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Floating Mascot Widget ==========
|
||||
class _FloatingMascot extends StatefulWidget {
|
||||
@override
|
||||
State<_FloatingMascot> createState() => _FloatingMascotState();
|
||||
}
|
||||
|
||||
class _FloatingMascotState extends State<_FloatingMascot>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _animation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
duration: const Duration(seconds: 5),
|
||||
vsync: this,
|
||||
)..repeat(reverse: true);
|
||||
|
||||
_animation = Tween<double>(
|
||||
begin: 0,
|
||||
end: -15,
|
||||
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _animation,
|
||||
builder: (context, child) {
|
||||
return Transform.translate(
|
||||
offset: Offset(0, _animation.value),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
width: 220,
|
||||
height: 220,
|
||||
decoration: BoxDecoration(
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF8B5CF6).withOpacity(0.25),
|
||||
offset: const Offset(0, 20),
|
||||
blurRadius: 40,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Image.asset(
|
||||
'assets/www/mascot.png',
|
||||
width: 220,
|
||||
height: 220,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
416
airhub_app/lib/pages/product_selection_page.dart
Normal file
@ -0,0 +1,416 @@
|
||||
import 'package:flutter/material.dart';
|
||||
// Actually I should write TextStyle(fontFamily: 'Inter') directly to avoid sed step again.
|
||||
// import 'package:google_fonts/google_fonts.dart'; (Removed)
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
|
||||
class ProductSelectionPage extends StatelessWidget {
|
||||
const ProductSelectionPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white, // Will use gradient background
|
||||
body: Stack(
|
||||
children: [
|
||||
// Gradient Background (matching DeviceControlPage)
|
||||
const _GradientBackground(),
|
||||
|
||||
SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
_buildHeader(context),
|
||||
Expanded(child: _buildProductList(context)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.5),
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const Icon(
|
||||
Icons.arrow_back_ios_new,
|
||||
size: 20,
|
||||
color: Color(0xFF1F2937),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Text(
|
||||
'选择产品',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: const Color(0xFF1F2937),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProductList(BuildContext context) {
|
||||
final products = [
|
||||
{
|
||||
'id': 'capybara',
|
||||
'name': '毛绒机芯',
|
||||
'status': '已连接',
|
||||
'statusColor': const Color(0xFF10B981), // Green
|
||||
'icon': 'assets/www/Capybara.png', // PNG
|
||||
'isPng': true,
|
||||
'hasTag': true,
|
||||
'tag': 'AI',
|
||||
'gradient': const LinearGradient(
|
||||
colors: [
|
||||
Color(0xFFE6B98D),
|
||||
Color(0xFFE8C9A8),
|
||||
Color(0xFFD4A373),
|
||||
Color(0xFFB07D5A),
|
||||
],
|
||||
stops: [0.0, 0.35, 0.70, 1.0],
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
),
|
||||
'shadowColor': const Color(0xFFC9A07A),
|
||||
'selected': true,
|
||||
},
|
||||
{
|
||||
'id': 'badge-ai',
|
||||
'name': '电子吧唧 AI',
|
||||
'status': '离线',
|
||||
'statusColor': const Color(0xFFE5E7EB), // Gray
|
||||
'icon': 'assets/www/icons/icon-product-badge.svg',
|
||||
'isPng': false,
|
||||
'hasTag': true,
|
||||
'tag': 'AI',
|
||||
'gradient': const LinearGradient(
|
||||
colors: [
|
||||
Color(0xFF22D3EE),
|
||||
Color(0xFF60A5FA),
|
||||
Color(0xFF818CF8),
|
||||
Color(0xFFA78BFA),
|
||||
],
|
||||
stops: [0.0, 0.35, 0.70, 1.0],
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
),
|
||||
'shadowColor': const Color(0xFF6366F1),
|
||||
'selected': false,
|
||||
},
|
||||
{
|
||||
'id': 'badge-basic',
|
||||
'name': '普通吧唧',
|
||||
'status': '未配对',
|
||||
'statusColor': const Color(0xFFE5E7EB),
|
||||
'icon': 'assets/www/icons/icon-product-badge.svg',
|
||||
'isPng': false,
|
||||
'hasTag': false,
|
||||
'gradient': const LinearGradient(
|
||||
colors: [
|
||||
Color(0xFFC084FC),
|
||||
Color(0xFFD8B4FE),
|
||||
Color(0xFFC4B5FD),
|
||||
Color(0xFFA78BFA),
|
||||
],
|
||||
stops: [0.0, 0.35, 0.70, 1.0],
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
),
|
||||
'shadowColor': const Color(0xFFA78BFA),
|
||||
'selected': false,
|
||||
},
|
||||
{
|
||||
'id': 'bracelet',
|
||||
'name': 'AI 手链',
|
||||
'status': '点击扫描',
|
||||
'statusColor': const Color(0xFFE5E7EB),
|
||||
'icon':
|
||||
'assets/www/icons/icon-product-badge.svg', // Fallback, originally icon-product-bracelet.svg
|
||||
'isPng': false,
|
||||
'hasTag': true,
|
||||
'tag': 'AI',
|
||||
'gradient': const LinearGradient(
|
||||
colors: [
|
||||
Color(0xFFFDBA74),
|
||||
Color(0xFFFB923C),
|
||||
Color(0xFFFBAF85),
|
||||
Color(0xFFE07B54),
|
||||
],
|
||||
stops: [0.0, 0.35, 0.70, 1.0],
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
),
|
||||
'shadowColor': const Color(0xFFE07B54),
|
||||
'selected': false,
|
||||
},
|
||||
{
|
||||
'id': 'vsinger',
|
||||
'name': '洛天依',
|
||||
'status': '去下载专属 APP →',
|
||||
'statusColor': Colors.transparent, // Special
|
||||
'icon': 'assets/www/icons/icon-product-luo.svg',
|
||||
'isPng': false,
|
||||
'hasTag': false,
|
||||
'gradient': const LinearGradient(
|
||||
colors: [
|
||||
Color(0xFF34D399),
|
||||
Color(0xFF5EEAD4),
|
||||
Color(0xFF22D3EE),
|
||||
Color(0xFF2DD4BF),
|
||||
],
|
||||
stops: [0.0, 0.35, 0.70, 1.0],
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
),
|
||||
'shadowColor': const Color(0xFF2DD4BF),
|
||||
'selected': false,
|
||||
},
|
||||
];
|
||||
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.all(20),
|
||||
itemCount: products.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 16),
|
||||
itemBuilder: (context, index) {
|
||||
final product = products[index];
|
||||
return _ProductCard(product: product);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProductCard extends StatelessWidget {
|
||||
final Map<String, dynamic> product;
|
||||
|
||||
const _ProductCard({required this.product});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
bool isSelected = product['selected'] == true;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
if (isSelected) {
|
||||
Navigator.of(context).pop();
|
||||
} else {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('${product['name']} 离线或未配对')));
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
height: 140, // min-height 140px
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
gradient: product['gradient'] as Gradient,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: (product['shadowColor'] as Color).withOpacity(0.25),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 0),
|
||||
),
|
||||
BoxShadow(
|
||||
color: (product['shadowColor'] as Color).withOpacity(0.2),
|
||||
blurRadius: 24,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
// Top Shine (Simulated with Gradient Overlay? No, simple gradient is enough)
|
||||
Row(
|
||||
children: [
|
||||
// Icon Box
|
||||
_buildIconBox(),
|
||||
const SizedBox(width: 20),
|
||||
// Info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
product['name'],
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Colors.black12,
|
||||
offset: Offset(0, 1),
|
||||
blurRadius: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
if ((product['statusColor'] as Color) !=
|
||||
Colors.transparent)
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
margin: const EdgeInsets.only(right: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: product['id'] == 'capybara'
|
||||
? const Color(0xFF34D399)
|
||||
: Colors.white.withOpacity(
|
||||
0.5,
|
||||
), // Capybara is bright green
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: product['id'] == 'capybara'
|
||||
? [
|
||||
BoxShadow(
|
||||
color: const Color(
|
||||
0xFF34D399,
|
||||
).withOpacity(0.3),
|
||||
spreadRadius: 3,
|
||||
),
|
||||
]
|
||||
: [],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
product['status'],
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: Colors.white.withOpacity(0.85),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Arrow
|
||||
Icon(
|
||||
Icons.arrow_forward_ios_rounded,
|
||||
color: Colors.white.withOpacity(0.7),
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildIconBox() {
|
||||
return Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.25),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
_buildIconImage(),
|
||||
if (product['hasTag'] == true)
|
||||
Positioned(
|
||||
top: -8, // -6px in CSS top relative to what? centered stack.
|
||||
// Logic: Container is 72. center is 36.
|
||||
// Better: Stack fits parent.
|
||||
right: -8,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.95),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.15),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Text(
|
||||
product['tag'],
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF6366F1),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildIconImage() {
|
||||
if (product['isPng']) {
|
||||
return Image.asset(product['icon'], width: 56, fit: BoxFit.contain);
|
||||
} else {
|
||||
// SVG needs white filter except for capybara (which is png).
|
||||
// CSS says: filter: brightness(0) invert(1) for .p-icon img.
|
||||
return SvgPicture.asset(
|
||||
product['icon'],
|
||||
width: 48,
|
||||
colorFilter: const ColorFilter.mode(Colors.white, BlendMode.srcIn),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _GradientBackground extends StatelessWidget {
|
||||
const _GradientBackground();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: const BoxDecoration(color: Colors.white),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
top: -100,
|
||||
left: -100,
|
||||
child: Container(
|
||||
width: 300,
|
||||
height: 300,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
const Color(0xFFC4B5FD).withOpacity(0.3),
|
||||
Colors.transparent,
|
||||
],
|
||||
radius: 0.6,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
341
airhub_app/lib/pages/profile/agent_manage_page.dart
Normal file
@ -0,0 +1,341 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:airhub_app/theme/design_tokens.dart';
|
||||
import 'package:airhub_app/widgets/glass_dialog.dart';
|
||||
|
||||
class AgentManagePage extends StatefulWidget {
|
||||
const AgentManagePage({super.key});
|
||||
|
||||
@override
|
||||
State<AgentManagePage> createState() => _AgentManagePageState();
|
||||
}
|
||||
|
||||
class _AgentManagePageState extends State<AgentManagePage> {
|
||||
// Mock data matching HTML
|
||||
final List<Map<String, String>> _agents = [
|
||||
{
|
||||
'id': 'Airhub_Mem_01',
|
||||
'date': '2025/01/15',
|
||||
'icon': '🧠',
|
||||
'bind': 'Airhub_5G',
|
||||
'nickname': '小毛球',
|
||||
'status': 'bound', // bound, unbound
|
||||
},
|
||||
{
|
||||
'id': 'Airhub_Mem_02',
|
||||
'date': '2024/08/22',
|
||||
'icon': '🐾',
|
||||
'bind': '未绑定设备',
|
||||
'nickname': '豆豆',
|
||||
'status': 'unbound',
|
||||
},
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
body: Stack(
|
||||
children: [
|
||||
Container(decoration: const BoxDecoration(color: Color(0xFFFEFEFE))),
|
||||
Column(
|
||||
children: [
|
||||
_buildHeader(context),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: EdgeInsets.only(
|
||||
top: 20,
|
||||
left: 20,
|
||||
right: 20,
|
||||
bottom: 40 + MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
itemCount: _agents.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _buildAgentCard(_agents[index]);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.only(
|
||||
top: MediaQuery.of(context).padding.top + 20,
|
||||
left: AppSpacing.lg,
|
||||
right: AppSpacing.lg,
|
||||
bottom: AppSpacing.md,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.pop(context),
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.iconBtnBg,
|
||||
borderRadius: BorderRadius.circular(AppRadius.button),
|
||||
border: Border.all(color: AppColors.iconBtnBorder),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.arrow_back,
|
||||
color: AppColors.textPrimary,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Text('角色记忆', style: AppTextStyles.title),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
showGlassDialog(
|
||||
context: context,
|
||||
title: '什么是角色记忆?',
|
||||
description:
|
||||
'角色记忆是您与 AI 互动产生的人格数据,它是独立的数字资产,可以在不同设备间迁移,或分享给好友。',
|
||||
confirmText: '确定',
|
||||
onConfirm: () => Navigator.pop(context),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.iconBtnBg,
|
||||
borderRadius: BorderRadius.circular(AppRadius.button),
|
||||
border: Border.all(color: AppColors.iconBtnBorder),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'?',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
color: AppColors.textSecondary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAgentCard(Map<String, String> agent) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFD4A373), // Fallback
|
||||
gradient: const LinearGradient(colors: AppColors.gradientCapybara),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFFC9A07A).withOpacity(0.25),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
// Top highlight layer
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
height: 60,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.white.withOpacity(0.12), Colors.transparent],
|
||||
),
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(20),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
agent['date']!,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.85),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.25),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
agent['icon']!,
|
||||
style: const TextStyle(fontSize: 24),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
agent['id']!,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Color(0x1A000000),
|
||||
blurRadius: 2,
|
||||
offset: Offset(0, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildDetailRow('已绑定:', agent['bind']!),
|
||||
const SizedBox(height: 4),
|
||||
_buildDetailRow('角色昵称:', agent['nickname']!),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
Container(height: 1, color: Colors.white.withOpacity(0.2)),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (agent['status'] == 'bound')
|
||||
_buildActionBtn(
|
||||
'解绑',
|
||||
isDanger: true,
|
||||
onTap: () => _showUnbindDialog(agent['id']!),
|
||||
)
|
||||
else
|
||||
_buildActionBtn(
|
||||
'注入设备',
|
||||
isInject: true,
|
||||
onTap: () => _showInjectDialog(agent['id']!),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailRow(String label, String value) {
|
||||
return RichText(
|
||||
text: TextSpan(
|
||||
style: TextStyle(fontSize: 14, color: Colors.white.withOpacity(0.85)),
|
||||
children: [
|
||||
TextSpan(text: label),
|
||||
TextSpan(
|
||||
text: value,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionBtn(
|
||||
String text, {
|
||||
bool isDanger = false,
|
||||
bool isInject = false,
|
||||
VoidCallback? onTap,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: Colors.white.withOpacity(0.3)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isDanger) ...[
|
||||
Icon(
|
||||
Icons.link_off,
|
||||
size: 14,
|
||||
color: AppColors.danger.withOpacity(0.9),
|
||||
), // Use icon for visual
|
||||
const SizedBox(width: 4),
|
||||
] else if (isInject) ...[
|
||||
Icon(Icons.download, size: 14, color: const Color(0xFFB07D5A)),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isDanger
|
||||
? AppColors.danger
|
||||
: (isInject ? const Color(0xFFB07D5A) : Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showUnbindDialog(String id) {
|
||||
showGlassDialog(
|
||||
context: context,
|
||||
title: '确认解绑角色记忆?',
|
||||
description: '解绑后,该角色记忆将与当前设备断开连接,但数据会保留在云端。',
|
||||
cancelText: '取消',
|
||||
confirmText: '确认解绑',
|
||||
isDanger:
|
||||
true, // Note: GlassDialog implementation currently doesn't distinct danger style strongly but passed prop
|
||||
onConfirm: () {
|
||||
Navigator.pop(context); // Close dialog
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('已解绑: $id')));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showInjectDialog(String id) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('正在查找附近的可用设备以注入: $id')));
|
||||
}
|
||||
}
|
||||
198
airhub_app/lib/pages/profile/guide_feeding_page.dart
Normal file
@ -0,0 +1,198 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:airhub_app/theme/design_tokens.dart';
|
||||
|
||||
class GuideFeedingPage extends StatelessWidget {
|
||||
const GuideFeedingPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
body: Stack(
|
||||
children: [
|
||||
Container(decoration: const BoxDecoration(color: Color(0xFFFEFEFE))),
|
||||
Column(
|
||||
children: [
|
||||
_buildHeader(context),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.only(
|
||||
top: 20,
|
||||
left: 20,
|
||||
right: 20,
|
||||
bottom: 40 + MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
child: _buildManualCard(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.only(
|
||||
top: MediaQuery.of(context).padding.top + 20,
|
||||
left: AppSpacing.lg,
|
||||
right: AppSpacing.lg,
|
||||
bottom: AppSpacing.md,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.pop(context),
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.iconBtnBg,
|
||||
borderRadius: BorderRadius.circular(AppRadius.button),
|
||||
border: Border.all(color: AppColors.iconBtnBorder),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.arrow_back,
|
||||
color: AppColors.textPrimary,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Text('喂养指南', style: AppTextStyles.title),
|
||||
const SizedBox(width: 44),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildManualCard() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(color: const Color(0xFFF3F4F6), width: 2),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 24),
|
||||
child: Image.asset(
|
||||
'assets/www/pixel_capybara_eating_guide_1770187625762.png',
|
||||
width: 128,
|
||||
height: 128,
|
||||
fit: BoxFit.contain,
|
||||
filterQuality: FilterQuality.none, // Pixelated
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
_buildSection('如何喂食你的电子宠物?', [
|
||||
const TextSpan(text: '当你的毛绒机芯显示“饿了”的图标时,它需要补充能量!\n\n'),
|
||||
const TextSpan(text: '1. 打开 APP 首页,点击右下角的 '),
|
||||
TextSpan(
|
||||
text: '[能量]',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
const TextSpan(text: ' 按钮。\n'),
|
||||
const TextSpan(text: '2. 从列表中选择它喜欢的食物(胡萝卜、西瓜或干草饼干)。\n'),
|
||||
const TextSpan(text: '3. 点击“投喂”,观察它的反应!'),
|
||||
], highlight: '💡 小贴士: 不同的食物会增加不同的心情值哦!西瓜会让它超级开心。'),
|
||||
|
||||
_buildSection('心情与成长', [
|
||||
const TextSpan(text: '保持饱腹感可以提升心情值。心情值越高,它的互动反应就越丰富。\n'),
|
||||
const TextSpan(text: '如果你连续 3 天忘记喂食,它可能会变得懒洋洋的,不愿理人哦... 💤'),
|
||||
]),
|
||||
|
||||
_buildSection('特殊互动', [
|
||||
const TextSpan(text: '在喂食的时候,试着抚摸它的头(在屏幕上滑动),它会发出满意的咕噜声!'),
|
||||
]),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSection(
|
||||
String title,
|
||||
List<InlineSpan> content, {
|
||||
String? highlight,
|
||||
}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 32),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Styled H2 mimic
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
margin: const EdgeInsets.only(right: 10),
|
||||
decoration: const BoxDecoration(color: Color(0xFF8B5E3C)),
|
||||
),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF8B5E3C),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFamily: 'Courier', // Monospace-ish backup
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Content
|
||||
RichText(
|
||||
text: TextSpan(
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
color: Color(0xFF4B5563),
|
||||
height: 1.7,
|
||||
),
|
||||
children: content,
|
||||
),
|
||||
),
|
||||
|
||||
if (highlight != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFFFF7ED),
|
||||
borderRadius: BorderRadius.horizontal(
|
||||
right: Radius.circular(8),
|
||||
),
|
||||
border: Border(
|
||||
left: BorderSide(color: Color(0xFFF97316), width: 4),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
highlight,
|
||||
style: const TextStyle(fontSize: 14, color: Color(0xFF9A3412)),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
278
airhub_app/lib/pages/profile/help_page.dart
Normal file
@ -0,0 +1,278 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:airhub_app/theme/design_tokens.dart';
|
||||
import 'package:airhub_app/pages/profile/guide_feeding_page.dart';
|
||||
|
||||
class HelpPage extends StatelessWidget {
|
||||
const HelpPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
body: Stack(
|
||||
children: [
|
||||
Container(decoration: const BoxDecoration(color: Color(0xFFFEFEFE))),
|
||||
Column(
|
||||
children: [
|
||||
_buildHeader(context),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.only(
|
||||
top: 20,
|
||||
left: 20,
|
||||
right: 20,
|
||||
bottom: 40 + MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const Text(
|
||||
'帮助 Q&A',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'更新日期:2025年1月15日',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildGuideCard(context),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
_buildFaqSection('设备连接与管理', [
|
||||
_FaqItem(
|
||||
'手机连接设备时"未扫描到设备"',
|
||||
'请检查设备是否在配网模式下(双击设备电源键按钮,直至呈现Wi-Fi图标),请确保设备和手机距离在10m内,点击【重新扫描】。',
|
||||
),
|
||||
_FaqItem(
|
||||
'手机连接设备时"连接设备失败"',
|
||||
'可能为服务超时造成的异常,请保持设备处于配网模式下,点击【再试一次】。',
|
||||
),
|
||||
_FaqItem(
|
||||
'如何添加多个 Wi-Fi 网络?',
|
||||
'进入设备控制页 → 设置 → 配置网络,按提示添加备用网络。设备会自动切换到信号最强的网络。',
|
||||
),
|
||||
]),
|
||||
|
||||
_buildFaqSection('角色养成', [
|
||||
_FaqItem(
|
||||
'什么是角色记忆?',
|
||||
'角色记忆是您与 AI 互动过程中产生的人格数据,包含对话风格、喜好偏好等信息。角色记忆可以在不同设备间迁移,让您的 AI 伙伴始终如一。',
|
||||
),
|
||||
_FaqItem(
|
||||
'如何将角色记忆迁移到新设备?',
|
||||
'进入「我的」→「角色记忆」,找到需要迁移的记忆,点击「注入设备」,选择目标设备即可完成迁移。',
|
||||
),
|
||||
]),
|
||||
|
||||
_buildFaqSection('常见问题', [
|
||||
_FaqItem(
|
||||
'设备离线怎么办?',
|
||||
'请检查设备电源和网络连接。如果问题持续,尝试重启设备或重新配网。',
|
||||
),
|
||||
_FaqItem(
|
||||
'如何联系客服?',
|
||||
'您可以通过「我的」→「意见反馈」联系我们,或发送邮件至 support@airhub.com。',
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.only(
|
||||
top: MediaQuery.of(context).padding.top + 20,
|
||||
left: AppSpacing.lg,
|
||||
right: AppSpacing.lg,
|
||||
bottom: AppSpacing.md,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.pop(context),
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.iconBtnBg,
|
||||
borderRadius: BorderRadius.circular(AppRadius.button),
|
||||
border: Border.all(color: AppColors.iconBtnBorder),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.arrow_back,
|
||||
color: AppColors.textPrimary,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Text('帮助中心', style: AppTextStyles.title),
|
||||
const SizedBox(width: 44),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGuideCard(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFFFEF9E7), Color(0xFFFDF2E9)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF8B5E3C).withOpacity(0.1),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFFECCFA8), Color(0xFFC99672)],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const Text('📖', style: TextStyle(fontSize: 24)),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: const [
|
||||
Text(
|
||||
'喂养指南',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 4),
|
||||
Text(
|
||||
'详细的角色养成方法和日常照顾指南',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const GuideFeedingPage()),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFFECCFA8), Color(0xFFC99672)],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: const Text(
|
||||
'查看 →',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFaqSection(String title, List<_FaqItem> items) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4, bottom: 8),
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.sectionTitle,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.8),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: const [AppShadows.card],
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: items.map((item) => _buildExpansionTile(item)).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildExpansionTile(_FaqItem item) {
|
||||
return Theme(
|
||||
data: ThemeData().copyWith(dividerColor: Colors.transparent),
|
||||
child: ExpansionTile(
|
||||
title: Text(
|
||||
item.question,
|
||||
style: const TextStyle(fontSize: 15, color: AppColors.textPrimary),
|
||||
),
|
||||
childrenPadding: const EdgeInsets.only(left: 20, right: 20, bottom: 16),
|
||||
expandedCrossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.answer,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FaqItem {
|
||||
final String question;
|
||||
final String answer;
|
||||
_FaqItem(this.question, this.answer);
|
||||
}
|
||||
381
airhub_app/lib/pages/profile/profile_info_page.dart
Normal file
@ -0,0 +1,381 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:airhub_app/theme/design_tokens.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'dart:io';
|
||||
|
||||
class ProfileInfoPage extends StatefulWidget {
|
||||
const ProfileInfoPage({super.key});
|
||||
|
||||
@override
|
||||
State<ProfileInfoPage> createState() => _ProfileInfoPageState();
|
||||
}
|
||||
|
||||
class _ProfileInfoPageState extends State<ProfileInfoPage> {
|
||||
String _gender = '男';
|
||||
String _birthday = '1994-12-09';
|
||||
File? _avatarImage;
|
||||
final TextEditingController _nicknameController = TextEditingController(
|
||||
text: '土豆',
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
body: Stack(
|
||||
children: [
|
||||
// Background - Simplified gradient for consistency
|
||||
Container(
|
||||
decoration: const BoxDecoration(color: Color(0xFFFEFEFE)),
|
||||
// We can reuse the same gradient background widget or implement a similar one
|
||||
// For now, simple background to focus on content
|
||||
),
|
||||
|
||||
Column(
|
||||
children: [
|
||||
_buildHeader(context),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.only(
|
||||
top: 20,
|
||||
left: AppSpacing.lg,
|
||||
right: AppSpacing.lg,
|
||||
bottom: 40 + MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 20),
|
||||
_buildAvatarSection(),
|
||||
const SizedBox(height: 32),
|
||||
_buildFormCard(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.only(
|
||||
top: MediaQuery.of(context).padding.top + 20,
|
||||
left: AppSpacing.lg,
|
||||
right: AppSpacing.lg,
|
||||
bottom: AppSpacing.md,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_buildBackButton(context),
|
||||
const Text('个人信息', style: AppTextStyles.title),
|
||||
_buildSaveButton(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBackButton(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () => Navigator.pop(context),
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.iconBtnBg,
|
||||
borderRadius: BorderRadius.circular(AppRadius.button),
|
||||
border: Border.all(color: AppColors.iconBtnBorder),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.arrow_back,
|
||||
color: AppColors.textPrimary,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSaveButton() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: AppColors.saveBtnGradient,
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: const Text(
|
||||
'保存',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAvatarSection() {
|
||||
return Stack(
|
||||
children: [
|
||||
Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: LinearGradient(
|
||||
colors: AppColors.avatarGradient,
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Color(0x338B5E3C), // rgba(139, 94, 60, 0.2)
|
||||
blurRadius: 24,
|
||||
offset: Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipOval(
|
||||
child: _avatarImage != null
|
||||
? Image.file(_avatarImage!, fit: BoxFit.cover)
|
||||
: Image.asset(
|
||||
'assets/www/Capybara.png',
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (ctx, err, stack) =>
|
||||
const Icon(Icons.person, color: Colors.white, size: 40),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
child: GestureDetector(
|
||||
onTap: _pickImage,
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: AppColors.saveBtnGradient,
|
||||
),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 3),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.15),
|
||||
offset: const Offset(0, 2),
|
||||
blurRadius: 8,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.camera_alt,
|
||||
color: Colors.white,
|
||||
size: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickImage() async {
|
||||
try {
|
||||
final ImagePicker picker = ImagePicker();
|
||||
final XFile? image = await picker.pickImage(source: ImageSource.gallery);
|
||||
|
||||
if (image != null) {
|
||||
setState(() {
|
||||
_avatarImage = File(image.path);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('选择图片失败: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildFormCard() {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cardSurface,
|
||||
borderRadius: BorderRadius.circular(AppRadius.card),
|
||||
boxShadow: const [AppShadows.card],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildInputItem('昵称', _nicknameController),
|
||||
_buildSelectionItem('性别', _gender, onTap: _showGenderModal),
|
||||
_buildSelectionItem(
|
||||
'生日',
|
||||
_birthday,
|
||||
showDivider: false,
|
||||
onTap: _showBirthdayInput,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInputItem(String label, TextEditingController controller) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.lg,
|
||||
vertical: 18,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: AppColors.divider)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 80,
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(color: AppColors.formLabel, fontSize: 15),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
textAlign: TextAlign.right,
|
||||
decoration: const InputDecoration.collapsed(
|
||||
hintText: '请输入',
|
||||
hintStyle: TextStyle(color: AppColors.textHint),
|
||||
),
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSelectionItem(
|
||||
String label,
|
||||
String value, {
|
||||
bool showDivider = true,
|
||||
VoidCallback? onTap,
|
||||
}) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.lg,
|
||||
vertical: 18,
|
||||
),
|
||||
decoration: showDivider
|
||||
? const BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: AppColors.divider)),
|
||||
)
|
||||
: null,
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 80,
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: AppColors.formLabel,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
textAlign: TextAlign.right,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(
|
||||
Icons.chevron_right,
|
||||
color: AppColors.textHint,
|
||||
size: 18,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showGenderModal() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('选择性别', style: AppTextStyles.title),
|
||||
const SizedBox(height: 20),
|
||||
ListTile(
|
||||
title: const Text('男', textAlign: TextAlign.center),
|
||||
onTap: () {
|
||||
setState(() => _gender = '男');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
title: const Text('女', textAlign: TextAlign.center),
|
||||
onTap: () {
|
||||
setState(() => _gender = '女');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text(
|
||||
'取消',
|
||||
style: TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Simplified for MVP - using text input dialog for birthday as per PRD implication (custom input modal)
|
||||
void _showBirthdayInput() {
|
||||
// ... Implementation omitted for brevity in this step, can be added if requested or use standard date picker
|
||||
// Using standard DatePicker for better UX in Flutter
|
||||
showDatePicker(
|
||||
context: context,
|
||||
initialDate: DateTime.tryParse(_birthday) ?? DateTime(1994, 12, 9),
|
||||
firstDate: DateTime(1900),
|
||||
lastDate: DateTime.now(),
|
||||
).then((picked) {
|
||||
if (picked != null) {
|
||||
setState(() {
|
||||
_birthday =
|
||||
"${picked.year}-${picked.month.toString().padLeft(2, '0')}-${picked.day.toString().padLeft(2, '0')}";
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
338
airhub_app/lib/pages/profile/profile_page.dart
Normal file
@ -0,0 +1,338 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:airhub_app/theme/design_tokens.dart';
|
||||
import 'package:airhub_app/widgets/feedback_dialog.dart';
|
||||
import 'package:airhub_app/pages/profile/profile_info_page.dart';
|
||||
import 'package:airhub_app/pages/profile/settings_page.dart';
|
||||
import 'package:airhub_app/pages/profile/agent_manage_page.dart';
|
||||
import 'package:airhub_app/pages/profile/help_page.dart';
|
||||
import 'package:airhub_app/pages/product_selection_page.dart';
|
||||
|
||||
class ProfilePage extends StatelessWidget {
|
||||
const ProfilePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
// 动态渐变背景
|
||||
const Positioned.fill(child: _GradientBackground()),
|
||||
|
||||
// 内容区域
|
||||
Column(
|
||||
children: [
|
||||
_buildHeader(),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.lg,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 20), // Top spacing
|
||||
const SizedBox(height: 20), // Top spacing
|
||||
_buildUserCard(context),
|
||||
const SizedBox(height: 20),
|
||||
_buildMenuList(context),
|
||||
const SizedBox(height: 140), // Bottom padding for footer
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 20, // safe area will be added by SafeArea or MediaQuery
|
||||
left: AppSpacing.lg,
|
||||
right: AppSpacing.lg,
|
||||
bottom: AppSpacing.md,
|
||||
),
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const SizedBox(width: 44), // Placeholder for balance
|
||||
const Text('我的', style: AppTextStyles.title),
|
||||
_buildNotificationButton(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNotificationButton() {
|
||||
return Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.iconBtnBg,
|
||||
borderRadius: BorderRadius.circular(AppRadius.button),
|
||||
border: Border.all(color: AppColors.iconBtnBorder),
|
||||
),
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.notifications_outlined,
|
||||
color: AppColors.textPrimary,
|
||||
size: 22,
|
||||
),
|
||||
Positioned(
|
||||
top: 10,
|
||||
right: 10,
|
||||
child: Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.notificationDot,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUserCard(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const ProfileInfoPage()),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cardSurface,
|
||||
borderRadius: BorderRadius.circular(AppRadius.card),
|
||||
boxShadow: const [AppShadows.card],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: LinearGradient(
|
||||
colors: AppColors.avatarGradient,
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
child: ClipOval(
|
||||
child: Image.asset(
|
||||
'assets/www/Capybara.png',
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (ctx, err, stack) =>
|
||||
const Icon(Icons.person, color: Colors.white),
|
||||
), // Fallback
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: const [
|
||||
Text('土豆', style: AppTextStyles.userName),
|
||||
SizedBox(height: 4),
|
||||
Text('ID: 138****3069', style: AppTextStyles.userId),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.chevron_right,
|
||||
color: AppColors.textHint,
|
||||
size: 24,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMenuList(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
boxShadow: const [AppShadows.card],
|
||||
borderRadius: BorderRadius.circular(AppRadius.card),
|
||||
),
|
||||
child: Material(
|
||||
color: AppColors.cardSurface,
|
||||
borderRadius: BorderRadius.circular(AppRadius.card),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: [
|
||||
_buildMenuItem(
|
||||
context,
|
||||
'🧠',
|
||||
'角色记忆',
|
||||
showDivider: true,
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const AgentManagePage()),
|
||||
),
|
||||
),
|
||||
_buildMenuItem(
|
||||
context,
|
||||
'📦',
|
||||
'我的设备',
|
||||
showDivider: true,
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const ProductSelectionPage()),
|
||||
),
|
||||
),
|
||||
_buildMenuItem(
|
||||
context,
|
||||
'⚙️',
|
||||
'设置',
|
||||
showDivider: true,
|
||||
badge: 'NEW',
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const SettingsPage()),
|
||||
);
|
||||
},
|
||||
),
|
||||
_buildMenuItem(
|
||||
context,
|
||||
'💬',
|
||||
'意见反馈',
|
||||
showDivider: true,
|
||||
onTap: () => _showFeedbackDialog(context),
|
||||
),
|
||||
_buildMenuItem(
|
||||
context,
|
||||
'❓',
|
||||
'帮助中心',
|
||||
showDivider: false,
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const HelpPage()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMenuItem(
|
||||
BuildContext context,
|
||||
String iconEmoji,
|
||||
String text, {
|
||||
bool showDivider = true,
|
||||
String? badge,
|
||||
VoidCallback? onTap,
|
||||
}) {
|
||||
return InkWell(
|
||||
onTap:
|
||||
onTap ??
|
||||
() {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('点击了: $text (功能开发中)')));
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.lg,
|
||||
vertical: 18,
|
||||
),
|
||||
decoration: showDivider
|
||||
? const BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Color(0x0D000000),
|
||||
), // rgba(0,0,0,0.05)
|
||||
),
|
||||
)
|
||||
: null,
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 24,
|
||||
child: Text(iconEmoji, style: const TextStyle(fontSize: 20)),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(child: Text(text, style: AppTextStyles.menuText)),
|
||||
if (badge != null) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.badgeNew,
|
||||
borderRadius: BorderRadius.circular(AppRadius.badge),
|
||||
),
|
||||
child: Text(badge, style: AppTextStyles.badge),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
const Icon(
|
||||
Icons.chevron_right,
|
||||
color: AppColors.textHint,
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showFeedbackDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierColor: Colors.black.withOpacity(0.5),
|
||||
builder: (context) => const FeedbackDialog(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GradientBackground extends StatelessWidget {
|
||||
const _GradientBackground();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Simplified static version of the animated gradient for now
|
||||
// Future enhancement: Implement the full CSS animations
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFFEFEFE), // Base
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
// Layer 1
|
||||
Positioned(
|
||||
top: -100,
|
||||
left: -100,
|
||||
child: Container(
|
||||
width: 400,
|
||||
height: 400,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
const Color(0xFFFFC8DC).withOpacity(0.6),
|
||||
Colors.transparent,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Add more layers as needed to mimic css
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
323
airhub_app/lib/pages/profile/settings_page.dart
Normal file
@ -0,0 +1,323 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:airhub_app/theme/design_tokens.dart';
|
||||
import 'package:airhub_app/pages/profile/settings_sub_pages.dart';
|
||||
import 'package:airhub_app/pages/product_selection_page.dart';
|
||||
import 'package:airhub_app/widgets/glass_dialog.dart';
|
||||
|
||||
class SettingsPage extends StatefulWidget {
|
||||
const SettingsPage({super.key});
|
||||
|
||||
@override
|
||||
State<SettingsPage> createState() => _SettingsPageState();
|
||||
}
|
||||
|
||||
class _SettingsPageState extends State<SettingsPage> {
|
||||
bool _notificationEnabled = true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
body: Stack(
|
||||
children: [
|
||||
Container(decoration: const BoxDecoration(color: Color(0xFFFEFEFE))),
|
||||
Column(
|
||||
children: [
|
||||
_buildHeader(context),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.only(
|
||||
top: 20,
|
||||
left: AppSpacing.lg,
|
||||
right: AppSpacing.lg,
|
||||
bottom: 40 + MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildSection('账号安全', [
|
||||
_buildItem(
|
||||
'📱',
|
||||
'绑定手机',
|
||||
value: '138****3069',
|
||||
onTap: () => _showMessage('绑定手机', '138****3069'),
|
||||
),
|
||||
_buildItem(
|
||||
'🔐',
|
||||
'账号密码',
|
||||
onTap: () => _showMessage('提示', '密码修改功能开发中...'),
|
||||
),
|
||||
_buildItem(
|
||||
'📦',
|
||||
'设备管理',
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const ProductSelectionPage(),
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildItem(
|
||||
'🔔',
|
||||
'推送通知权限',
|
||||
value: _notificationEnabled ? '已开启' : '已关闭',
|
||||
onTap: _toggleNotification,
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 24),
|
||||
_buildSection('关于', [
|
||||
_buildItem(
|
||||
'🔄',
|
||||
'检查更新',
|
||||
value: '当前最新 1.0.0',
|
||||
onTap: () => _showMessage('检查更新', '当前已是最新版本 v1.0.0'),
|
||||
),
|
||||
_buildItem(
|
||||
'💻',
|
||||
'硬件信息',
|
||||
onTap: () => _showMessage(
|
||||
'硬件信息',
|
||||
'设备型号: Airhub_5G\n固件版本: 2.1.3',
|
||||
),
|
||||
),
|
||||
_buildItem(
|
||||
'📄',
|
||||
'用户协议',
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const AgreementPage(),
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildItem(
|
||||
'🔒',
|
||||
'隐私政策',
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const PrivacyPage(),
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildItem(
|
||||
'📋',
|
||||
'个人信息收集清单',
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const CollectionListPage(),
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildItem(
|
||||
'🔗',
|
||||
'第三方信息共享清单',
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const SharingListPage(),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 24),
|
||||
_buildSection(null, [
|
||||
_buildItem(
|
||||
'🚪',
|
||||
'退出登录',
|
||||
isDanger: true,
|
||||
onTap: _showLogoutDialog,
|
||||
),
|
||||
_buildItem(
|
||||
'⚠️',
|
||||
'账号注销',
|
||||
isDanger: true,
|
||||
isLast: true,
|
||||
onTap: _showDeleteAccountDialog,
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 32),
|
||||
const Text(
|
||||
'Airhub v1.0.0\n© 2025 Airhub Team',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.only(
|
||||
top: MediaQuery.of(context).padding.top + 20,
|
||||
left: AppSpacing.lg,
|
||||
right: AppSpacing.lg,
|
||||
bottom: AppSpacing.md,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.pop(context),
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.iconBtnBg,
|
||||
borderRadius: BorderRadius.circular(AppRadius.button),
|
||||
border: Border.all(color: AppColors.iconBtnBorder),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.arrow_back,
|
||||
color: AppColors.textPrimary,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Center(child: Text('设置', style: AppTextStyles.title)),
|
||||
),
|
||||
const SizedBox(width: 44), // Balance
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSection(String? title, List<Widget> children) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (title != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4, bottom: 8),
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: AppColors.sectionTitle,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cardSurface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: const [AppShadows.card],
|
||||
),
|
||||
child: Column(children: children),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildItem(
|
||||
String icon,
|
||||
String text, {
|
||||
String? value,
|
||||
bool isDanger = false,
|
||||
bool isLast = false,
|
||||
VoidCallback? onTap,
|
||||
}) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
decoration: !isLast
|
||||
? const BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: AppColors.divider)),
|
||||
)
|
||||
: null,
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 24,
|
||||
child: Text(icon, style: const TextStyle(fontSize: 18)),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: isDanger ? AppColors.danger : AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (value != null) ...[
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
const Icon(
|
||||
Icons.chevron_right,
|
||||
color: AppColors.textHint,
|
||||
size: 18,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _toggleNotification() {
|
||||
setState(() => _notificationEnabled = !_notificationEnabled);
|
||||
}
|
||||
|
||||
void _showMessage(String title, String desc) {
|
||||
showGlassDialog(
|
||||
context: context,
|
||||
title: title,
|
||||
description: desc,
|
||||
confirmText: '确定',
|
||||
onConfirm: () => Navigator.pop(context),
|
||||
);
|
||||
}
|
||||
|
||||
void _showLogoutDialog() {
|
||||
showGlassDialog(
|
||||
context: context,
|
||||
title: '确认退出登录?',
|
||||
description: '退出后需要重新登录才能使用。',
|
||||
cancelText: '取消',
|
||||
confirmText: '退出',
|
||||
isDanger: true,
|
||||
onConfirm: () {
|
||||
Navigator.pop(context); // Close dialog
|
||||
// In real app: clear session and nav to login
|
||||
Navigator.of(
|
||||
context,
|
||||
).pushNamedAndRemoveUntil('/login', (route) => false);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showDeleteAccountDialog() {
|
||||
showGlassDialog(
|
||||
context: context,
|
||||
title: '确认注销账号?',
|
||||
description: '账号注销后所有数据将被永久删除,且无法恢复。',
|
||||
cancelText: '取消',
|
||||
confirmText: '确认注销',
|
||||
isDanger: true,
|
||||
onConfirm: () {
|
||||
Navigator.pop(context);
|
||||
_showMessage('已提交', '账号注销申请已提交,将在7个工作日内处理。');
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
247
airhub_app/lib/pages/profile/settings_sub_pages.dart
Normal file
@ -0,0 +1,247 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:airhub_app/theme/design_tokens.dart';
|
||||
|
||||
class SettingsContentPage extends StatelessWidget {
|
||||
final String title;
|
||||
final String date;
|
||||
final List<Widget> children;
|
||||
|
||||
const SettingsContentPage({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.date,
|
||||
required this.children,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
body: Stack(
|
||||
children: [
|
||||
Container(
|
||||
decoration: const BoxDecoration(color: Color(0xFFFEFEFE)),
|
||||
), // Simplified background
|
||||
Column(
|
||||
children: [
|
||||
_buildHeader(context),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.only(
|
||||
top: 20,
|
||||
left: 24,
|
||||
right: 24,
|
||||
bottom: 40 + MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
...children,
|
||||
const SizedBox(height: 40),
|
||||
Center(
|
||||
child: Text(
|
||||
'更新日期:$date',
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.only(
|
||||
top: MediaQuery.of(context).padding.top + 20,
|
||||
left: AppSpacing.lg,
|
||||
right: AppSpacing.lg,
|
||||
bottom: AppSpacing.md,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.pop(context),
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.iconBtnBg,
|
||||
borderRadius: BorderRadius.circular(AppRadius.button),
|
||||
border: Border.all(color: AppColors.iconBtnBorder),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.arrow_back,
|
||||
color: AppColors.textPrimary,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(title, style: AppTextStyles.title),
|
||||
const SizedBox(width: 44), // Balance
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper methods to generate text styles
|
||||
Widget buildSectionTitle(String text) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 32, bottom: 12),
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildParagraph(String text) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Text(
|
||||
text,
|
||||
textAlign: TextAlign.justify,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
height: 1.6,
|
||||
color: Color(0xFF374151),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildBulletList(List<String> items) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16, left: 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: items
|
||||
.map(
|
||||
(item) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('• ', style: TextStyle(fontSize: 15, height: 1.6)),
|
||||
Expanded(
|
||||
child: Text(
|
||||
item,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
height: 1.6,
|
||||
color: Color(0xFF374151),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Pre-defined pages content factories
|
||||
class AgreementPage extends StatelessWidget {
|
||||
const AgreementPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SettingsContentPage(
|
||||
title: '用户协议',
|
||||
date: '2025年1月15日',
|
||||
children: [
|
||||
buildParagraph('欢迎您使用 Airhub 产品及服务!'),
|
||||
buildParagraph(
|
||||
'特别提示: 在您开始使用 Airhub 产品(以下简称"本产品")及相关服务之前,请您务必仔细阅读本《用户协议》(以下简称"本协议")。特别是涉及免除或者限制责任的条款、法律适用和争议解决条款等,请您重点阅读。',
|
||||
),
|
||||
buildSectionTitle('1. 服务说明'),
|
||||
buildParagraph(
|
||||
'1.1 Airhub Team(以下简称"我们")向用户提供包括但不限于设备连接控制、AI 语音交互、角色记忆存储、云端同步等服务(以下简称"本服务")。',
|
||||
),
|
||||
buildParagraph('1.2 本服务的具体内容由我们根据实际情况提供,我们有权随时变更、中断或终止部分或全部服务。'),
|
||||
buildParagraph('1.3 用户理解并同意,本服务仅供用户个人非商业性质的使用。用户不得利用本服务进行销售或其他商业用途。'),
|
||||
buildSectionTitle('2. 账号注册与使用'),
|
||||
buildParagraph('2.1 用户在使用本服务时需要注册一个 Airhub 账号。用户应保证注册信息的真实性、准确性和完整性。'),
|
||||
buildParagraph('2.2 用户有责任妥善保管注册账号信息及密码安全。因用户保管不善可能导致账号被盗及其后果,由用户自行承担。'),
|
||||
buildParagraph(
|
||||
'2.3 如发现任何未经授权使用您账号登录、使用本服务的情况,您应立即通知我们。您理解我们对您的任何请求采取行动需要合理时间,我们对在采取行动前已经产生的后果不承担责任。',
|
||||
),
|
||||
buildSectionTitle('3. 用户行为规范'),
|
||||
buildParagraph('用户在使用本服务过程中,应当遵守法律法规,不得从事下列行为:'),
|
||||
buildBulletList([
|
||||
'发布、传送、传播、储存危害国家安全、破坏社会稳定、违反公序良俗的内容;',
|
||||
'发布、传送、传播、储存侮辱、诽谤、淫秽、暴力、赌博等违法违规内容;',
|
||||
'利用 AI 功能生成虚假信息、诈骗信息或用于非法用途;',
|
||||
'对 AI 角色进行性骚扰、辱骂或诱导生成不当内容;',
|
||||
'进行任何危害计算机网络安全的行为,包括但不限于攻击、侵入他人系统。',
|
||||
]),
|
||||
buildSectionTitle('4. 个人信息保护'),
|
||||
buildParagraph(
|
||||
'4.1 保护用户个人信息是我们的基本原则。我们将按照本协议及《隐私政策》的规定收集、使用、存储和分享您的个人信息。',
|
||||
),
|
||||
// ... simplified for brevity, following the pattern
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PrivacyPage extends StatelessWidget {
|
||||
const PrivacyPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SettingsContentPage(
|
||||
title: '隐私政策',
|
||||
date: '2025年1月15日',
|
||||
children: [
|
||||
buildParagraph('Airhub 非常重视用户的隐私保护。本隐私政策旨在向您说明我们如何收集、使用、存储和分享您的个人信息。'),
|
||||
buildSectionTitle('1. 我们收集的信息'),
|
||||
buildParagraph('1.1 为了向您提供服务,我们可能会收集您的手机号码、设备信息(如设备型号、操作系统版本)、IP地址等。'),
|
||||
// ... Placeholder content similar to structure
|
||||
buildParagraph('1.2 当您使用语音交互功能时,我们会处理您的语音数据以提供识别和回复服务。'),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CollectionListPage extends StatelessWidget {
|
||||
const CollectionListPage({super.key});
|
||||
@override
|
||||
Widget build(BuildContext context) => SettingsContentPage(
|
||||
title: '个人信息收集清单',
|
||||
date: '2025年1月15日',
|
||||
children: [
|
||||
buildParagraph('以下是我们收集的个人信息清单:'),
|
||||
buildBulletList(['手机号码:用于账号注册和登录', '设备信息:用于适配和安全风控']),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class SharingListPage extends StatelessWidget {
|
||||
const SharingListPage({super.key});
|
||||
@override
|
||||
Widget build(BuildContext context) => SettingsContentPage(
|
||||
title: '第三方信息共享清单',
|
||||
date: '2025年1月15日',
|
||||
children: [
|
||||
buildParagraph('我们可能会与以下第三方共享必要信息:'),
|
||||
buildBulletList(['SDK服务商:提供推送、地图等基础服务', '云服务商:提供数据存储和计算服务']),
|
||||
],
|
||||
);
|
||||
}
|
||||
436
airhub_app/lib/pages/settings_page.dart
Normal file
@ -0,0 +1,436 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'product_selection_page.dart';
|
||||
import '../widgets/glass_dialog.dart';
|
||||
|
||||
class SettingsPage extends StatefulWidget {
|
||||
const SettingsPage({super.key});
|
||||
|
||||
@override
|
||||
State<SettingsPage> createState() => _SettingsPageState();
|
||||
}
|
||||
|
||||
class _SettingsPageState extends State<SettingsPage> {
|
||||
// State for mock data
|
||||
String _deviceName = '小毛球';
|
||||
String _userName = '土豆';
|
||||
double _volume = 60;
|
||||
double _brightness = 85;
|
||||
bool _allowInterrupt = true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
decoration: const BoxDecoration(
|
||||
// CSS: linear-gradient(135deg, #FEF5EC 0%, #FDF2F8 100%);
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFFFEF5EC), Color(0xFFFDF2F8)],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// Header
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 12,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
// HTML icon-btn style: rgba(255, 255, 255, 0.25) but settings-header says transparent!
|
||||
// CSS .settings-header says: background: transparent !important;
|
||||
// And the button inside? HTML lines 885: <button class="icon-btn" ...>
|
||||
// .icon-btn has border/bg.
|
||||
// But usually header buttons in Airhub are styled.
|
||||
// I'll stick to simple icon or matching box.
|
||||
// HTML: <button class="icon-btn">...
|
||||
// CSS: .icon-btn { background: rgba(255, 255, 255, 0.25); ... width: 44px... }
|
||||
// So yes, it has a box.
|
||||
color: Colors.white.withOpacity(0.25),
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
border: Border.all(
|
||||
color: Colors.white.withOpacity(0.4),
|
||||
),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const Icon(
|
||||
Icons.arrow_back_ios_new,
|
||||
size: 20,
|
||||
color: Color(0xFF1F2937),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Text(
|
||||
'设置',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF1F2937),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 44), // Spacer to center title
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 40),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildGroupTitle('基础设置'),
|
||||
_buildSettingsGroup([
|
||||
_buildListTile(
|
||||
'设备昵称',
|
||||
_deviceName,
|
||||
onTap: () => _showEditDialog(
|
||||
'修改设备昵称',
|
||||
_deviceName,
|
||||
(val) => setState(() => _deviceName = val),
|
||||
),
|
||||
),
|
||||
_buildDivider(),
|
||||
_buildListTile(
|
||||
'你的称呼',
|
||||
_userName,
|
||||
onTap: () => _showEditDialog(
|
||||
'修改你的称呼',
|
||||
_userName,
|
||||
(val) => setState(() => _userName = val),
|
||||
),
|
||||
),
|
||||
]),
|
||||
|
||||
_buildGroupTitle('音量与亮度'),
|
||||
_buildSettingsGroup([
|
||||
_buildSliderItem(
|
||||
'音量',
|
||||
_volume,
|
||||
'🔈',
|
||||
'🔊',
|
||||
(val) => setState(() => _volume = val),
|
||||
),
|
||||
_buildDivider(),
|
||||
_buildSliderItem(
|
||||
'亮度',
|
||||
_brightness,
|
||||
'☀',
|
||||
'☼',
|
||||
(val) => setState(() => _brightness = val),
|
||||
),
|
||||
]),
|
||||
|
||||
_buildGroupTitle('网络与连接'),
|
||||
_buildSettingsGroup([
|
||||
_buildListTile(
|
||||
'配置网络',
|
||||
'',
|
||||
subtitle: '为该设备添加更多 Wi-Fi',
|
||||
onTap: _showNetworkDialog,
|
||||
),
|
||||
_buildDivider(),
|
||||
_buildListTile(
|
||||
'解绑设备',
|
||||
'',
|
||||
subtitle: '解绑后的角色记忆将保存至云端',
|
||||
textColor: const Color(0xFFEF4444),
|
||||
onTap: _showUnbindDialog,
|
||||
),
|
||||
]),
|
||||
|
||||
_buildGroupTitle('交互体验'),
|
||||
_buildSettingsGroup([
|
||||
_buildToggleItem(
|
||||
'允许打断',
|
||||
_allowInterrupt,
|
||||
(val) => setState(() => _allowInterrupt = val),
|
||||
),
|
||||
_buildDivider(),
|
||||
_buildListTile('隐私模式', '已开启'),
|
||||
]),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ... (Helper widgets same as before) ...
|
||||
// To avoid extremely long tool call, I will include them.
|
||||
|
||||
Widget _buildGroupTitle(String title) {
|
||||
return Padding(
|
||||
// HTML: margin-top: 24px, margin-bottom: 8px
|
||||
padding: const EdgeInsets.only(top: 24, bottom: 8, left: 16),
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12, // HTML: 12px
|
||||
fontWeight: FontWeight.w500, // HTML: 500
|
||||
color: Color(0xFF8B5E3C), // HTML: warm brown
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSettingsGroup(List<Widget> children) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
// HTML: rgba(255, 255, 255, 0.8), border-radius 20px
|
||||
color: Colors.white.withOpacity(0.8),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF8B5E3C).withOpacity(0.04),
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(children: children),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildListTile(
|
||||
String label,
|
||||
String value, {
|
||||
String? subtitle,
|
||||
Color? textColor,
|
||||
VoidCallback? onTap,
|
||||
}) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: textColor ?? const Color(0xFF1F2937),
|
||||
),
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
color: const Color(0xFF9CA3AF),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (value.isNotEmpty)
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: const Color(0xFF4B5563),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(
|
||||
Icons.arrow_forward_ios_rounded,
|
||||
size: 14,
|
||||
color: Color(0xFFD1D5DB),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildToggleItem(
|
||||
String label,
|
||||
bool value,
|
||||
ValueChanged<bool> onChanged,
|
||||
) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF1F2937),
|
||||
),
|
||||
),
|
||||
Switch(
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
activeColor: Colors.white,
|
||||
activeTrackColor: const Color(0xFFFFB088), // HTML: warm orange
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSliderItem(
|
||||
String label,
|
||||
double value,
|
||||
String iconL,
|
||||
String iconR,
|
||||
ValueChanged<double> onChanged,
|
||||
) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF1F2937),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${value.toInt()}%',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF6B7280),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
iconL,
|
||||
style: const TextStyle(fontSize: 16, color: Color(0xFF9CA3AF)),
|
||||
),
|
||||
Expanded(
|
||||
child: SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
activeTrackColor: const Color(0xFF8B5CF6),
|
||||
inactiveTrackColor: const Color(0xFFE5E7EB),
|
||||
thumbColor: Colors.white,
|
||||
trackHeight: 4,
|
||||
),
|
||||
child: Slider(
|
||||
value: value,
|
||||
min: 0,
|
||||
max: 100,
|
||||
onChanged: onChanged,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
iconR,
|
||||
style: const TextStyle(fontSize: 18, color: Color(0xFF9CA3AF)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDivider() {
|
||||
return const Divider(
|
||||
height: 1,
|
||||
color: Color(0xFFF3F4F6),
|
||||
indent: 16,
|
||||
endIndent: 16,
|
||||
);
|
||||
}
|
||||
|
||||
void _showEditDialog(
|
||||
String title,
|
||||
String initialValue,
|
||||
ValueSetter<String> onSaved,
|
||||
) {
|
||||
final controller = TextEditingController(text: initialValue);
|
||||
showGlassDialog(
|
||||
context: context,
|
||||
title: title,
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
),
|
||||
),
|
||||
onConfirm: () {
|
||||
onSaved(controller.text);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showNetworkDialog() {
|
||||
showGlassDialog(
|
||||
context: context,
|
||||
title: '添加备用网络',
|
||||
description: '需要重新连接设备蓝牙来配置新的 Wi-Fi 网络。请确保设备在附近且已开机,手机蓝牙已打开。',
|
||||
confirmText: '开始配置',
|
||||
onConfirm: () {
|
||||
Navigator.pop(context);
|
||||
Navigator.pop(context);
|
||||
Navigator.of(context).pushNamedAndRemoveUntil('/', (route) => false);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showUnbindDialog() {
|
||||
showGlassDialog(
|
||||
context: context,
|
||||
title: '确认解绑设备?',
|
||||
description: '解绑后,设备 Airhub_5G 将无法使用。您与 小毛球 的交互数据已形成角色记忆,可注入其他设备。',
|
||||
confirmText: '解绑',
|
||||
isDanger: true,
|
||||
onConfirm: () {
|
||||
Navigator.pop(context);
|
||||
Navigator.pop(context);
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(builder: (context) => const ProductSelectionPage()),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
409
airhub_app/lib/pages/story_detail_page.dart
Normal file
@ -0,0 +1,409 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import '../theme/design_tokens.dart';
|
||||
import '../widgets/gradient_button.dart';
|
||||
|
||||
enum StoryMode { generated, read }
|
||||
|
||||
class StoryDetailPage extends StatefulWidget {
|
||||
final Map<String, dynamic>? story; // Pass story object
|
||||
final StoryMode mode;
|
||||
|
||||
const StoryDetailPage({
|
||||
super.key,
|
||||
this.story,
|
||||
this.mode = StoryMode.read, // Default: Read mode (from HTML logic)
|
||||
});
|
||||
|
||||
@override
|
||||
State<StoryDetailPage> createState() => _StoryDetailPageState();
|
||||
}
|
||||
|
||||
class _StoryDetailPageState extends State<StoryDetailPage> {
|
||||
// Tab State
|
||||
String _activeTab = 'text'; // 'text' or 'video'
|
||||
bool _isPlaying = false;
|
||||
bool _hasGeneratedVideo = false;
|
||||
bool _isLoadingVideo = false;
|
||||
|
||||
// Mock Content from HTML
|
||||
final Map<String, dynamic> _defaultStory = {
|
||||
'title': "星际忍者的茶话会",
|
||||
'content': """
|
||||
在遥远的银河系边缘,有一个被星云包裹的神秘茶馆。今天,这里迎来了两位特殊的客人:刚执行完火星探测任务的宇航员波波,和正在追捕暗影怪兽的忍者小次郎。
|
||||
|
||||
“这儿的重力好像有点不对劲?”波波飘在半空中,试图抓住飞来飞去的茶杯。小次郎则冷静地倒挂在天花板上,手里紧握着一枚手里剑——其实那是用来切月饼的。
|
||||
|
||||
突然,桌上的魔法茶壶“噗”地一声喷出了七彩烟雾,一只会说话的卡皮巴拉钻了出来:“别打架,别打架,喝了这杯‘银河气泡茶’,我们都是好朋友!”
|
||||
|
||||
于是,宇宙中最奇怪的组合诞生了。他们决定,下一站,去黑洞边缘钓星星。
|
||||
""",
|
||||
};
|
||||
|
||||
Map<String, dynamic> get _currentStory => widget.story ?? _defaultStory;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Logic from HTML: if mode is read, we might start with text.
|
||||
// HTML defaults to text tab.
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.storyBackground, // #FDF9F3
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// Header
|
||||
_buildHeader(),
|
||||
|
||||
// Tab Switcher (Visible if video generated or implies interactability)
|
||||
// HTML hides it initially (`style="display:none;"`), shows when generating.
|
||||
if (_hasGeneratedVideo || _isLoadingVideo) _buildTabSwitcher(),
|
||||
|
||||
// Content Card (Scrollable)
|
||||
Expanded(child: _buildContentCard()),
|
||||
|
||||
// Footer
|
||||
_buildFooter(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new, size: 20),
|
||||
color: const Color(0xFF4B5563),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_currentStory['title'],
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700, // HTML: 700
|
||||
color: AppColors.storyTitle, // #4B2404
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTabSwitcher() {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildTabBtn('📄 故事', 'text'),
|
||||
const SizedBox(width: 8),
|
||||
_buildTabBtn('🎬 绘本', 'video'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTabBtn(String label, String key) {
|
||||
bool isActive = _activeTab == key;
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_activeTab = key;
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isActive ? Colors.white : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: isActive
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isActive ? AppColors.storyTitle : AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContentCard() {
|
||||
// HTML: .story-paper
|
||||
bool isVideoMode = _activeTab == 'video';
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.fromLTRB(
|
||||
24,
|
||||
0,
|
||||
24,
|
||||
10,
|
||||
), // HTML: 0 24px 110px padding on parent, paper fills flex
|
||||
decoration: isVideoMode
|
||||
? null
|
||||
: BoxDecoration(
|
||||
color: Colors.white.withOpacity(
|
||||
0.6,
|
||||
), // HTML says transparent for video, what about text? It implies simple text flow in HTML... wait.
|
||||
// HTML: .story-paper has margin-bottom 10px. Scrollbar none.
|
||||
// Actually HTML doesn't explicitly set white background on .story-paper unless implies by default?
|
||||
// Ah, looking closely at styles.css or html structure:
|
||||
// .story-paper { flex: 1; overflow-y: auto ... }
|
||||
// .story-content { font-size: 16px ... }
|
||||
// It seems the background is just the page background #FDF9F3.
|
||||
// But in `story_detail_page.dart` (original), it had white card.
|
||||
// HTML PRD: `body { background: #FDF9F3; }`. .story-paper doesn't have background color set, so it's transparent?
|
||||
// Let's assume transparent to match "Paper" feel being part of background or if existing Flutter impl used white card, user might prefer that.
|
||||
// BUT strict 1:1 implies following HTML. HTML has NO white card background on .story-paper.
|
||||
// So I will remove the white background container.
|
||||
),
|
||||
child: isVideoMode ? _buildVideoView() : _buildTextView(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTextView() {
|
||||
return SingleChildScrollView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
child: Text(
|
||||
_currentStory['content']
|
||||
.toString()
|
||||
.replaceAll(RegExp(r'\n+'), '\n\n')
|
||||
.trim(), // Simple paragraph spacing
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16, // HTML: 16px
|
||||
height: 2.0, // HTML: line-height 2.0
|
||||
color: AppColors.storyText, // #374151
|
||||
),
|
||||
textAlign: TextAlign.justify,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildVideoView() {
|
||||
if (_isLoadingVideo) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: CircularProgressIndicator(
|
||||
color: Color(0xFFF43F5E), // HTML: #F43F5E
|
||||
strokeWidth: 3,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'AI 正在绘制动态绘本...',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF4B5563),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'消耗 10 SP',
|
||||
style: TextStyle(fontSize: 12, color: Color(0xFF9CA3AF)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 16 / 9, // Assume landscape video
|
||||
child: Container(
|
||||
color: Colors.black,
|
||||
child: const Center(
|
||||
child: Icon(Icons.videocam, color: Colors.white54, size: 48),
|
||||
), // Placeholder for Video Player
|
||||
),
|
||||
),
|
||||
// Play Button Overlay
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.8),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.play_arrow, color: Colors.black),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFooter() {
|
||||
// HTML: .generator-footer { padding: 0 24px 30px; ... } (Inferred from container padding bottom 110px? No, fixed to bottom?)
|
||||
// Actually HTML has .generator-footer inside body? No, .result-container has padding-bottom 110px?
|
||||
// Let's stick to a fixed bottom container.
|
||||
|
||||
return Container(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
24,
|
||||
0,
|
||||
24,
|
||||
MediaQuery.of(context).padding.bottom + 20,
|
||||
),
|
||||
// HTML footer is customized per mode.
|
||||
child: _activeTab == 'text' ? _buildTextFooter() : _buildVideoFooter(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTextFooter() {
|
||||
if (widget.mode == StoryMode.generated) {
|
||||
// Generator Mode: Rewrite + Save
|
||||
return Row(
|
||||
children: [
|
||||
// Rewrite (Secondary)
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: const Color(0xFFE5E7EB)),
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
color: Colors.white.withOpacity(0.8),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const Text(
|
||||
'↻ 重写',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF4B5563),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Save (Primary) - Returns 'saved' to trigger add book animation
|
||||
Expanded(
|
||||
child: GradientButton(
|
||||
text: '保存故事',
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop('saved');
|
||||
},
|
||||
gradient: const LinearGradient(
|
||||
colors: AppColors.btnCapybaraGradient,
|
||||
),
|
||||
height: 50,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
// Read Mode: TTS + Make Picture Book
|
||||
return Row(
|
||||
children: [
|
||||
// TTS
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => setState(() => _isPlaying = !_isPlaying),
|
||||
child: Container(
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: const Color(0xFFE5E7EB)),
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
color: Colors.white.withOpacity(0.8),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
_isPlaying ? Icons.pause : Icons.headphones,
|
||||
size: 20,
|
||||
color: const Color(0xFF4B5563),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
_isPlaying ? '暂停' : '朗读',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF4B5563),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Make Picture Book
|
||||
Expanded(
|
||||
child: GradientButton(
|
||||
text: '变绘本',
|
||||
onPressed: _startVideoGeneration, // Simulate logic
|
||||
gradient: const LinearGradient(
|
||||
colors: AppColors.btnCapybaraGradient,
|
||||
),
|
||||
height: 50,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildVideoFooter() {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GradientButton(
|
||||
text: '↻ 重新生成',
|
||||
onPressed: _startVideoGeneration,
|
||||
gradient: const LinearGradient(
|
||||
colors: AppColors.btnCapybaraGradient,
|
||||
),
|
||||
height: 50,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _startVideoGeneration() {
|
||||
setState(() {
|
||||
_isLoadingVideo = true;
|
||||
_activeTab = 'video';
|
||||
});
|
||||
// Mock delay
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoadingVideo = false;
|
||||
_hasGeneratedVideo = true;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
137
airhub_app/lib/pages/story_loading_page.dart
Normal file
@ -0,0 +1,137 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'story_detail_page.dart';
|
||||
|
||||
class StoryLoadingPage extends StatefulWidget {
|
||||
const StoryLoadingPage({super.key});
|
||||
|
||||
@override
|
||||
State<StoryLoadingPage> createState() => _StoryLoadingPageState();
|
||||
}
|
||||
|
||||
class _StoryLoadingPageState extends State<StoryLoadingPage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
double _progress = 0.0;
|
||||
String _loadingText = "构思故事中...";
|
||||
final List<Map<String, dynamic>> _milestones = [
|
||||
{'pct': 0.2, 'text': "正在收集灵感碎片..."},
|
||||
{'pct': 0.5, 'text': "正在往故事里撒魔法粉..."},
|
||||
{'pct': 0.8, 'text': "正在编制最后的魔法..."},
|
||||
{'pct': 0.98, 'text': "大功告成!"},
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_startLoading();
|
||||
}
|
||||
|
||||
void _startLoading() {
|
||||
// Total duration approx 3.5s (match Web 35ms * 100 steps)
|
||||
Timer.periodic(const Duration(milliseconds: 35), (timer) {
|
||||
if (!mounted) {
|
||||
timer.cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_progress += 0.01;
|
||||
// Check text updates
|
||||
for (var m in _milestones) {
|
||||
if ((_progress - m['pct'] as double).abs() < 0.01) {
|
||||
_loadingText = m['text'] as String;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (_progress >= 1.0) {
|
||||
timer.cancel();
|
||||
_navigateToDetail();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _navigateToDetail() async {
|
||||
// Use push instead of pushReplacement to properly return the result
|
||||
final result = await Navigator.of(context).push<String>(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const StoryDetailPage(
|
||||
mode: StoryMode.generated,
|
||||
story: {
|
||||
'title': '新生成的冒险',
|
||||
'content': '在遥远的未来,勇敢的宇航员发现了一个神秘的星球...\n(这是生成的示例故事内容)',
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Pass the result back to DeviceControlPage
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop(result);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFFDF9F3),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Image
|
||||
Image.asset(
|
||||
'assets/www/kapi_writing.png',
|
||||
width: 200,
|
||||
height: 200, // Approximate
|
||||
errorBuilder: (c, e, s) => const Icon(
|
||||
Icons.edit_note,
|
||||
size: 100,
|
||||
color: Color(0xFFD1D5DB),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Text - HTML: font-size 18px, color #4B2404 (dark brown)
|
||||
Text(
|
||||
_loadingText,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 18, // HTML: 18px
|
||||
color: Color(0xFF4B2404), // HTML: dark chocolate brown
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Progress Bar - HTML: height 12px, max-width 280px
|
||||
// Track: rgba(201,150,114,0.2), Fill: gradient #ECCFA8 to #C99672
|
||||
Container(
|
||||
width: 280, // HTML: max-width 280px
|
||||
height: 12, // HTML: height 12px
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFC99672).withOpacity(0.2), // Warm sand
|
||||
borderRadius: BorderRadius.circular(6), // HTML: 6px
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: FractionallySizedBox(
|
||||
alignment: Alignment.centerLeft,
|
||||
widthFactor: _progress.clamp(0.0, 1.0),
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
// HTML: gradient #ECCFA8 to #C99672
|
||||
gradient: LinearGradient(
|
||||
colors: [Color(0xFFECCFA8), Color(0xFFC99672)],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
109
airhub_app/lib/pages/webview_page.dart
Normal file
@ -0,0 +1,109 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
import 'package:webview_flutter_android/webview_flutter_android.dart';
|
||||
import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart';
|
||||
|
||||
class WebViewPage extends StatefulWidget {
|
||||
const WebViewPage({super.key});
|
||||
|
||||
@override
|
||||
State<WebViewPage> createState() => _WebViewPageState();
|
||||
}
|
||||
|
||||
class _WebViewPageState extends State<WebViewPage> {
|
||||
late final WebViewController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
late final PlatformWebViewControllerCreationParams params;
|
||||
if (WebViewPlatform.instance is WebKitWebViewPlatform) {
|
||||
params = WebKitWebViewControllerCreationParams(
|
||||
allowsInlineMediaPlayback: true,
|
||||
mediaTypesRequiringUserAction: const <PlaybackMediaTypes>{},
|
||||
);
|
||||
} else {
|
||||
params = const PlatformWebViewControllerCreationParams();
|
||||
}
|
||||
|
||||
final WebViewController controller =
|
||||
WebViewController.fromPlatformCreationParams(params);
|
||||
|
||||
controller
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..setBackgroundColor(const Color(0x00000000))
|
||||
..setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onProgress: (int progress) {
|
||||
debugPrint('WebView is loading (progress : $progress%)');
|
||||
},
|
||||
onPageStarted: (String url) {
|
||||
debugPrint('Page started loading: $url');
|
||||
},
|
||||
onPageFinished: (String url) {
|
||||
debugPrint('Page finished loading: $url');
|
||||
},
|
||||
onWebResourceError: (WebResourceError error) {
|
||||
debugPrint('''
|
||||
Page resource error:
|
||||
code: ${error.errorCode}
|
||||
description: ${error.description}
|
||||
errorType: ${error.errorType}
|
||||
isForMainFrame: ${error.isForMainFrame}
|
||||
''');
|
||||
},
|
||||
onNavigationRequest: (NavigationRequest request) {
|
||||
if (request.url.contains('bluetooth.html')) {
|
||||
// Intercept bluetooth.html and navigate to native BluetoothPage
|
||||
debugPrint(
|
||||
'Intercepting navigation to bluetooth.html -> Native Route',
|
||||
);
|
||||
// We need context to navigate, but initState doesn't have it easily available
|
||||
// inside this callback unless we store a reference or use a GlobalKey.
|
||||
// However, since we are in a State object, we can use 'context' if mounted?
|
||||
// Actually, NavigationDelegate callbacks are not bound to context directly.
|
||||
// We should probably move the controller creation or use a helper.
|
||||
// BUT, since this is a callback, 'context' of the State is available in the closure!
|
||||
|
||||
// Warning: don't use 'context' across async gaps without checking mounted.
|
||||
// Since this is synchronous, it should be fine to schedule a navigation.
|
||||
|
||||
// We must return NavigationDecision.prevent to stop WebView.
|
||||
// And execute navigation asynchronously to avoid blocking.
|
||||
Future.microtask(() {
|
||||
if (mounted) {
|
||||
Navigator.of(context).pushNamed('/bluetooth');
|
||||
}
|
||||
});
|
||||
return NavigationDecision.prevent;
|
||||
}
|
||||
return NavigationDecision.navigate;
|
||||
},
|
||||
),
|
||||
)
|
||||
..loadFlutterAsset(
|
||||
'assets/www/index.html',
|
||||
); // CHANGED: Load Home directly
|
||||
|
||||
if (controller.platform is AndroidWebViewController) {
|
||||
AndroidWebViewController.enableDebugging(true);
|
||||
(controller.platform as AndroidWebViewController)
|
||||
.setMediaPlaybackRequiresUserGesture(false);
|
||||
}
|
||||
|
||||
_controller = controller;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// We want the WebView to control the full screen, including status bar usually,
|
||||
// but SafeArea might be needed if the Web content doesn't handle padding.
|
||||
// Our CSS handles env(safe-area-inset-top), so we can disable SafeArea here
|
||||
// or keep top:false.
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: WebViewWidget(controller: _controller),
|
||||
);
|
||||
}
|
||||
}
|
||||
662
airhub_app/lib/pages/wifi_config_page.dart
Normal file
@ -0,0 +1,662 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import '../widgets/gradient_button.dart';
|
||||
|
||||
class WifiConfigPage extends StatefulWidget {
|
||||
const WifiConfigPage({super.key});
|
||||
|
||||
@override
|
||||
State<WifiConfigPage> createState() => _WifiConfigPageState();
|
||||
}
|
||||
|
||||
class _WifiConfigPageState extends State<WifiConfigPage>
|
||||
with TickerProviderStateMixin {
|
||||
int _currentStep = 1;
|
||||
String _selectedWifiSsid = '';
|
||||
final TextEditingController _passwordController = TextEditingController();
|
||||
|
||||
// Progress State
|
||||
double _progress = 0.0;
|
||||
String _progressText = '正在连接WiFi...';
|
||||
|
||||
// Device Info (Mock or from Route Args)
|
||||
// We'll try to get it from arguments, default to a fallback
|
||||
Map<String, dynamic> _deviceInfo = {};
|
||||
|
||||
// Mock WiFi List
|
||||
final List<Map<String, dynamic>> _wifiList = [
|
||||
{'ssid': 'Home_5G', 'level': 4},
|
||||
{'ssid': 'Office_WiFi', 'level': 3},
|
||||
{'ssid': 'Guest_Network', 'level': 2},
|
||||
];
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
// Retrieve device info from arguments
|
||||
final args = ModalRoute.of(context)?.settings.arguments;
|
||||
if (args is Map<String, dynamic>) {
|
||||
_deviceInfo = args;
|
||||
}
|
||||
}
|
||||
|
||||
void _handleNext() {
|
||||
if (_currentStep == 1 && _selectedWifiSsid.isEmpty) return;
|
||||
if (_currentStep == 2 && _passwordController.text.isEmpty) return;
|
||||
|
||||
if (_currentStep == 4) {
|
||||
// Navigate to Device Control
|
||||
// Use pushNamedAndRemoveUntil to remove Bluetooth and WiFi pages from stack
|
||||
// but keep Home page so back button goes to Home
|
||||
Navigator.of(context).pushNamedAndRemoveUntil(
|
||||
'/device-control',
|
||||
ModalRoute.withName('/home'),
|
||||
arguments: _deviceInfo,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_currentStep++;
|
||||
});
|
||||
|
||||
if (_currentStep == 3) {
|
||||
_startConnecting();
|
||||
}
|
||||
}
|
||||
|
||||
void _handleBack() {
|
||||
if (_currentStep > 1) {
|
||||
setState(() {
|
||||
_currentStep--;
|
||||
});
|
||||
} else {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
|
||||
void _startConnecting() {
|
||||
const steps = [
|
||||
{'progress': 0.3, 'text': '正在连接WiFi...'},
|
||||
{'progress': 0.6, 'text': '正在验证密码...'},
|
||||
{'progress': 0.9, 'text': '正在同步设备...'},
|
||||
{'progress': 1.0, 'text': '完成!'},
|
||||
];
|
||||
|
||||
int stepIndex = 0;
|
||||
Timer.periodic(const Duration(milliseconds: 800), (timer) {
|
||||
if (stepIndex < steps.length) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_progress = steps[stepIndex]['progress'] as double;
|
||||
_progressText = steps[stepIndex]['text'] as String;
|
||||
});
|
||||
}
|
||||
stepIndex++;
|
||||
} else {
|
||||
timer.cancel();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_currentStep = 4;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
resizeToAvoidBottomInset: true,
|
||||
body: Stack(
|
||||
children: [
|
||||
// Background
|
||||
_buildGradientBackground(),
|
||||
|
||||
Positioned.fill(
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// Header
|
||||
_buildHeader(),
|
||||
|
||||
// Content
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Column(
|
||||
children: [
|
||||
// Steps Indicator
|
||||
_buildStepIndicator(),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Dynamic Step Content
|
||||
_buildCurrentStepContent(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Footer
|
||||
_buildFooter(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Common Gradient Background
|
||||
Widget _buildGradientBackground() {
|
||||
final size = MediaQuery.of(context).size;
|
||||
return Positioned.fill(
|
||||
child: Stack(
|
||||
children: [
|
||||
// Layer 1
|
||||
Positioned(
|
||||
bottom: -size.width * 0.5,
|
||||
left: -size.width * 0.5,
|
||||
width: size.width * 2,
|
||||
height: size.width * 2,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
const Color(0xFFFFC8DC).withOpacity(0.6),
|
||||
Colors.transparent,
|
||||
],
|
||||
radius: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Layer 2
|
||||
Positioned(
|
||||
top: -size.width * 0.5,
|
||||
right: -size.width * 0.5,
|
||||
width: size.width * 2,
|
||||
height: size.width * 2,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
const Color(0xFFB4F0F0).withOpacity(0.5),
|
||||
Colors.transparent,
|
||||
],
|
||||
radius: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
// Back button - HTML: bg rgba(255,255,255,0.6), border-radius: 12px, color #4B5563
|
||||
GestureDetector(
|
||||
onTap: _handleBack,
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: Colors.white.withOpacity(0.6),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.arrow_back_ios_new,
|
||||
size: 18,
|
||||
color: Color(0xFF4B5563), // Gray per HTML, not purple
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'WiFi配网',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF1F2937),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 48), // Balance back button
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStepIndicator() {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: List.generate(4, (index) {
|
||||
int step = index + 1;
|
||||
bool isActive = step == _currentStep;
|
||||
bool isCompleted = step < _currentStep;
|
||||
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
width: isActive ? 24 : 8,
|
||||
height: 8,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: isCompleted
|
||||
? const Color(0xFF22C55E) // Green for completed
|
||||
: isActive
|
||||
? const Color(0xFF8B5CF6) // Purple for active
|
||||
: const Color(0xFF8B5CF6).withOpacity(0.3), // Faded purple
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCurrentStepContent() {
|
||||
switch (_currentStep) {
|
||||
case 1:
|
||||
return _buildStep1();
|
||||
case 2:
|
||||
return _buildStep2();
|
||||
case 3:
|
||||
return _buildStep3();
|
||||
case 4:
|
||||
return _buildStep4();
|
||||
default:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
// Step 1: Select Network
|
||||
Widget _buildStep1() {
|
||||
return Column(
|
||||
children: [
|
||||
// Icon
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 24),
|
||||
child: const Icon(Icons.wifi, size: 80, color: Color(0xFF8B5CF6)),
|
||||
),
|
||||
const Text(
|
||||
'选择WiFi网络',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF1F2937),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'设备需要连接WiFi以使用AI功能',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: Color(0xFF6B7280),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// List
|
||||
Column(
|
||||
children: _wifiList.map((wifi) => _buildWifiItem(wifi)).toList(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWifiItem(Map<String, dynamic> wifi) {
|
||||
bool isSelected = _selectedWifiSsid == wifi['ssid'];
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
setState(() => _selectedWifiSsid = wifi['ssid']);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.8),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? const Color(0xFF8B5CF6)
|
||||
: Colors.white.withOpacity(0.5),
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
boxShadow: isSelected
|
||||
? [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF8B5CF6).withOpacity(0.2),
|
||||
blurRadius: 0,
|
||||
spreadRadius: 2,
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
wifi['ssid'],
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF1F2937),
|
||||
),
|
||||
),
|
||||
),
|
||||
// HTML uses per-level SVG icons: wifi-1.svg to wifi-4.svg
|
||||
Opacity(
|
||||
opacity: 0.8,
|
||||
child: SvgPicture.asset(
|
||||
'assets/www/icons/wifi-${wifi['level']}.svg',
|
||||
width: 24,
|
||||
height: 24,
|
||||
colorFilter: const ColorFilter.mode(
|
||||
Color(0xFF6B7280),
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Step 2: Enter Password
|
||||
Widget _buildStep2() {
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 24),
|
||||
child: const Icon(
|
||||
Icons.lock_outline,
|
||||
size: 80,
|
||||
color: Color(0xFF8B5CF6),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_selectedWifiSsid,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: const Color(0xFF1F2937),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'请输入WiFi密码',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: const Color(0xFF6B7280),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
TextField(
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: InputDecoration(
|
||||
hintText: '输入密码',
|
||||
filled: true,
|
||||
fillColor: Colors.white.withOpacity(0.8),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.all(20),
|
||||
),
|
||||
style: TextStyle(fontFamily: 'Inter', fontSize: 16),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Step 3: Connecting
|
||||
Widget _buildStep3() {
|
||||
return Column(
|
||||
children: [
|
||||
// Animation placeholder (using Icon for now, can be upgraded to Wave animation)
|
||||
SizedBox(
|
||||
height: 120,
|
||||
child: Center(
|
||||
child: TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 0, end: 1),
|
||||
duration: const Duration(seconds: 1),
|
||||
builder: (context, value, child) {
|
||||
return Icon(
|
||||
Icons.wifi_tethering,
|
||||
size: 80 + (value * 10),
|
||||
color: const Color(0xFF8B5CF6).withOpacity(1 - value * 0.5),
|
||||
);
|
||||
},
|
||||
onEnd:
|
||||
() {}, // Repeat logic usually handled by AnimationController
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'正在配网...',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: const Color(0xFF1F2937),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Progress Bar
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
child: SizedBox(
|
||||
height: 6,
|
||||
child: LinearProgressIndicator(
|
||||
value: _progress,
|
||||
backgroundColor: const Color(0xFF8B5CF6).withOpacity(0.2),
|
||||
valueColor: const AlwaysStoppedAnimation(Color(0xFF8B5CF6)),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_progressText,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: const Color(0xFF6B7280),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Get device icon path based on device type
|
||||
String _getDeviceIconPath() {
|
||||
final type = _deviceInfo['type'] as String? ?? 'plush';
|
||||
switch (type) {
|
||||
case 'plush_core':
|
||||
case 'plush':
|
||||
return 'assets/www/icons/pixel-capybara.svg';
|
||||
case 'badge_ai':
|
||||
return 'assets/www/icons/pixel-badge-ai.svg';
|
||||
case 'badge_basic':
|
||||
case 'badge':
|
||||
return 'assets/www/icons/pixel-badge-basic.svg';
|
||||
default:
|
||||
return 'assets/www/icons/pixel-capybara.svg';
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Result (Success)
|
||||
Widget _buildStep4() {
|
||||
return Column(
|
||||
children: [
|
||||
// Success Icon Stack - HTML: no white background
|
||||
Stack(
|
||||
clipBehavior: Clip.none,
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
// Device icon container - 120x120 per HTML
|
||||
SizedBox(
|
||||
width: 120,
|
||||
height: 120,
|
||||
child: SvgPicture.asset(
|
||||
_getDeviceIconPath(),
|
||||
width: 120,
|
||||
height: 120,
|
||||
placeholderBuilder: (_) => const Icon(
|
||||
Icons.smart_toy,
|
||||
size: 80,
|
||||
color: Color(0xFF8B5CF6),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Check badge
|
||||
Positioned(
|
||||
bottom: -5,
|
||||
right: -5,
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF22C55E),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 3),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF22C55E).withOpacity(0.3),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(Icons.check, color: Colors.white, size: 18),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'配网成功!',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: const Color(0xFF1F2937),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'设备已成功连接到网络',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: const Color(0xFF6B7280),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFooter() {
|
||||
bool showNext = false;
|
||||
String nextText = '下一步';
|
||||
|
||||
if (_currentStep == 1 && _selectedWifiSsid.isNotEmpty) showNext = true;
|
||||
if (_currentStep == 2 && _passwordController.text.isNotEmpty) {
|
||||
showNext = true;
|
||||
nextText = '连接';
|
||||
}
|
||||
if (_currentStep == 4) {
|
||||
showNext = true;
|
||||
nextText = '进入设备';
|
||||
}
|
||||
|
||||
if (!showNext && _currentStep != 3) {
|
||||
// Show cancel only?
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(
|
||||
'取消',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
color: const Color(0xFF6B7280),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_currentStep == 3)
|
||||
return const SizedBox(height: 100); // Hide buttons during connection
|
||||
|
||||
return Container(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
20, // HTML: 20px sides
|
||||
20,
|
||||
20,
|
||||
MediaQuery.of(context).padding.bottom + 60, // HTML: safe-area + 60px
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Cancel button - HTML: frosted glass with border
|
||||
if (_currentStep < 4)
|
||||
GestureDetector(
|
||||
onTap: _handleBack,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 32,
|
||||
vertical: 14,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.8),
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
border: Border.all(color: const Color(0xFFE5E7EB)),
|
||||
),
|
||||
child: const Text(
|
||||
'取消',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF6B7280),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_currentStep < 4) const SizedBox(width: 16), // HTML: gap 16px
|
||||
|
||||
Expanded(
|
||||
child: GradientButton(
|
||||
text: nextText,
|
||||
onPressed: _handleNext,
|
||||
height: 56,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
110
airhub_app/lib/theme/app_colors.dart
Normal file
@ -0,0 +1,110 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppColors {
|
||||
// Gradient Colors (Backgrounds)
|
||||
static const Color gradientPink = Color(0xFFFEF0F5);
|
||||
static const Color gradientLavender = Color(0xFFF5F0FE);
|
||||
static const Color gradientBlue = Color(0xFFEEF8FC);
|
||||
static const Color gradientMint = Color(0xFFF0FCFA);
|
||||
|
||||
// Primary Colors
|
||||
static const Color primaryPurple = Color(0xFFA78BFA);
|
||||
static const Color primaryBlue = Color(0xFF93C5FD);
|
||||
static const Color primaryPink = Color(0xFFF9A8D4);
|
||||
static const Color primaryIndigo = Color(0xFF6366F1);
|
||||
|
||||
// Capybara Theme Colors
|
||||
static const Color capybaraSand = Color(0xFFFDF9F3);
|
||||
static const Color capybaraBrown = Color(0xFF4B2404);
|
||||
static const Color capybaraWarmGrey = Color(0xFF4B5563);
|
||||
static const Color capybaraAmber = Color(0xFFEA9A3E); // Selected state ring color
|
||||
static const Color capybaraSelectedBg = Color(0xFFFFF7ED); // Selected card background
|
||||
static const Color capybaraPlushLight = Color(0xFFECCFA8); // Plush gradient start
|
||||
static const Color capybaraPlushDark = Color(0xFFC99672); // Plush gradient end
|
||||
|
||||
// Additional Primary Colors from Button Gradient
|
||||
static const Color cyan = Color(0xFF22D3EE); // #22D3EE
|
||||
static const Color deepPurple = Color(0xFF8B5CF6); // #8B5CF6
|
||||
|
||||
// Text Colors
|
||||
static const Color textPrimary = Color(0xFF1F2937);
|
||||
static const Color textSecondary = Color(0xFF6B7280);
|
||||
static const Color textLight = Color(0xFF9CA3AF);
|
||||
|
||||
// Backgrounds
|
||||
static const Color bgBase = Color(0xFFFAFBFC);
|
||||
static const Color bgCard = Color(
|
||||
0xB3FFFFFF,
|
||||
); // rgba(255, 255, 255, 0.7) -> 0.7 * 255 = 178.5 -> B3
|
||||
|
||||
// Shadows
|
||||
static final BoxShadow shadowSoft = BoxShadow(
|
||||
color: Colors.black.withOpacity(0.04),
|
||||
offset: const Offset(0, 4),
|
||||
blurRadius: 24,
|
||||
);
|
||||
|
||||
static final BoxShadow shadowMedium = BoxShadow(
|
||||
color: Colors.black.withOpacity(0.08),
|
||||
offset: const Offset(0, 8),
|
||||
blurRadius: 32,
|
||||
);
|
||||
|
||||
static final BoxShadow shadowButton = BoxShadow(
|
||||
color: const Color(0xFFA78BFA).withOpacity(0.3),
|
||||
offset: const Offset(0, 8),
|
||||
blurRadius: 32,
|
||||
);
|
||||
|
||||
// Plush button shadows (warm brown glow for Capybara theme)
|
||||
static final List<BoxShadow> shadowPlushButton = [
|
||||
BoxShadow(
|
||||
color: const Color(0xFFC99672).withOpacity(0.35),
|
||||
offset: Offset.zero,
|
||||
blurRadius: 15,
|
||||
),
|
||||
BoxShadow(
|
||||
color: const Color(0xFFC99672).withOpacity(0.25),
|
||||
offset: Offset.zero,
|
||||
blurRadius: 30,
|
||||
),
|
||||
BoxShadow(
|
||||
color: const Color(0xFFC99672).withOpacity(0.4),
|
||||
offset: const Offset(0, 6),
|
||||
blurRadius: 20,
|
||||
),
|
||||
];
|
||||
|
||||
// Primary button shadows (purple/indigo glow)
|
||||
static final List<BoxShadow> shadowPrimaryButton = [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF6366F1).withOpacity(0.4),
|
||||
offset: const Offset(0, 4),
|
||||
blurRadius: 20,
|
||||
),
|
||||
BoxShadow(
|
||||
color: const Color(0xFF8B5CF6).withOpacity(0.2),
|
||||
offset: Offset.zero,
|
||||
blurRadius: 40,
|
||||
),
|
||||
];
|
||||
|
||||
// Gradients
|
||||
static const LinearGradient btnPrimaryGradient = LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: [
|
||||
Color(0xFF22D3EE),
|
||||
Color(0xFF3B82F6),
|
||||
Color(0xFF6366F1),
|
||||
Color(0xFF8B5CF6),
|
||||
],
|
||||
stops: [0.0, 0.35, 0.65, 1.0],
|
||||
);
|
||||
|
||||
static const LinearGradient btnPlushGradient = LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFFECCFA8), Color(0xFFC99672)],
|
||||
);
|
||||
}
|
||||
71
airhub_app/lib/theme/app_theme.dart
Normal file
@ -0,0 +1,71 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'app_colors.dart';
|
||||
|
||||
class AppTheme {
|
||||
static ThemeData get lightTheme {
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
scaffoldBackgroundColor: AppColors.bgBase,
|
||||
primaryColor: AppColors.primaryIndigo,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: AppColors.primaryIndigo,
|
||||
primary: AppColors.primaryIndigo,
|
||||
secondary: AppColors.primaryPurple,
|
||||
surface: AppColors.bgBase,
|
||||
background: AppColors.bgBase,
|
||||
),
|
||||
// We will rely on system fonts for now, to replicate 'Inter' look
|
||||
// we can adjust weights later.
|
||||
fontFamilyFallback: const [
|
||||
'Inter',
|
||||
'Roboto',
|
||||
'PingFang SC',
|
||||
'Helvetica Neue',
|
||||
],
|
||||
|
||||
textTheme: const TextTheme(
|
||||
// h1 / Large Headings
|
||||
displayLarge: TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.w700, // Bold
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
// h2 / Subheadings
|
||||
displayMedium: TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w600, // Semi-bold
|
||||
),
|
||||
// Body Text
|
||||
bodyLarge: TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w400, // Normal
|
||||
height: 1.5,
|
||||
),
|
||||
bodyMedium: TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
// Small captions
|
||||
bodySmall: TextStyle(color: AppColors.textLight, fontSize: 12),
|
||||
// Button Text
|
||||
labelLarge: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Animation Curves from CSS
|
||||
// --ease-smooth: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
static const Curve easeSmooth = Cubic(0.4, 0, 0.2, 1);
|
||||
|
||||
// --ease-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
static const Curve easeBounce = Cubic(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
276
airhub_app/lib/theme/design_tokens.dart
Normal file
@ -0,0 +1,276 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 颜色定义 - 精确还原 Profile PRD
|
||||
class AppColors {
|
||||
// 文字颜色
|
||||
static const Color textPrimary = Color(0xFF1F2937);
|
||||
static const Color textSecondary = Color(0xFF9CA3AF);
|
||||
static const Color textHint = Color(0xFFD1D5DB); // Arrow icon color
|
||||
|
||||
// 背景颜色
|
||||
static const Color background = Color(0xFFFAFBFC);
|
||||
static const Color cardSurface = Color(
|
||||
0xCCFFFFFF,
|
||||
); // rgba(255, 255, 255, 0.8)
|
||||
|
||||
// Story Page
|
||||
static const Color storyBackground = Color(0xFFFDF9F3);
|
||||
static const Color storyTitle = Color(0xFF4B2404);
|
||||
static const Color storyText = Color(0xFF374151);
|
||||
|
||||
// Bookshelf (Story Book) - CSS .story-book, .story-slot
|
||||
static const Color bookshelfBg = Color(
|
||||
0x8CFFFFFF,
|
||||
); // rgba(255, 255, 255, 0.55)
|
||||
static const Color bookshelfBorder = Color(
|
||||
0x99FFFFFF,
|
||||
); // rgba(255, 255, 255, 0.6)
|
||||
static const Color bookCountBg = Color(
|
||||
0x80FFFFFF,
|
||||
); // rgba(255, 255, 255, 0.5)
|
||||
static const Color slotBg = Color(0x99FFFFFF); // rgba(255, 255, 255, 0.6)
|
||||
static const Color slotClickableBg = Color(
|
||||
0x66FFFFFF,
|
||||
); // rgba(255, 255, 255, 0.4)
|
||||
static const Color slotBorder = Color(0x0D000000); // rgba(0, 0, 0, 0.05)
|
||||
static const Color slotTitleBarBg = Color(0x99000000); // rgba(0, 0, 0, 0.6)
|
||||
static const Color slotFilledShadow = Color(0x1A000000); // rgba(0, 0, 0, 0.1)
|
||||
static const Color emptyPlusColor = Color(0xFF9CA3AF);
|
||||
static const Color bookTitleColor = Color(0xFF1F2937);
|
||||
static const Color bookCountColor = Color(0xFF6B7280);
|
||||
|
||||
// 状态颜色
|
||||
static const Color notificationDot = Color(0xFFEF4444);
|
||||
static const Color badgeNew = Color(0xFFEF4444);
|
||||
|
||||
// 按钮/交互
|
||||
static const Color iconBtnBg = Color(0x40FFFFFF); // rgba(255, 255, 255, 0.25)
|
||||
static const Color iconBtnBorder = Color(
|
||||
0x66FFFFFF,
|
||||
); // rgba(255, 255, 255, 0.4)
|
||||
|
||||
// 危险操作
|
||||
static const Color danger = Color(0xFFEF4444);
|
||||
|
||||
// 表单 & 列表
|
||||
static const Color formLabel = Color(0xFF6B7280);
|
||||
static const Color sectionTitle = Color(0xFF9CA3AF);
|
||||
static const Color divider = Color(0x0D000000); // rgba(0, 0, 0, 0.05)
|
||||
|
||||
// 渐变色 (Avatar & Buttons)
|
||||
static const List<Color> avatarGradient = [
|
||||
Color(0xFFECCFA8),
|
||||
Color(0xFFC99672),
|
||||
];
|
||||
static const List<Color> saveBtnGradient = [
|
||||
Color(0xFFECCFA8),
|
||||
Color(0xFFC99672),
|
||||
];
|
||||
static const List<Color> btnCapybaraGradient = [
|
||||
Color(0xFFECCFA8),
|
||||
Color(0xFFC99672),
|
||||
];
|
||||
|
||||
// 产品卡片渐变
|
||||
static const List<Color> gradientCapybara = [
|
||||
Color(0xFFE6B98D),
|
||||
Color(0xFFE8C9A8),
|
||||
Color(0xFFD4A373),
|
||||
Color(0xFFB07D5A),
|
||||
];
|
||||
static const List<Color> gradientBadgeAI = [
|
||||
Color(0xFF22D3EE),
|
||||
Color(0xFF60A5FA),
|
||||
Color(0xFF818CF8),
|
||||
Color(0xFFA78BFA),
|
||||
];
|
||||
static const List<Color> gradientBadgeBasic = [
|
||||
Color(0xFFC084FC),
|
||||
Color(0xFFD8B4FE),
|
||||
Color(0xFFC4B5FD),
|
||||
Color(0xFFA78BFA),
|
||||
];
|
||||
static const List<Color> gradientBracelet = [
|
||||
Color(0xFFFDBA74),
|
||||
Color(0xFFFB923C),
|
||||
Color(0xFFFBAF85),
|
||||
Color(0xFFE07B54),
|
||||
];
|
||||
static const List<Color> gradientVSinger = [
|
||||
Color(0xFF34D399),
|
||||
Color(0xFF5EEAD4),
|
||||
Color(0xFF22D3EE),
|
||||
Color(0xFF2DD4BF),
|
||||
];
|
||||
|
||||
// 遮罩
|
||||
static const Color overlay = Color(0x80000000); // implied for modal overlay
|
||||
}
|
||||
|
||||
/// 字体样式 - Inter
|
||||
class AppTextStyles {
|
||||
// Profile Title
|
||||
static const TextStyle title = TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
);
|
||||
|
||||
// User Name
|
||||
static const TextStyle userName = TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
);
|
||||
|
||||
// User ID
|
||||
static const TextStyle userId = TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: AppColors.textSecondary,
|
||||
);
|
||||
|
||||
// Menu Text
|
||||
static const TextStyle menuText = TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: AppColors.textPrimary,
|
||||
);
|
||||
|
||||
// Badge Text
|
||||
static const TextStyle badge = TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: Colors.white,
|
||||
);
|
||||
|
||||
// Modal Title
|
||||
static const TextStyle modalTitle = TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
);
|
||||
|
||||
// Book specific styles
|
||||
static const TextStyle bookTitle = TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
);
|
||||
|
||||
static const TextStyle bookCount = TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary,
|
||||
);
|
||||
|
||||
static const TextStyle slotTitle = TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 10,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w400,
|
||||
);
|
||||
|
||||
// PRD: font-size: 24px, color: #9CA3AF, font-weight: 300, opacity: 0.7
|
||||
static const TextStyle emptyPlus = TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w300,
|
||||
color: Color(0xB39CA3AF), // #9CA3AF with 0.7 opacity
|
||||
);
|
||||
|
||||
static const TextStyle createStoryBtn = TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
);
|
||||
}
|
||||
|
||||
/// 间距定义
|
||||
class AppSpacing {
|
||||
static const double xs = 4.0;
|
||||
static const double sm = 8.0;
|
||||
static const double md = 16.0;
|
||||
static const double lg = 20.0;
|
||||
static const double xl = 24.0;
|
||||
}
|
||||
|
||||
/// 圆角定义
|
||||
class AppRadius {
|
||||
static const double card = 20.0;
|
||||
static const double button = 22.0; // 44px height / 2
|
||||
static const double avatar = 32.0; // 64px size / 2
|
||||
static const double badge = 10.0;
|
||||
}
|
||||
|
||||
/// 阴影定义
|
||||
class AppShadows {
|
||||
static const BoxShadow card = BoxShadow(
|
||||
color: Color(0x148B5E3C), // rgba(139, 94, 60, 0.08)
|
||||
blurRadius: 20,
|
||||
offset: Offset(0, 4),
|
||||
);
|
||||
|
||||
static const BoxShadow btnCapybara = BoxShadow(
|
||||
color: Color(0x59C99672), // rgba(201, 150, 114, 0.35)
|
||||
blurRadius: 15,
|
||||
offset: Offset(0, 4),
|
||||
);
|
||||
|
||||
static const BoxShadow storyBook = BoxShadow(
|
||||
color: Color(0x08000000), // rgba(0,0,0,0.03)
|
||||
blurRadius: 40,
|
||||
offset: Offset(0, 10),
|
||||
);
|
||||
|
||||
static const BoxShadow storySlotFilled = BoxShadow(
|
||||
color: Color(0x1A000000), // rgba(0,0,0,0.1)
|
||||
blurRadius: 12,
|
||||
offset: Offset(0, 4),
|
||||
);
|
||||
|
||||
static const List<BoxShadow> createBtn = [
|
||||
BoxShadow(color: Color(0x59C99672), blurRadius: 15), // glow
|
||||
BoxShadow(color: Color(0x40C99672), blurRadius: 30), // outer glow
|
||||
BoxShadow(
|
||||
color: Color(0x66C99672),
|
||||
blurRadius: 20,
|
||||
offset: Offset(0, 6),
|
||||
), // depth
|
||||
];
|
||||
}
|
||||
|
||||
/// Story Book Spacing
|
||||
class StoryBookSpacing {
|
||||
static const double bookPadding = 24.0;
|
||||
static const double gridGap = 12.0;
|
||||
static const double bookCoverMarginBottom = 20.0;
|
||||
static const EdgeInsets bookCountPadding = EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
);
|
||||
static const EdgeInsets titleBarPadding = EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 4,
|
||||
);
|
||||
// PRD: padding: 16px 48px
|
||||
static const EdgeInsets createBtnPadding = EdgeInsets.symmetric(
|
||||
horizontal: 48, // PRD: 48px
|
||||
vertical: 16, // PRD: 16px
|
||||
);
|
||||
}
|
||||
|
||||
/// Story Book Radius
|
||||
class StoryBookRadius {
|
||||
static const double book = 24.0;
|
||||
static const double slot = 12.0;
|
||||
static const double bookCount = 12.0;
|
||||
static const double createBtn = 29.0;
|
||||
}
|
||||
87
airhub_app/lib/widgets/dashed_rect.dart
Normal file
@ -0,0 +1,87 @@
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class DashedRect extends StatelessWidget {
|
||||
final Color color;
|
||||
final double strokeWidth;
|
||||
final double gap;
|
||||
final Widget? child;
|
||||
final BorderRadius? borderRadius;
|
||||
|
||||
const DashedRect({
|
||||
super.key,
|
||||
this.color = Colors.black,
|
||||
this.strokeWidth = 1.0,
|
||||
this.gap = 5.0,
|
||||
this.child,
|
||||
this.borderRadius,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CustomPaint(
|
||||
painter: _DashedRectPainter(
|
||||
color: color,
|
||||
strokeWidth: strokeWidth,
|
||||
gap: gap,
|
||||
borderRadius: borderRadius ?? BorderRadius.zero,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DashedRectPainter extends CustomPainter {
|
||||
final Color color;
|
||||
final double strokeWidth;
|
||||
final double gap;
|
||||
final BorderRadius borderRadius;
|
||||
|
||||
_DashedRectPainter({
|
||||
required this.color,
|
||||
required this.strokeWidth,
|
||||
required this.gap,
|
||||
required this.borderRadius,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final Paint paint = Paint()
|
||||
..color = color
|
||||
..strokeWidth = strokeWidth
|
||||
..style = PaintingStyle.stroke;
|
||||
|
||||
final Path path = Path()
|
||||
..addRRect(
|
||||
RRect.fromRectAndCorners(
|
||||
Rect.fromLTWH(0, 0, size.width, size.height),
|
||||
topLeft: borderRadius.topLeft,
|
||||
topRight: borderRadius.topRight,
|
||||
bottomLeft: borderRadius.bottomLeft,
|
||||
bottomRight: borderRadius.bottomRight,
|
||||
),
|
||||
);
|
||||
|
||||
Path dashedPath = Path();
|
||||
for (PathMetric pathMetric in path.computeMetrics()) {
|
||||
double distance = 0.0;
|
||||
while (distance < pathMetric.length) {
|
||||
dashedPath.addPath(
|
||||
pathMetric.extractPath(distance, distance + gap),
|
||||
Offset.zero,
|
||||
);
|
||||
distance += gap * 2;
|
||||
}
|
||||
}
|
||||
|
||||
canvas.drawPath(dashedPath, paint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _DashedRectPainter oldDelegate) {
|
||||
return oldDelegate.color != color ||
|
||||
oldDelegate.strokeWidth != strokeWidth ||
|
||||
oldDelegate.gap != gap ||
|
||||
oldDelegate.borderRadius != borderRadius;
|
||||
}
|
||||
}
|
||||
106
airhub_app/lib/widgets/feedback_dialog.dart
Normal file
@ -0,0 +1,106 @@
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:airhub_app/theme/design_tokens.dart';
|
||||
|
||||
class FeedbackDialog extends StatelessWidget {
|
||||
const FeedbackDialog({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.9), // Glass effect
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: Colors.white.withOpacity(0.5)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Center(
|
||||
child: Text('意见反馈', style: AppTextStyles.modalTitle),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Container(
|
||||
height: 120,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF3F4F6),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const TextField(
|
||||
maxLines: null,
|
||||
decoration: InputDecoration(
|
||||
hintText: '请输入您的意见或建议...',
|
||||
border: InputBorder.none,
|
||||
hintStyle: TextStyle(
|
||||
color: Color(0xFF9CA3AF),
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
backgroundColor: const Color(0xFFF3F4F6),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'取消',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF6B7280),
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
onPressed: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('感谢您的反馈!')),
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
backgroundColor: const Color(0xFF1F2937),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'提交',
|
||||
style: TextStyle(color: Colors.white, fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
239
airhub_app/lib/widgets/glass_dialog.dart
Normal file
@ -0,0 +1,239 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class GlassDialog extends StatelessWidget {
|
||||
final String title;
|
||||
final String? description;
|
||||
final Widget? content; // For custom content like TextField
|
||||
final String cancelText;
|
||||
final String confirmText;
|
||||
final VoidCallback onCancel;
|
||||
final VoidCallback onConfirm;
|
||||
final bool
|
||||
isDanger; // If we need a specific danger style, though CSS shows Pink Gradient default
|
||||
|
||||
const GlassDialog({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.description,
|
||||
this.content,
|
||||
this.cancelText = '取消',
|
||||
this.confirmText = '确定',
|
||||
required this.onCancel,
|
||||
required this.onConfirm,
|
||||
this.isDanger = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 40),
|
||||
child: Container(
|
||||
// Clean white card style
|
||||
width: double.infinity,
|
||||
constraints: const BoxConstraints(maxWidth: 300),
|
||||
padding: const EdgeInsets.fromLTRB(24, 32, 24, 24),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
offset: const Offset(0, 10),
|
||||
blurRadius: 30,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Title
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF4B2404),
|
||||
height: 1.2,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Description
|
||||
if (description != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
child: Text(
|
||||
description!,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 15,
|
||||
color: Color(0xFF6B7280),
|
||||
height: 1.6,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
|
||||
// Custom Content
|
||||
if (content != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
child: content!,
|
||||
),
|
||||
|
||||
// Button (Confirm only design often used in modals)
|
||||
// But preserving Row for Cancel if needed, though PRD screenshot shows single "Confirm" style mostly.
|
||||
// Screenshot 1 (Help): Single "Confirm" button.
|
||||
// Screenshot 2 (Bind Phone): "Confirm" button.
|
||||
// Let's keep Row but make Confirm prominent.
|
||||
if (cancelText.isEmpty || onCancel == () {}) ...[
|
||||
// Single Button Layout
|
||||
GestureDetector(
|
||||
onTap: onConfirm,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFD4A373), // Matching Capybara tone
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFFE6B98D), Color(0xFFC99672)],
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFFC99672).withOpacity(0.4),
|
||||
offset: const Offset(0, 4),
|
||||
blurRadius: 10,
|
||||
),
|
||||
],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
confirmText,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF4B2404),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
// Two Buttons Layout
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: onCancel,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
cancelText,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF9CA3AF),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: onConfirm,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFFE6B98D), Color(0xFFC99672)],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFFC99672).withOpacity(0.3),
|
||||
offset: const Offset(0, 4),
|
||||
blurRadius: 10,
|
||||
),
|
||||
],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
confirmText,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF4B2404),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to show the dialog with animation scaling
|
||||
Future<T?> showGlassDialog<T>({
|
||||
required BuildContext context,
|
||||
required String title,
|
||||
String? description,
|
||||
Widget? content,
|
||||
String cancelText = '取消',
|
||||
String confirmText = '确定',
|
||||
required VoidCallback onConfirm,
|
||||
bool isDanger = false,
|
||||
}) {
|
||||
return showGeneralDialog<T>(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
barrierLabel: 'Dismiss',
|
||||
barrierColor: Colors.black.withOpacity(
|
||||
0.4,
|
||||
), // .modal-overlay background: rgba(0,0,0,0.4)
|
||||
// Actually modal-overlay in CSS might be defined in lines I didn't see.
|
||||
// Assuming standard dim.
|
||||
transitionDuration: const Duration(milliseconds: 300),
|
||||
pageBuilder: (context, anim1, anim2) {
|
||||
return GlassDialog(
|
||||
title: title,
|
||||
description: description,
|
||||
content: content,
|
||||
cancelText: cancelText,
|
||||
confirmText: confirmText,
|
||||
onCancel: () => Navigator.of(context).pop(),
|
||||
onConfirm: onConfirm,
|
||||
isDanger: isDanger,
|
||||
);
|
||||
},
|
||||
transitionBuilder: (context, anim1, anim2, child) {
|
||||
// CSS: transform: scale(0.9) -> scale(1)
|
||||
// cubic-bezier(0.175, 0.885, 0.32, 1.275)
|
||||
// Actually standard ScaleTransition with curve is easier
|
||||
return ScaleTransition(
|
||||
scale: Tween<double>(begin: 0.9, end: 1.0).animate(
|
||||
CurvedAnimation(
|
||||
parent: anim1,
|
||||
curve: const Cubic(0.175, 0.885, 0.32, 1.275),
|
||||
),
|
||||
),
|
||||
child: FadeTransition(opacity: anim1, child: child),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
151
airhub_app/lib/widgets/gradient_button.dart
Normal file
@ -0,0 +1,151 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
|
||||
class GradientButton extends StatelessWidget {
|
||||
final String text;
|
||||
final VoidCallback? onPressed;
|
||||
final double width;
|
||||
final double height;
|
||||
final bool isLoading;
|
||||
final Gradient? gradient;
|
||||
|
||||
const GradientButton({
|
||||
super.key,
|
||||
required this.text,
|
||||
this.onPressed,
|
||||
this.width = double.infinity,
|
||||
this.height = 50.0, // Changed from 56 to 50 to match CSS
|
||||
this.isLoading = false,
|
||||
this.gradient,
|
||||
});
|
||||
|
||||
// Check if using plush/capybara gradient
|
||||
bool get _isPlushGradient {
|
||||
if (gradient == null) return false;
|
||||
if (gradient is LinearGradient) {
|
||||
final lg = gradient as LinearGradient;
|
||||
// Check if colors match plush gradient colors
|
||||
if (lg.colors.length >= 2) {
|
||||
return lg.colors.first.value == 0xFFECCFA8 ||
|
||||
lg.colors.last.value == 0xFFC99672;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
List<BoxShadow> get _boxShadows {
|
||||
if (_isPlushGradient) {
|
||||
// Warm brown glow for Capybara plush gradient
|
||||
return [
|
||||
BoxShadow(
|
||||
color: const Color(0xFFC99672).withOpacity(0.35),
|
||||
offset: Offset.zero,
|
||||
blurRadius: 15,
|
||||
),
|
||||
BoxShadow(
|
||||
color: const Color(0xFFC99672).withOpacity(0.25),
|
||||
offset: Offset.zero,
|
||||
blurRadius: 30,
|
||||
),
|
||||
BoxShadow(
|
||||
color: const Color(0xFFC99672).withOpacity(0.4),
|
||||
offset: const Offset(0, 6),
|
||||
blurRadius: 20,
|
||||
),
|
||||
];
|
||||
} else {
|
||||
// Purple/indigo glow for primary gradient
|
||||
return [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF6366F1).withOpacity(0.4),
|
||||
offset: const Offset(0, 4),
|
||||
blurRadius: 20,
|
||||
),
|
||||
BoxShadow(
|
||||
color: const Color(0xFF8B5CF6).withOpacity(0.2),
|
||||
offset: Offset.zero,
|
||||
blurRadius: 40,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bool isDisabled = onPressed == null || isLoading;
|
||||
|
||||
return Opacity(
|
||||
opacity: isDisabled ? 0.7 : 1.0,
|
||||
child: Container(
|
||||
width: width,
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(height / 2),
|
||||
gradient: gradient ?? AppColors.btnPrimaryGradient,
|
||||
boxShadow: _boxShadows,
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
// Shine overlay (top half gradient)
|
||||
Positioned.fill(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(height / 2),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.white.withOpacity(0.15),
|
||||
Colors.transparent,
|
||||
],
|
||||
stops: const [0.0, 0.5],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Button content
|
||||
Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: isDisabled ? null : onPressed,
|
||||
borderRadius: BorderRadius.circular(height / 2),
|
||||
splashColor: Colors.white.withOpacity(0.2),
|
||||
highlightColor: Colors.white.withOpacity(0.1),
|
||||
child: Center(
|
||||
child: isLoading
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
strokeWidth: 2.5,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: _isPlushGradient ? 18 : 17,
|
||||
fontWeight:
|
||||
_isPlushGradient ? FontWeight.w700 : FontWeight.w600,
|
||||
color: Colors.white,
|
||||
shadows: const [
|
||||
Shadow(
|
||||
offset: Offset(0, 1),
|
||||
blurRadius: 2,
|
||||
color: Colors.black12,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
380
airhub_app/lib/widgets/story_generator_modal.dart
Normal file
@ -0,0 +1,380 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import 'gradient_button.dart';
|
||||
|
||||
class StoryGeneratorModal extends StatefulWidget {
|
||||
const StoryGeneratorModal({super.key});
|
||||
|
||||
@override
|
||||
State<StoryGeneratorModal> createState() => _StoryGeneratorModalState();
|
||||
}
|
||||
|
||||
class _StoryGeneratorModalState extends State<StoryGeneratorModal> {
|
||||
String _activeTab = 'characters';
|
||||
|
||||
// PRD: Complete story data with 28 items per category
|
||||
final Map<String, List<Map<String, String>>> _storyData = {
|
||||
'characters': [
|
||||
{'id': 'c1', 'name': '宇航员', 'icon': '🧑🚀'},
|
||||
{'id': 'c2', 'name': '忍者', 'icon': '🥷'},
|
||||
{'id': 'c3', 'name': '精灵', 'icon': '🧚'},
|
||||
{'id': 'c4', 'name': '小熊', 'icon': '🐻'},
|
||||
{'id': 'c5', 'name': '机器人', 'icon': '🤖'},
|
||||
{'id': 'c6', 'name': '公主', 'icon': '👸'},
|
||||
{'id': 'c7', 'name': '猫咪', 'icon': '🐱'},
|
||||
{'id': 'c8', 'name': '恐龙', 'icon': '🦖'},
|
||||
{'id': 'c9', 'name': '吸血鬼', 'icon': '🧛'},
|
||||
{'id': 'c10', 'name': '海盗', 'icon': '🏴☠️'},
|
||||
{'id': 'c11', 'name': '侦探', 'icon': '🕵️'},
|
||||
{'id': 'c12', 'name': '外星人', 'icon': '👽'},
|
||||
{'id': 'c13', 'name': '幽灵', 'icon': '👻'},
|
||||
{'id': 'c14', 'name': '骑士', 'icon': '🛡️'},
|
||||
{'id': 'c15', 'name': '超人', 'icon': '🦸'},
|
||||
{'id': 'c16', 'name': '僵尸', 'icon': '🧟'},
|
||||
{'id': 'c17', 'name': '美人鱼', 'icon': '🧜♀️'},
|
||||
{'id': 'c18', 'name': '巫师', 'icon': '🧙♂️'},
|
||||
{'id': 'c19', 'name': '小丑', 'icon': '🤡'},
|
||||
{'id': 'c20', 'name': '厨师', 'icon': '👨🍳'},
|
||||
{'id': 'c21', 'name': '医生', 'icon': '👨⚕️'},
|
||||
{'id': 'c22', 'name': '警察', 'icon': '👮'},
|
||||
{'id': 'c23', 'name': '消防员', 'icon': '👨🚒'},
|
||||
{'id': 'c24', 'name': '画家', 'icon': '🎨'},
|
||||
{'id': 'c25', 'name': '国王', 'icon': '🤴'},
|
||||
{'id': 'c26', 'name': '王后', 'icon': '👸'},
|
||||
{'id': 'c27', 'name': '兔子', 'icon': '🐰'},
|
||||
{'id': 'c28', 'name': '老虎', 'icon': '🐯'},
|
||||
],
|
||||
'scenes': [
|
||||
{'id': 's1', 'name': '森林', 'icon': '🌲'},
|
||||
{'id': 's2', 'name': '城堡', 'icon': '🏰'},
|
||||
{'id': 's3', 'name': '太空', 'icon': '🪐'},
|
||||
{'id': 's4', 'name': '海底', 'icon': '🐙'},
|
||||
{'id': 's5', 'name': '沙漠', 'icon': '🏜️'},
|
||||
{'id': 's6', 'name': '城市', 'icon': '🏙️'},
|
||||
{'id': 's7', 'name': '雪山', 'icon': '🏔️'},
|
||||
{'id': 's8', 'name': '游乐园', 'icon': '🎡'},
|
||||
{'id': 's9', 'name': '海滩', 'icon': '🏖️'},
|
||||
{'id': 's10', 'name': '学校', 'icon': '🏫'},
|
||||
{'id': 's11', 'name': '农村', 'icon': '🚜'},
|
||||
{'id': 's12', 'name': '月球', 'icon': '🌕'},
|
||||
{'id': 's13', 'name': '火星', 'icon': '🔴'},
|
||||
{'id': 's14', 'name': '洞穴', 'icon': '🦇'},
|
||||
{'id': 's15', 'name': '鬼屋', 'icon': '🏚️'},
|
||||
{'id': 's16', 'name': '海盗船', 'icon': '🏴☠️'},
|
||||
{'id': 's17', 'name': '云端', 'icon': '☁️'},
|
||||
{'id': 's18', 'name': '糖果屋', 'icon': '🍬'},
|
||||
{'id': 's19', 'name': '动物园', 'icon': '🦁'},
|
||||
{'id': 's20', 'name': '博物馆', 'icon': '🏛️'},
|
||||
{'id': 's21', 'name': '图书馆', 'icon': '📚'},
|
||||
{'id': 's22', 'name': '花园', 'icon': '🌷'},
|
||||
{'id': 's23', 'name': '赛车场', 'icon': '🏎️'},
|
||||
{'id': 's24', 'name': '足球场', 'icon': '⚽'},
|
||||
{'id': 's25', 'name': '原始森林', 'icon': '🌴'},
|
||||
{'id': 's26', 'name': '冰川', 'icon': '🧊'},
|
||||
{'id': 's27', 'name': '火山', 'icon': '🌋'},
|
||||
{'id': 's28', 'name': '天空之城', 'icon': '🏰'},
|
||||
],
|
||||
'props': [
|
||||
{'id': 'p1', 'name': '魔法棒', 'icon': '🪄'},
|
||||
{'id': 'p2', 'name': '宝剑', 'icon': '🗡️'},
|
||||
{'id': 'p3', 'name': '地图', 'icon': '🗺️'},
|
||||
{'id': 'p4', 'name': '宝石', 'icon': '💎'},
|
||||
{'id': 'p5', 'name': '吉他', 'icon': '🎸'},
|
||||
{'id': 'p6', 'name': '火箭', 'icon': '🚀'},
|
||||
{'id': 'p7', 'name': '汉堡', 'icon': '🍔'},
|
||||
{'id': 'p8', 'name': '手电筒', 'icon': '🔦'},
|
||||
{'id': 'p9', 'name': '皇冠', 'icon': '👑'},
|
||||
{'id': 'p10', 'name': '足球', 'icon': '⚽'},
|
||||
{'id': 'p11', 'name': '钥匙', 'icon': '🔑'},
|
||||
{'id': 'p12', 'name': '书本', 'icon': '📖'},
|
||||
{'id': 'p13', 'name': '药水', 'icon': '🧪'},
|
||||
{'id': 'p14', 'name': '水晶球', 'icon': '🔮'},
|
||||
{'id': 'p15', 'name': '望远镜', 'icon': '🔭'},
|
||||
{'id': 'p16', 'name': '滑板', 'icon': '🛹'},
|
||||
{'id': 'p17', 'name': '单车', 'icon': '🚲'},
|
||||
{'id': 'p18', 'name': '蛋糕', 'icon': '🎂'},
|
||||
{'id': 'p19', 'name': '披萨', 'icon': '🍕'},
|
||||
{'id': 'p20', 'name': '冰淇淋', 'icon': '🍦'},
|
||||
{'id': 'p21', 'name': '手机', 'icon': '📱'},
|
||||
{'id': 'p22', 'name': '电脑', 'icon': '💻'},
|
||||
{'id': 'p23', 'name': '相机', 'icon': '📷'},
|
||||
{'id': 'p24', 'name': '雨伞', 'icon': '☂️'},
|
||||
{'id': 'p25', 'name': '背包', 'icon': '🎒'},
|
||||
{'id': 'p26', 'name': '眼镜', 'icon': '👓'},
|
||||
{'id': 'p27', 'name': '帽子', 'icon': '🎩'},
|
||||
{'id': 'p28', 'name': '飞毯', 'icon': '🧶'},
|
||||
],
|
||||
};
|
||||
|
||||
final List<Map<String, String>> _selectedElements = [];
|
||||
|
||||
void _toggleElement(Map<String, String> item) {
|
||||
setState(() {
|
||||
final index = _selectedElements.indexWhere((e) => e['id'] == item['id']);
|
||||
if (index >= 0) {
|
||||
_selectedElements.removeAt(index);
|
||||
} else {
|
||||
if (_selectedElements.length >= 9) {
|
||||
_showSnack('最多只能选择9个元素哦');
|
||||
return;
|
||||
}
|
||||
|
||||
final currentCount = _selectedElements
|
||||
.where((e) => _isItemInTab(e, _activeTab))
|
||||
.length;
|
||||
if (currentCount >= 3) {
|
||||
_showSnack('每个类别最多选择3个哦');
|
||||
return;
|
||||
}
|
||||
|
||||
_selectedElements.add(item);
|
||||
|
||||
if (currentCount + 1 == 3) {
|
||||
_autoSwitchTab();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bool _isItemInTab(Map<String, String> item, String tab) {
|
||||
final list = _storyData[tab];
|
||||
return list?.any((e) => e['id'] == item['id']) ?? false;
|
||||
}
|
||||
|
||||
void _autoSwitchTab() {
|
||||
if (_activeTab == 'characters') {
|
||||
Future.delayed(const Duration(milliseconds: 300), () {
|
||||
if (mounted) setState(() => _activeTab = 'scenes');
|
||||
});
|
||||
} else if (_activeTab == 'scenes') {
|
||||
Future.delayed(const Duration(milliseconds: 300), () {
|
||||
if (mounted) setState(() => _activeTab = 'props');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _showSnack(String msg) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
|
||||
}
|
||||
|
||||
void _switchTab(String tab) {
|
||||
setState(() => _activeTab = tab);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: MediaQuery.of(context).size.height * 0.9,
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFFDF9F3),
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(24),
|
||||
topRight: Radius.circular(24),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Header
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 20, 24, 10),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'创作故事',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF374151),
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.pop(context),
|
||||
child: const Icon(
|
||||
Icons.close,
|
||||
size: 28,
|
||||
color: Color(0xFF9CA3AF),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Tabs - spaceAround layout per CSS
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
Expanded(child: _buildTabBtn('角色', 'characters')),
|
||||
Expanded(child: _buildTabBtn('环境', 'scenes')),
|
||||
Expanded(child: _buildTabBtn('道具', 'props')),
|
||||
],
|
||||
),
|
||||
),
|
||||
Divider(height: 1, color: Colors.black.withOpacity(0.05)),
|
||||
|
||||
// Grid - 4 columns per CSS .element-grid-4col
|
||||
Expanded(
|
||||
child: GridView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 100),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 4,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
childAspectRatio: 0.85,
|
||||
),
|
||||
itemCount: _storyData[_activeTab]?.length ?? 0,
|
||||
itemBuilder: (context, index) {
|
||||
final item = _storyData[_activeTab]![index];
|
||||
final isSelected = _selectedElements.any(
|
||||
(e) => e['id'] == item['id'],
|
||||
);
|
||||
return GestureDetector(
|
||||
onTap: () => _toggleElement(item),
|
||||
child: AnimatedScale(
|
||||
scale: isSelected ? 1.05 : 1.0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
// Selected: #FFF7ED background, otherwise white
|
||||
color: isSelected
|
||||
? const Color(0xFFFFF7ED)
|
||||
: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
// Selected: amber ring #EA9A3E
|
||||
border: isSelected
|
||||
? Border.all(
|
||||
color: const Color(0xFFEA9A3E), width: 2)
|
||||
: null,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.02),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
item['icon']!,
|
||||
style: const TextStyle(fontSize: 32),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
item['name']!,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
color: Color(0xFF4B5563),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isSelected)
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
decoration: const BoxDecoration(
|
||||
// Capybara plush gradient for check badge
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
Color(0xFFECCFA8),
|
||||
Color(0xFFC99672)
|
||||
],
|
||||
),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.check,
|
||||
size: 12,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Footer - Start Generation Button with gradient fade background
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(24, 20, 24, 30),
|
||||
decoration: const BoxDecoration(
|
||||
// Gradient fade from bottom: #FDF9F3 80% to transparent
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
colors: [
|
||||
Color(0xFFFDF9F3),
|
||||
Color(0xFFFDF9F3),
|
||||
Color(0x00FDF9F3),
|
||||
],
|
||||
stops: [0.0, 0.8, 1.0],
|
||||
),
|
||||
),
|
||||
child: GradientButton(
|
||||
text: '✨ 开始生成',
|
||||
gradient: AppColors.btnPlushGradient,
|
||||
onPressed: () {
|
||||
if (_selectedElements.isEmpty) {
|
||||
_showSnack('请至少选择一个元素');
|
||||
return;
|
||||
}
|
||||
// Return 'start_generation' to trigger full-screen loading flow
|
||||
Navigator.pop(context, 'start_generation');
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTabBtn(String label, String key) {
|
||||
final isActive = _activeTab == key;
|
||||
return GestureDetector(
|
||||
onTap: () => _switchTab(key),
|
||||
child: Container(
|
||||
// CSS: padding: 12px 16px
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
border: isActive
|
||||
? const Border(
|
||||
// Capybara brown #4B2404, width 3px
|
||||
bottom: BorderSide(color: Color(0xFF4B2404), width: 3),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
// CSS: active 700, inactive 500
|
||||
fontWeight: isActive ? FontWeight.w700 : FontWeight.w500,
|
||||
// CSS: active #4B2404 (brown), inactive #9CA3AF
|
||||
color: isActive ? const Color(0xFF4B2404) : const Color(0xFF9CA3AF),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
56
airhub_app/migration_report.md
Normal file
@ -0,0 +1,56 @@
|
||||
# Airhub 迁移进度与 UI 还原度报告 📊
|
||||
|
||||
本文档记录了从 Web 原型到 Flutter 原生 App 的转换进度,并标记了目前存在的差异点。
|
||||
|
||||
## 1. 页面转换状态 (Conversion Status)
|
||||
|
||||
| 页面名称 | 状态 | 转换类型 | 备注 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **登录页 (Login)** | ✅ 已完成 | Flutter Native | 使用 `login_mascot.png` |
|
||||
| **产品选择 (Switch Product)** | ✅ 已完成 | Flutter Native | 支持多梯度背景 |
|
||||
| **配网流程 (WiFi/Bluetooth)** | ✅ 已完成 | Flutter Native | |
|
||||
| **设备控制主页 (Home)** | ✅ 已完成 | Flutter Native | 包括卡皮巴拉动效 |
|
||||
| **故事书架 (Story Library)** | ✅ 已完成 | Flutter Native | 已实现书架网格 |
|
||||
| **故事生成器 (Generator)** | ⚠️ 待优化 | Flutter Native | UI 逻辑已转换,但样式需调优 |
|
||||
| **生成等待页 (Loading)** | ✅ 已完成 | Flutter Native | **新增:全屏显示** |
|
||||
| **故事详情页 (Detail)** | ✅ 已完成 | Flutter Native | **新增:全屏显示**,支持朗读/生成模式 |
|
||||
| **设置页 (Settings)** | ✅ 已完成 | Flutter Native | 级联弹窗已实现 |
|
||||
| **个人中心 (Profile)** | ❌ 未转换 | Web Asset | `profile.html` |
|
||||
| **收藏/分享列表** | ❌ 未转换 | Web Asset | `collection-list.html` 等 |
|
||||
| **喂养指南/帮助** | ❌ 未转换 | Web Asset | `guide-feeding.html` 等 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 视觉还原 Gap (1:1 UI Gaps)
|
||||
|
||||
### 🔴 核心色调差异 (Color Mismatch)
|
||||
* **页面底色**:
|
||||
* Web 规范:`#FDF9F3` (暖沙色)。
|
||||
* 当前 Flutter:部分页面(如首页)仍在使用 `Colors.white` 或淡紫渐变。
|
||||
* **按钮渐变**:
|
||||
* Web 规范:`linear-gradient(135deg, #ECCFA8, #C99672)` (温暖杏褐)。
|
||||
* 当前 Flutter:生成器按钮多使用 `0xFF8B5CF6` (紫色),未完全遵循“毛绒机芯”专属色。
|
||||
* **标题文字**:
|
||||
* Web 规范:`#4B2404` (黑巧棕)。
|
||||
* 当前 Flutter:多使用 `#1F2937` (深灰)。
|
||||
|
||||
### 🟡 尺寸与边距 (Metrics)
|
||||
* **Header 高度**:
|
||||
* Web:`calc(env(safe-area-inset-top) + 48px)`。
|
||||
* Flutter:目前使用固定的 `padding + 10dp`,在不同刘海屏下可能存在对齐偏差。
|
||||
* **圆角一致性**: Web 广泛使用 `24px/28px` 圆角,Flutter 部分组件(如按钮)可能使用了默认的 `16px`。
|
||||
|
||||
### 🟠 动效与交互 (Interaction)
|
||||
* **书架上架动画**:
|
||||
* 原因:之前受曲线越界报错影响临时禁用。
|
||||
* 目标:实现 Scale + Fade 的稳定进入效果。
|
||||
* **反馈震动**: 尚未集成 Haptic Feedback。
|
||||
|
||||
---
|
||||
|
||||
## 3. 后续修复计划
|
||||
|
||||
1. **全局色值同步**: 提取 `AppColors` 类,强制从 `design_system.md` 注入 hex。
|
||||
2. **按钮样式统一**: 为“开始生成”等关键按钮应用杏褐色渐变。
|
||||
3. **二级页面转换**: 逐步迁移 `Profile` 和 `Help` 等静态/低交互页面。
|
||||
4. **动效修复**: 重新上线书架“飞入”动画,并解决 Opacity 越界问题。
|
||||
@ -1,6 +1,14 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: args
|
||||
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -9,6 +17,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.0"
|
||||
bluez:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: bluez
|
||||
sha256: "61a7204381925896a374301498f2f5399e59827c6498ae1e924aaa598751b545"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.8.3"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -33,6 +49,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
code_assets:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: code_assets
|
||||
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -41,6 +65,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
cross_file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cross_file
|
||||
sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.5+2"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
dbus:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dbus
|
||||
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.12"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -49,11 +97,107 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi
|
||||
sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.5"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file
|
||||
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
file_selector_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_linux
|
||||
sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.4"
|
||||
file_selector_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_macos
|
||||
sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.5"
|
||||
file_selector_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_platform_interface
|
||||
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
file_selector_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_windows
|
||||
sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.3+5"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_blue_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_blue_plus
|
||||
sha256: "69a8c87c11fc792e8cf0f997d275484fbdb5143ac9f0ac4d424429700cb4e0ed"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.36.8"
|
||||
flutter_blue_plus_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_blue_plus_android
|
||||
sha256: "6f7fe7e69659c30af164a53730707edc16aa4d959e4c208f547b893d940f853d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.4"
|
||||
flutter_blue_plus_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_blue_plus_darwin
|
||||
sha256: "682982862c1d964f4d54a3fb5fccc9e59a066422b93b7e22079aeecd9c0d38f8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.3"
|
||||
flutter_blue_plus_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_blue_plus_linux
|
||||
sha256: "56b0c45edd0a2eec8f85bd97a26ac3cd09447e10d0094fed55587bf0592e3347"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.3"
|
||||
flutter_blue_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_blue_plus_platform_interface
|
||||
sha256: "84fbd180c50a40c92482f273a92069960805ce324e3673ad29c41d2faaa7c5c2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.0"
|
||||
flutter_blue_plus_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_blue_plus_web
|
||||
sha256: a1aceee753d171d24c0e0cdadb37895b5e9124862721f25f60bb758e20b72c99
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.2"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@ -62,6 +206,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
flutter_plugin_android_lifecycle:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_plugin_android_lifecycle
|
||||
sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.33"
|
||||
flutter_svg:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_svg
|
||||
sha256: "87fbd7c534435b6c5d9d98b01e1fd527812b82e68ddd8bd35fc45ed0fa8f0a95"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.3"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
@ -72,6 +232,110 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
glob:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: glob
|
||||
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
google_fonts:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: google_fonts
|
||||
sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.3"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: hooks
|
||||
sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
http:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.0"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_parser
|
||||
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
image_picker:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: image_picker
|
||||
sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
image_picker_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_android
|
||||
sha256: "518a16108529fc18657a3e6dde4a043dc465d16596d20ab2abd49a4cac2e703d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.8.13+13"
|
||||
image_picker_for_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_for_web
|
||||
sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.1"
|
||||
image_picker_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_ios
|
||||
sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.8.13+6"
|
||||
image_picker_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_linux
|
||||
sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.2"
|
||||
image_picker_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_macos
|
||||
sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.2+1"
|
||||
image_picker_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_platform_interface
|
||||
sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.11.1"
|
||||
image_picker_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_windows
|
||||
sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.2"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -104,6 +368,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: logging
|
||||
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -128,6 +400,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.17.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: mime
|
||||
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
native_toolchain_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: native_toolchain_c
|
||||
sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.17.4"
|
||||
objective_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: objective_c
|
||||
sha256: "983c7fa1501f6dcc0cb7af4e42072e9993cb28d73604d25ebf4dab08165d997e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.2.5"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -136,6 +432,62 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_parsing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_parsing
|
||||
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
path_provider:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider
|
||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.5"
|
||||
path_provider_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_android
|
||||
sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.22"
|
||||
path_provider_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_foundation
|
||||
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.0"
|
||||
path_provider_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_linux
|
||||
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.1"
|
||||
path_provider_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_platform_interface
|
||||
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
path_provider_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_windows
|
||||
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
permission_handler:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -184,6 +536,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.1"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: petitparser
|
||||
sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: platform
|
||||
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.6"
|
||||
plugin_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -192,6 +560,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pub_semver
|
||||
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
rxdart:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: rxdart
|
||||
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.28.0"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
@ -245,6 +629,38 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.7"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: typed_data
|
||||
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
vector_graphics:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_graphics
|
||||
sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.19"
|
||||
vector_graphics_codec:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_graphics_codec
|
||||
sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.13"
|
||||
vector_graphics_compiler:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_graphics_compiler
|
||||
sha256: "201e876b5d52753626af64b6359cd13ac6011b80728731428fd34bc840f71c9b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.20"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -301,6 +717,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.23.5"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xdg_directories
|
||||
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
xml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xml
|
||||
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.6.1"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: yaml
|
||||
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.10.7 <4.0.0"
|
||||
flutter: ">=3.35.0"
|
||||
flutter: ">=3.38.4"
|
||||
|
||||
@ -32,6 +32,10 @@ dependencies:
|
||||
sdk: flutter
|
||||
webview_flutter: ^4.4.2
|
||||
permission_handler: ^11.0.0 # Good practice for future
|
||||
google_fonts: ^6.1.0 # For 'Inter' and 'Press Start 2P' fonts
|
||||
flutter_blue_plus: ^1.31.0 # For Bluetooth scanning and connection
|
||||
flutter_svg: ^2.0.9 # For rendering SVG icons
|
||||
image_picker: ^1.2.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
@ -45,7 +49,18 @@ flutter:
|
||||
- assets/www/icons/
|
||||
- assets/www/storybook_videos/
|
||||
- assets/www/story_covers/
|
||||
- assets/fonts/
|
||||
|
||||
fonts:
|
||||
- family: Inter
|
||||
fonts:
|
||||
- asset: assets/fonts/Inter-Regular.ttf
|
||||
- asset: assets/fonts/Inter-Medium.ttf
|
||||
weight: 500
|
||||
- asset: assets/fonts/Inter-SemiBold.ttf
|
||||
weight: 600
|
||||
- asset: assets/fonts/Inter-Bold.ttf
|
||||
weight: 700
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/to/resolution-aware-images
|
||||
|
||||
|
||||
296
airhub_app/skills/prd_to_flutter/SKILL.md
Normal file
@ -0,0 +1,296 @@
|
||||
---
|
||||
name: PRD-to-Flutter
|
||||
description: 从 HTML PRD 精确 1:1 还原 Flutter 代码,覆盖所有页面、弹窗、提示、状态等可视元素。
|
||||
---
|
||||
|
||||
# PRD-to-Flutter Skill
|
||||
|
||||
此 Skill 用于将 HTML 格式的 PRD(产品需求文档/设计稿)**精确 1:1 还原**为 Flutter 代码。
|
||||
|
||||
> **核心原则**:不自由发挥,不擅自修改,严格按照 PRD 执行。
|
||||
|
||||
---
|
||||
|
||||
## 触发条件
|
||||
|
||||
当用户提供 HTML PRD 文件并要求还原为 Flutter 代码时激活此 Skill。
|
||||
|
||||
---
|
||||
|
||||
## 执行流程
|
||||
|
||||
### 阶段一:完整清单提取(必须完成后才能编码)
|
||||
|
||||
#### 1.1 阅读 PRD
|
||||
- 完整阅读 PRD HTML 文件
|
||||
- 理解整体页面结构和交互逻辑
|
||||
|
||||
#### 1.2 生成页面清单
|
||||
列出 PRD 中**所有**可视元素,包括:
|
||||
|
||||
| 类型 | 必须识别的元素 |
|
||||
|------|----------------|
|
||||
| 主页面 | 所有屏幕级页面 |
|
||||
| 弹窗 (Dialog) | 确认框、警告框、自定义弹窗 |
|
||||
| 底部弹层 (BottomSheet) | 分享、选择器、筛选、操作菜单 |
|
||||
| Toast / Snackbar | 成功提示、错误提示、警告提示 |
|
||||
| 加载状态 | 骨架屏、Loading 动画、进度条 |
|
||||
| 空状态 | 列表为空、搜索无结果 |
|
||||
| 错误状态 | 网络错误、服务器错误、权限错误 |
|
||||
| 悬浮组件 | FAB、悬浮工具栏 |
|
||||
| 动画过渡 | 页面切换动画、组件进出场动画 |
|
||||
|
||||
生成清单格式:
|
||||
```markdown
|
||||
## 页面清单
|
||||
- [ ] 首页 (home_page.dart)
|
||||
- [ ] 正常状态
|
||||
- [ ] 加载状态(骨架屏)
|
||||
- [ ] 空状态
|
||||
- [ ] 错误状态
|
||||
- [ ] 详情页 (detail_page.dart)
|
||||
- [ ] 登录弹窗 (login_dialog.dart)
|
||||
- [ ] 分享底部弹层 (share_bottom_sheet.dart)
|
||||
- [ ] 操作成功 Toast
|
||||
- [ ] 网络错误提示
|
||||
...
|
||||
```
|
||||
|
||||
#### 1.3 提取设计 Token
|
||||
从 PRD 中提取所有设计规范,生成 `design_tokens.dart` 文件:
|
||||
|
||||
```dart
|
||||
// lib/theme/design_tokens.dart
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 颜色定义 - 必须使用 PRD 中的精确 Hex 值
|
||||
class AppColors {
|
||||
// 主色
|
||||
static const Color primary = Color(0xFF______);
|
||||
static const Color primaryLight = Color(0xFF______);
|
||||
static const Color primaryDark = Color(0xFF______);
|
||||
|
||||
// 文字颜色
|
||||
static const Color textPrimary = Color(0xFF______);
|
||||
static const Color textSecondary = Color(0xFF______);
|
||||
static const Color textHint = Color(0xFF______);
|
||||
|
||||
// 背景颜色
|
||||
static const Color background = Color(0xFF______);
|
||||
static const Color surface = Color(0xFF______);
|
||||
static const Color card = Color(0xFF______);
|
||||
|
||||
// 状态颜色
|
||||
static const Color success = Color(0xFF______);
|
||||
static const Color warning = Color(0xFF______);
|
||||
static const Color error = Color(0xFF______);
|
||||
|
||||
// 边框和分割线
|
||||
static const Color border = Color(0xFF______);
|
||||
static const Color divider = Color(0xFF______);
|
||||
|
||||
// 遮罩
|
||||
static const Color overlay = Color(0x80______);
|
||||
}
|
||||
|
||||
/// 字体样式 - 必须使用 PRD 中的精确字号和字重
|
||||
class AppTextStyles {
|
||||
// 标题
|
||||
static const TextStyle h1 = TextStyle(
|
||||
fontSize: __,
|
||||
fontWeight: FontWeight.w___,
|
||||
color: AppColors.textPrimary,
|
||||
);
|
||||
static const TextStyle h2 = TextStyle(fontSize: __, fontWeight: FontWeight.w___);
|
||||
static const TextStyle h3 = TextStyle(fontSize: __, fontWeight: FontWeight.w___);
|
||||
|
||||
// 正文
|
||||
static const TextStyle bodyLarge = TextStyle(fontSize: __, fontWeight: FontWeight.w___);
|
||||
static const TextStyle body = TextStyle(fontSize: __, fontWeight: FontWeight.w___);
|
||||
static const TextStyle bodySmall = TextStyle(fontSize: __, fontWeight: FontWeight.w___);
|
||||
|
||||
// 按钮文字
|
||||
static const TextStyle button = TextStyle(fontSize: __, fontWeight: FontWeight.w___);
|
||||
|
||||
// 辅助文字
|
||||
static const TextStyle caption = TextStyle(fontSize: __, fontWeight: FontWeight.w___);
|
||||
}
|
||||
|
||||
/// 间距定义 - 必须使用 PRD 中的精确数值
|
||||
class AppSpacing {
|
||||
static const double xs = __; // 极小间距
|
||||
static const double sm = __; // 小间距
|
||||
static const double md = __; // 中等间距
|
||||
static const double lg = __; // 大间距
|
||||
static const double xl = __; // 超大间距
|
||||
static const double xxl = __; // 特大间距
|
||||
}
|
||||
|
||||
/// 圆角定义
|
||||
class AppRadius {
|
||||
static const double none = 0;
|
||||
static const double xs = __;
|
||||
static const double sm = __;
|
||||
static const double md = __;
|
||||
static const double lg = __;
|
||||
static const double full = 999; // 全圆角
|
||||
}
|
||||
|
||||
/// 阴影定义
|
||||
class AppShadows {
|
||||
static const BoxShadow sm = BoxShadow(
|
||||
color: Color(0x1A000000),
|
||||
blurRadius: __,
|
||||
offset: Offset(0, __),
|
||||
);
|
||||
static const BoxShadow md = BoxShadow(
|
||||
color: Color(0x1A000000),
|
||||
blurRadius: __,
|
||||
offset: Offset(0, __),
|
||||
);
|
||||
}
|
||||
|
||||
/// 动画时长
|
||||
class AppDurations {
|
||||
static const Duration fast = Duration(milliseconds: ___);
|
||||
static const Duration normal = Duration(milliseconds: ___);
|
||||
static const Duration slow = Duration(milliseconds: ___);
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.4 用户确认
|
||||
**必须**将页面清单和设计 Token 报告给用户确认。
|
||||
**未经用户确认,禁止开始编码。**
|
||||
|
||||
---
|
||||
|
||||
### 阶段二:逐项还原
|
||||
|
||||
对清单中的每一项,按以下顺序执行:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 1. 阅读 PRD 中该元素的具体设计 │
|
||||
│ ↓ │
|
||||
│ 2. 编写 Flutter 代码 │
|
||||
│ - 必须使用 design_tokens.dart 中的值 │
|
||||
│ - 禁止硬编码任何颜色、字号、间距 │
|
||||
│ ↓ │
|
||||
│ 3. 运行应用,触发该元素显示 │
|
||||
│ ↓ │
|
||||
│ 4. 截图该元素 │
|
||||
│ ↓ │
|
||||
│ 5. 对比截图与 PRD │
|
||||
│ ├── ✅ 一致 → 标记完成,进入下一项 │
|
||||
│ └── ❌ 不一致 → 分析差异,修复代码,回到步骤 3 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 阶段三:弹窗/弹层专项检查
|
||||
|
||||
对于每个弹窗或弹层,必须逐项验证:
|
||||
|
||||
| 检查项 | 验证内容 |
|
||||
|--------|----------|
|
||||
| 尺寸 | 宽度、高度、最大/最小限制是否与 PRD 一致 |
|
||||
| 位置 | 居中/底部/顶部/自定义位置是否正确 |
|
||||
| 圆角 | 各角圆角值是否与 PRD 一致 |
|
||||
| 背景遮罩 | 遮罩颜色、透明度是否正确 |
|
||||
| 弹出动画 | 动画类型(滑动/淡入/缩放)和时长是否正确 |
|
||||
| 关闭动画 | 关闭时的动画是否正确 |
|
||||
| 关闭方式 | 点击遮罩/按钮/滑动关闭是否与 PRD 一致 |
|
||||
| 内容布局 | 标题、正文、按钮的排列和间距是否正确 |
|
||||
| 按钮样式 | 按钮颜色、圆角、文字样式是否正确 |
|
||||
|
||||
---
|
||||
|
||||
### 阶段四:状态专项检查
|
||||
|
||||
对于每个页面,必须验证以下状态:
|
||||
|
||||
| 状态 | 验证内容 |
|
||||
|------|----------|
|
||||
| 加载状态 | 骨架屏/Loading 动画是否与 PRD 一致 |
|
||||
| 空状态 | 图标、文案、按钮是否与 PRD 一致 |
|
||||
| 错误状态 | 图标、文案、重试按钮是否与 PRD 一致 |
|
||||
| 下拉刷新 | 刷新指示器样式是否正确 |
|
||||
| 上拉加载 | 加载更多提示是否正确 |
|
||||
|
||||
---
|
||||
|
||||
### 阶段五:交互验证
|
||||
|
||||
验证所有交互逻辑:
|
||||
|
||||
| 交互类型 | 验证内容 |
|
||||
|----------|----------|
|
||||
| 按钮点击 | 点击后的行为(跳转/弹窗/请求)是否正确 |
|
||||
| 页面跳转 | 跳转目标页面是否正确 |
|
||||
| 表单提交 | 校验规则、提交行为是否正确 |
|
||||
| 手势操作 | 滑动、长按等手势是否正确响应 |
|
||||
| 键盘处理 | 键盘弹起时页面是否正确调整 |
|
||||
|
||||
---
|
||||
|
||||
## 🚫 禁止行为(红线)
|
||||
|
||||
以下行为**绝对禁止**,违反任何一条都需要立即修正:
|
||||
|
||||
| 禁止行为 | 说明 |
|
||||
|----------|------|
|
||||
| ❌ 修改颜色值 | 必须使用 PRD 中定义的精确 Hex 颜色 |
|
||||
| ❌ 修改字号 | 必须与 PRD 完全一致 |
|
||||
| ❌ 修改间距/边距 | 必须使用 PRD 中定义的精确间距 |
|
||||
| ❌ 修改圆角值 | 必须与 PRD 一致 |
|
||||
| ❌ 删除 UI 元素 | PRD 中有的元素必须实现 |
|
||||
| ❌ 添加 UI 元素 | PRD 中没有的元素禁止添加 |
|
||||
| ❌ 改变组件层级 | 嵌套关系必须与 PRD 一致 |
|
||||
| ❌ 改变布局方式 | Row/Column/Stack 等布局必须与 PRD 一致 |
|
||||
| ❌ 改变交互逻辑 | 点击行为、跳转目标必须与 PRD 一致 |
|
||||
| ❌ 使用未定义的动画 | 动画类型和时长必须与 PRD 一致 |
|
||||
| ❌ 跳过任何状态 | 加载/空/错误状态都必须实现 |
|
||||
| ❌ 跳过任何弹窗/提示 | 所有弹窗和 Toast 都必须实现 |
|
||||
| ❌ 硬编码设计值 | 所有设计值必须引用 design_tokens.dart |
|
||||
|
||||
---
|
||||
|
||||
## 完成标准
|
||||
|
||||
只有满足以下所有条件,才能认为 PRD 还原完成:
|
||||
|
||||
1. ✅ 页面清单中所有项目都已标记完成
|
||||
2. ✅ 每个元素都经过截图对比验证
|
||||
3. ✅ 所有弹窗通过专项检查
|
||||
4. ✅ 所有状态通过专项检查
|
||||
5. ✅ 所有交互通过验证
|
||||
6. ✅ 没有违反任何禁止行为
|
||||
|
||||
---
|
||||
|
||||
## 输出报告
|
||||
|
||||
完成后,生成还原报告:
|
||||
|
||||
```markdown
|
||||
# PRD 还原报告
|
||||
|
||||
## 统计
|
||||
- 总页面数:X
|
||||
- 总弹窗数:X
|
||||
- 总状态数:X
|
||||
- 还原完成率:100%
|
||||
|
||||
## 文件清单
|
||||
- lib/theme/design_tokens.dart
|
||||
- lib/pages/home_page.dart
|
||||
- lib/pages/detail_page.dart
|
||||
- lib/widgets/dialogs/login_dialog.dart
|
||||
- lib/widgets/sheets/share_bottom_sheet.dart
|
||||
...
|
||||
|
||||
## 验证截图
|
||||
(附上关键页面和弹窗的截图)
|
||||
```
|
||||
123
airhub_app/skills/prd_to_flutter/checklists/dialog_checklist.md
Normal file
@ -0,0 +1,123 @@
|
||||
# 弹窗/弹层专项检查清单
|
||||
|
||||
> 使用说明:对于 PRD 中的每个弹窗和底部弹层,逐一完成以下检查项。
|
||||
|
||||
---
|
||||
|
||||
## 弹窗名称:__________
|
||||
|
||||
### 基础属性
|
||||
|
||||
| 检查项 | PRD 规范 | 实际值 | 是否一致 |
|
||||
|--------|----------|--------|----------|
|
||||
| 宽度 | ___px / ___% | | ☐ |
|
||||
| 高度 | ___px / auto | | ☐ |
|
||||
| 最大宽度 | ___px | | ☐ |
|
||||
| 最大高度 | ___px | | ☐ |
|
||||
| 最小高度 | ___px | | ☐ |
|
||||
|
||||
### 位置
|
||||
|
||||
| 检查项 | PRD 规范 | 实际值 | 是否一致 |
|
||||
|--------|----------|--------|----------|
|
||||
| 水平位置 | 居中 / 左 / 右 | | ☐ |
|
||||
| 垂直位置 | 居中 / 顶部 / 底部 | | ☐ |
|
||||
| 距离顶部 | ___px | | ☐ |
|
||||
| 距离底部 | ___px | | ☐ |
|
||||
|
||||
### 圆角
|
||||
|
||||
| 检查项 | PRD 规范 | 实际值 | 是否一致 |
|
||||
|--------|----------|--------|----------|
|
||||
| 左上圆角 | ___px | | ☐ |
|
||||
| 右上圆角 | ___px | | ☐ |
|
||||
| 左下圆角 | ___px | | ☐ |
|
||||
| 右下圆角 | ___px | | ☐ |
|
||||
|
||||
### 背景与遮罩
|
||||
|
||||
| 检查项 | PRD 规范 | 实际值 | 是否一致 |
|
||||
|--------|----------|--------|----------|
|
||||
| 弹窗背景色 | #______ | | ☐ |
|
||||
| 遮罩颜色 | #______ | | ☐ |
|
||||
| 遮罩透明度 | ___% | | ☐ |
|
||||
| 阴影 | 有 / 无 | | ☐ |
|
||||
|
||||
### 动画
|
||||
|
||||
| 检查项 | PRD 规范 | 实际值 | 是否一致 |
|
||||
|--------|----------|--------|----------|
|
||||
| 弹出动画类型 | 淡入 / 缩放 / 滑入 | | ☐ |
|
||||
| 弹出动画时长 | ___ms | | ☐ |
|
||||
| 关闭动画类型 | 淡出 / 缩放 / 滑出 | | ☐ |
|
||||
| 关闭动画时长 | ___ms | | ☐ |
|
||||
| 动画曲线 | easeInOut / easeOut / ... | | ☐ |
|
||||
|
||||
### 关闭方式
|
||||
|
||||
| 检查项 | PRD 规范 | 实际值 | 是否一致 |
|
||||
|--------|----------|--------|----------|
|
||||
| 点击遮罩关闭 | 是 / 否 | | ☐ |
|
||||
| 点击关闭按钮 | 是 / 否 | | ☐ |
|
||||
| 滑动关闭 | 是 / 否 | | ☐ |
|
||||
| 返回键关闭 | 是 / 否 | | ☐ |
|
||||
|
||||
### 内容布局
|
||||
|
||||
| 检查项 | PRD 规范 | 实际值 | 是否一致 |
|
||||
|--------|----------|--------|----------|
|
||||
| 内边距(上) | ___px | | ☐ |
|
||||
| 内边距(右) | ___px | | ☐ |
|
||||
| 内边距(下) | ___px | | ☐ |
|
||||
| 内边距(左) | ___px | | ☐ |
|
||||
| 标题与内容间距 | ___px | | ☐ |
|
||||
| 内容与按钮间距 | ___px | | ☐ |
|
||||
|
||||
### 标题
|
||||
|
||||
| 检查项 | PRD 规范 | 实际值 | 是否一致 |
|
||||
|--------|----------|--------|----------|
|
||||
| 标题字号 | ___px | | ☐ |
|
||||
| 标题字重 | w400 / w500 / w600 | | ☐ |
|
||||
| 标题颜色 | #______ | | ☐ |
|
||||
| 标题对齐 | 左 / 居中 / 右 | | ☐ |
|
||||
|
||||
### 正文
|
||||
|
||||
| 检查项 | PRD 规范 | 实际值 | 是否一致 |
|
||||
|--------|----------|--------|----------|
|
||||
| 正文字号 | ___px | | ☐ |
|
||||
| 正文字重 | w400 / w500 | | ☐ |
|
||||
| 正文颜色 | #______ | | ☐ |
|
||||
| 正文对齐 | 左 / 居中 / 右 | | ☐ |
|
||||
| 正文行高 | ___ | | ☐ |
|
||||
|
||||
### 按钮
|
||||
|
||||
| 检查项 | PRD 规范 | 实际值 | 是否一致 |
|
||||
|--------|----------|--------|----------|
|
||||
| 按钮排列 | 水平 / 垂直 | | ☐ |
|
||||
| 按钮间距 | ___px | | ☐ |
|
||||
| 主按钮宽度 | ___px / 自适应 | | ☐ |
|
||||
| 主按钮高度 | ___px | | ☐ |
|
||||
| 主按钮圆角 | ___px | | ☐ |
|
||||
| 主按钮背景色 | #______ | | ☐ |
|
||||
| 主按钮文字颜色 | #______ | | ☐ |
|
||||
| 主按钮文字字号 | ___px | | ☐ |
|
||||
| 次按钮背景色 | #______ | | ☐ |
|
||||
| 次按钮文字颜色 | #______ | | ☐ |
|
||||
| 次按钮边框 | 有 / 无,颜色 #______ | | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## 检查结果
|
||||
|
||||
- [ ] 所有检查项均一致
|
||||
- [ ] 截图对比已完成
|
||||
- [ ] 动画效果已验证
|
||||
|
||||
**如有不一致项,列出并修复:**
|
||||
|
||||
1. ____________________________________
|
||||
2. ____________________________________
|
||||
3. ____________________________________
|
||||
@ -0,0 +1,74 @@
|
||||
# 页面清单模板
|
||||
|
||||
> 使用说明:从 PRD 中识别所有页面和状态,填写此清单。完成后报告给用户确认。
|
||||
|
||||
## 主页面
|
||||
|
||||
- [ ] **首页** (`home_page.dart`)
|
||||
- [ ] 正常状态
|
||||
- [ ] 加载状态(骨架屏)
|
||||
- [ ] 空状态
|
||||
- [ ] 错误状态
|
||||
- [ ] 下拉刷新状态
|
||||
- [ ] 上拉加载更多状态
|
||||
|
||||
- [ ] **详情页** (`detail_page.dart`)
|
||||
- [ ] 正常状态
|
||||
- [ ] 加载状态
|
||||
- [ ] 错误状态
|
||||
|
||||
- [ ] **个人中心** (`profile_page.dart`)
|
||||
- [ ] 已登录状态
|
||||
- [ ] 未登录状态
|
||||
|
||||
## 弹窗 (Dialog)
|
||||
|
||||
- [ ] **确认弹窗** (`confirm_dialog.dart`)
|
||||
- [ ] 弹出动画
|
||||
- [ ] 关闭动画
|
||||
- [ ] 确认按钮
|
||||
- [ ] 取消按钮
|
||||
|
||||
- [ ] **自定义弹窗** (`xxx_dialog.dart`)
|
||||
- [ ] ...
|
||||
|
||||
## 底部弹层 (BottomSheet)
|
||||
|
||||
- [ ] **分享弹层** (`share_bottom_sheet.dart`)
|
||||
- [ ] 弹出动画
|
||||
- [ ] 滑动关闭
|
||||
- [ ] 图标列表
|
||||
|
||||
- [ ] **筛选弹层** (`filter_bottom_sheet.dart`)
|
||||
- [ ] ...
|
||||
|
||||
## Toast / Snackbar
|
||||
|
||||
- [ ] **成功提示** (Toast)
|
||||
- [ ] **错误提示** (Toast)
|
||||
- [ ] **警告提示** (Toast)
|
||||
- [ ] **网络错误提示** (Snackbar with retry)
|
||||
|
||||
## 悬浮组件
|
||||
|
||||
- [ ] **悬浮按钮** (FloatingActionButton)
|
||||
- [ ] **悬浮工具栏** (Floating Toolbar)
|
||||
|
||||
## 动画过渡
|
||||
|
||||
- [ ] **页面进入动画**
|
||||
- [ ] **页面退出动画**
|
||||
- [ ] **列表项动画**
|
||||
|
||||
---
|
||||
|
||||
## 统计
|
||||
|
||||
| 类型 | 数量 |
|
||||
|------|------|
|
||||
| 主页面 | __ |
|
||||
| 弹窗 | __ |
|
||||
| 底部弹层 | __ |
|
||||
| Toast/Snackbar | __ |
|
||||
| 悬浮组件 | __ |
|
||||
| **总计** | __ |
|
||||
134
airhub_app/skills/prd_to_flutter/checklists/state_checklist.md
Normal file
@ -0,0 +1,134 @@
|
||||
# 状态专项检查清单
|
||||
|
||||
> 使用说明:对于 PRD 中的每个页面状态(加载、空、错误等),逐一完成以下检查项。
|
||||
|
||||
---
|
||||
|
||||
## 页面名称:__________
|
||||
|
||||
---
|
||||
|
||||
## 1. 加载状态 (Loading State)
|
||||
|
||||
### 加载类型
|
||||
|
||||
| 检查项 | PRD 规范 | 实际值 | 是否一致 |
|
||||
|--------|----------|--------|----------|
|
||||
| 加载类型 | 骨架屏 / 菊花 / 进度条 / 自定义 | | ☐ |
|
||||
|
||||
### 骨架屏(如适用)
|
||||
|
||||
| 检查项 | PRD 规范 | 实际值 | 是否一致 |
|
||||
|--------|----------|--------|----------|
|
||||
| 骨架形状 | 与内容布局一致 | | ☐ |
|
||||
| 骨架颜色 | #______ | | ☐ |
|
||||
| 闪烁动画颜色 | #______ | | ☐ |
|
||||
| 圆角 | ___px | | ☐ |
|
||||
| 行数 | ___ 行 | | ☐ |
|
||||
|
||||
### Loading 动画(如适用)
|
||||
|
||||
| 检查项 | PRD 规范 | 实际值 | 是否一致 |
|
||||
|--------|----------|--------|----------|
|
||||
| 动画类型 | CircularProgressIndicator / 自定义 | | ☐ |
|
||||
| 动画颜色 | #______ | | ☐ |
|
||||
| 动画尺寸 | ___px | | ☐ |
|
||||
| 加载文案 | "加载中..." / 无 | | ☐ |
|
||||
| 文案字号 | ___px | | ☐ |
|
||||
| 文案颜色 | #______ | | ☐ |
|
||||
| 文案与动画间距 | ___px | | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## 2. 空状态 (Empty State)
|
||||
|
||||
| 检查项 | PRD 规范 | 实际值 | 是否一致 |
|
||||
|--------|----------|--------|----------|
|
||||
| 图标/插图 | 有 / 无 | | ☐ |
|
||||
| 图标类型 | 图片 / Icon / Lottie | | ☐ |
|
||||
| 图标尺寸 | ___px × ___px | | ☐ |
|
||||
| 标题文案 | "暂无数据" / ... | | ☐ |
|
||||
| 标题字号 | ___px | | ☐ |
|
||||
| 标题字重 | w400 / w500 / w600 | | ☐ |
|
||||
| 标题颜色 | #______ | | ☐ |
|
||||
| 副标题文案 | "..." / 无 | | ☐ |
|
||||
| 副标题字号 | ___px | | ☐ |
|
||||
| 副标题颜色 | #______ | | ☐ |
|
||||
| 图标与标题间距 | ___px | | ☐ |
|
||||
| 标题与副标题间距 | ___px | | ☐ |
|
||||
| 操作按钮 | 有 / 无 | | ☐ |
|
||||
| 按钮文案 | "重试" / "刷新" / ... | | ☐ |
|
||||
| 按钮样式 | 主按钮 / 文字按钮 / 边框按钮 | | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## 3. 错误状态 (Error State)
|
||||
|
||||
| 检查项 | PRD 规范 | 实际值 | 是否一致 |
|
||||
|--------|----------|--------|----------|
|
||||
| 错误图标/插图 | 有 / 无 | | ☐ |
|
||||
| 图标类型 | 图片 / Icon / Lottie | | ☐ |
|
||||
| 图标尺寸 | ___px × ___px | | ☐ |
|
||||
| 错误标题 | "网络错误" / "加载失败" / ... | | ☐ |
|
||||
| 标题字号 | ___px | | ☐ |
|
||||
| 标题字重 | w400 / w500 / w600 | | ☐ |
|
||||
| 标题颜色 | #______ | | ☐ |
|
||||
| 错误描述 | "请检查网络..." / 无 | | ☐ |
|
||||
| 描述字号 | ___px | | ☐ |
|
||||
| 描述颜色 | #______ | | ☐ |
|
||||
| 重试按钮 | 有 / 无 | | ☐ |
|
||||
| 重试按钮文案 | "重试" / "刷新" / ... | | ☐ |
|
||||
| 重试按钮样式 | 主按钮 / 文字按钮 / 边框按钮 | | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## 4. 下拉刷新 (Pull to Refresh)
|
||||
|
||||
| 检查项 | PRD 规范 | 实际值 | 是否一致 |
|
||||
|--------|----------|--------|----------|
|
||||
| 刷新指示器类型 | 系统默认 / 自定义 | | ☐ |
|
||||
| 指示器颜色 | #______ | | ☐ |
|
||||
| 下拉距离 | ___px | | ☐ |
|
||||
| 刷新文案 | "下拉刷新" / "释放刷新" / "刷新中" | | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## 5. 上拉加载更多 (Load More)
|
||||
|
||||
| 检查项 | PRD 规范 | 实际值 | 是否一致 |
|
||||
|--------|----------|--------|----------|
|
||||
| 加载指示器 | 有 / 无 | | ☐ |
|
||||
| 指示器类型 | 菊花 / 自定义 | | ☐ |
|
||||
| 指示器颜色 | #______ | | ☐ |
|
||||
| 加载中文案 | "加载中..." / 无 | | ☐ |
|
||||
| 没有更多文案 | "没有更多了" / "— 到底了 —" | | ☐ |
|
||||
| 文案字号 | ___px | | ☐ |
|
||||
| 文案颜色 | #______ | | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## 6. 网络状态提示
|
||||
|
||||
| 检查项 | PRD 规范 | 实际值 | 是否一致 |
|
||||
|--------|----------|--------|----------|
|
||||
| 无网络提示类型 | Toast / Banner / 全屏 | | ☐ |
|
||||
| 提示位置 | 顶部 / 底部 / 居中 | | ☐ |
|
||||
| 提示背景色 | #______ | | ☐ |
|
||||
| 提示文案 | "网络连接失败" / ... | | ☐ |
|
||||
| 提示文案颜色 | #______ | | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## 检查结果
|
||||
|
||||
- [ ] 所有加载状态检查项均一致
|
||||
- [ ] 所有空状态检查项均一致
|
||||
- [ ] 所有错误状态检查项均一致
|
||||
- [ ] 刷新/加载更多状态已验证
|
||||
- [ ] 截图对比已完成
|
||||
|
||||
**如有不一致项,列出并修复:**
|
||||
|
||||
1. ____________________________________
|
||||
2. ____________________________________
|
||||
3. ____________________________________
|
||||
@ -0,0 +1,353 @@
|
||||
/// Design Tokens Template
|
||||
///
|
||||
/// 使用说明:
|
||||
/// 1. 从 PRD 中提取所有设计规范
|
||||
/// 2. 用提取的精确值替换所有 `______` 和 `__` 占位符
|
||||
/// 3. 将此文件保存为 lib/theme/design_tokens.dart
|
||||
/// 4. 所有 Flutter 代码必须引用此文件中的值,禁止硬编码
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
// ============================================================================
|
||||
// 颜色定义
|
||||
// ============================================================================
|
||||
|
||||
/// 应用颜色 - 必须使用 PRD 中的精确 Hex 值
|
||||
class AppColors {
|
||||
AppColors._();
|
||||
|
||||
// -------- 主色 --------
|
||||
/// 主色调
|
||||
static const Color primary = Color(0xFF______);
|
||||
/// 主色调(浅)
|
||||
static const Color primaryLight = Color(0xFF______);
|
||||
/// 主色调(深)
|
||||
static const Color primaryDark = Color(0xFF______);
|
||||
|
||||
// -------- 文字颜色 --------
|
||||
/// 主要文字颜色
|
||||
static const Color textPrimary = Color(0xFF______);
|
||||
/// 次要文字颜色
|
||||
static const Color textSecondary = Color(0xFF______);
|
||||
/// 提示文字颜色
|
||||
static const Color textHint = Color(0xFF______);
|
||||
/// 禁用文字颜色
|
||||
static const Color textDisabled = Color(0xFF______);
|
||||
/// 链接文字颜色
|
||||
static const Color textLink = Color(0xFF______);
|
||||
|
||||
// -------- 背景颜色 --------
|
||||
/// 主背景色
|
||||
static const Color background = Color(0xFF______);
|
||||
/// 表面背景色(卡片、弹窗等)
|
||||
static const Color surface = Color(0xFF______);
|
||||
/// 卡片背景色
|
||||
static const Color card = Color(0xFF______);
|
||||
/// 输入框背景色
|
||||
static const Color inputBackground = Color(0xFF______);
|
||||
|
||||
// -------- 状态颜色 --------
|
||||
/// 成功状态
|
||||
static const Color success = Color(0xFF______);
|
||||
/// 成功状态(浅)
|
||||
static const Color successLight = Color(0xFF______);
|
||||
/// 警告状态
|
||||
static const Color warning = Color(0xFF______);
|
||||
/// 警告状态(浅)
|
||||
static const Color warningLight = Color(0xFF______);
|
||||
/// 错误状态
|
||||
static const Color error = Color(0xFF______);
|
||||
/// 错误状态(浅)
|
||||
static const Color errorLight = Color(0xFF______);
|
||||
/// 信息状态
|
||||
static const Color info = Color(0xFF______);
|
||||
|
||||
// -------- 边框和分割线 --------
|
||||
/// 边框颜色
|
||||
static const Color border = Color(0xFF______);
|
||||
/// 分割线颜色
|
||||
static const Color divider = Color(0xFF______);
|
||||
|
||||
// -------- 遮罩 --------
|
||||
/// 弹窗遮罩
|
||||
static const Color overlay = Color(0x80______);
|
||||
/// 深色遮罩
|
||||
static const Color overlayDark = Color(0xB3______);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 字体样式
|
||||
// ============================================================================
|
||||
|
||||
/// 字体样式 - 必须使用 PRD 中的精确字号和字重
|
||||
class AppTextStyles {
|
||||
AppTextStyles._();
|
||||
|
||||
// -------- 标题 --------
|
||||
/// 超大标题 - H1
|
||||
static const TextStyle h1 = TextStyle(
|
||||
fontSize: __, // PRD 字号
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.3,
|
||||
color: AppColors.textPrimary,
|
||||
);
|
||||
|
||||
/// 大标题 - H2
|
||||
static const TextStyle h2 = TextStyle(
|
||||
fontSize: __,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.3,
|
||||
color: AppColors.textPrimary,
|
||||
);
|
||||
|
||||
/// 中标题 - H3
|
||||
static const TextStyle h3 = TextStyle(
|
||||
fontSize: __,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.4,
|
||||
color: AppColors.textPrimary,
|
||||
);
|
||||
|
||||
/// 小标题 - H4
|
||||
static const TextStyle h4 = TextStyle(
|
||||
fontSize: __,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.4,
|
||||
color: AppColors.textPrimary,
|
||||
);
|
||||
|
||||
// -------- 正文 --------
|
||||
/// 大正文
|
||||
static const TextStyle bodyLarge = TextStyle(
|
||||
fontSize: __,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.5,
|
||||
color: AppColors.textPrimary,
|
||||
);
|
||||
|
||||
/// 正文
|
||||
static const TextStyle body = TextStyle(
|
||||
fontSize: __,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.5,
|
||||
color: AppColors.textPrimary,
|
||||
);
|
||||
|
||||
/// 小正文
|
||||
static const TextStyle bodySmall = TextStyle(
|
||||
fontSize: __,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.5,
|
||||
color: AppColors.textSecondary,
|
||||
);
|
||||
|
||||
// -------- 按钮 --------
|
||||
/// 大按钮文字
|
||||
static const TextStyle buttonLarge = TextStyle(
|
||||
fontSize: __,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.2,
|
||||
);
|
||||
|
||||
/// 默认按钮文字
|
||||
static const TextStyle button = TextStyle(
|
||||
fontSize: __,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.2,
|
||||
);
|
||||
|
||||
/// 小按钮文字
|
||||
static const TextStyle buttonSmall = TextStyle(
|
||||
fontSize: __,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.2,
|
||||
);
|
||||
|
||||
// -------- 辅助文字 --------
|
||||
/// 标签文字
|
||||
static const TextStyle label = TextStyle(
|
||||
fontSize: __,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.4,
|
||||
color: AppColors.textSecondary,
|
||||
);
|
||||
|
||||
/// 说明文字
|
||||
static const TextStyle caption = TextStyle(
|
||||
fontSize: __,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.4,
|
||||
color: AppColors.textSecondary,
|
||||
);
|
||||
|
||||
/// 超小文字
|
||||
static const TextStyle overline = TextStyle(
|
||||
fontSize: __,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.4,
|
||||
color: AppColors.textHint,
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 间距
|
||||
// ============================================================================
|
||||
|
||||
/// 间距定义 - 必须使用 PRD 中的精确数值
|
||||
class AppSpacing {
|
||||
AppSpacing._();
|
||||
|
||||
/// 极小间距 - 4
|
||||
static const double xs = __;
|
||||
/// 小间距 - 8
|
||||
static const double sm = __;
|
||||
/// 中等间距 - 16
|
||||
static const double md = __;
|
||||
/// 大间距 - 24
|
||||
static const double lg = __;
|
||||
/// 超大间距 - 32
|
||||
static const double xl = __;
|
||||
/// 特大间距 - 48
|
||||
static const double xxl = __;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 圆角
|
||||
// ============================================================================
|
||||
|
||||
/// 圆角定义
|
||||
class AppRadius {
|
||||
AppRadius._();
|
||||
|
||||
/// 无圆角
|
||||
static const double none = 0;
|
||||
/// 极小圆角 - 2
|
||||
static const double xs = __;
|
||||
/// 小圆角 - 4
|
||||
static const double sm = __;
|
||||
/// 中等圆角 - 8
|
||||
static const double md = __;
|
||||
/// 大圆角 - 12
|
||||
static const double lg = __;
|
||||
/// 超大圆角 - 16
|
||||
static const double xl = __;
|
||||
/// 全圆角
|
||||
static const double full = 999;
|
||||
|
||||
// -------- 常用 BorderRadius --------
|
||||
static BorderRadius get smAll => BorderRadius.circular(sm);
|
||||
static BorderRadius get mdAll => BorderRadius.circular(md);
|
||||
static BorderRadius get lgAll => BorderRadius.circular(lg);
|
||||
static BorderRadius get xlAll => BorderRadius.circular(xl);
|
||||
|
||||
/// 顶部圆角(用于 BottomSheet)
|
||||
static BorderRadius get topLg => const BorderRadius.only(
|
||||
topLeft: Radius.circular(__),
|
||||
topRight: Radius.circular(__),
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 阴影
|
||||
// ============================================================================
|
||||
|
||||
/// 阴影定义
|
||||
class AppShadows {
|
||||
AppShadows._();
|
||||
|
||||
/// 小阴影
|
||||
static const BoxShadow sm = BoxShadow(
|
||||
color: Color(0x1A000000),
|
||||
blurRadius: __,
|
||||
offset: Offset(0, __),
|
||||
);
|
||||
|
||||
/// 中等阴影
|
||||
static const BoxShadow md = BoxShadow(
|
||||
color: Color(0x1A000000),
|
||||
blurRadius: __,
|
||||
offset: Offset(0, __),
|
||||
);
|
||||
|
||||
/// 大阴影
|
||||
static const BoxShadow lg = BoxShadow(
|
||||
color: Color(0x1F000000),
|
||||
blurRadius: __,
|
||||
offset: Offset(0, __),
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 动画
|
||||
// ============================================================================
|
||||
|
||||
/// 动画时长定义
|
||||
class AppDurations {
|
||||
AppDurations._();
|
||||
|
||||
/// 快速动画 - 150ms
|
||||
static const Duration fast = Duration(milliseconds: __);
|
||||
/// 普通动画 - 250ms
|
||||
static const Duration normal = Duration(milliseconds: __);
|
||||
/// 慢速动画 - 350ms
|
||||
static const Duration slow = Duration(milliseconds: __);
|
||||
}
|
||||
|
||||
/// 动画曲线定义
|
||||
class AppCurves {
|
||||
AppCurves._();
|
||||
|
||||
/// 默认缓动
|
||||
static const Curve defaultCurve = Curves.easeInOut;
|
||||
/// 弹出动画
|
||||
static const Curve popup = Curves.easeOutBack;
|
||||
/// 滑入动画
|
||||
static const Curve slideIn = Curves.easeOutCubic;
|
||||
/// 滑出动画
|
||||
static const Curve slideOut = Curves.easeInCubic;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 组件尺寸
|
||||
// ============================================================================
|
||||
|
||||
/// 组件尺寸定义
|
||||
class AppSizes {
|
||||
AppSizes._();
|
||||
|
||||
// -------- 按钮高度 --------
|
||||
/// 大按钮高度
|
||||
static const double buttonHeightLg = __;
|
||||
/// 默认按钮高度
|
||||
static const double buttonHeight = __;
|
||||
/// 小按钮高度
|
||||
static const double buttonHeightSm = __;
|
||||
|
||||
// -------- 输入框高度 --------
|
||||
/// 默认输入框高度
|
||||
static const double inputHeight = __;
|
||||
|
||||
// -------- 图标尺寸 --------
|
||||
/// 小图标
|
||||
static const double iconSm = __;
|
||||
/// 默认图标
|
||||
static const double iconMd = __;
|
||||
/// 大图标
|
||||
static const double iconLg = __;
|
||||
|
||||
// -------- 头像尺寸 --------
|
||||
/// 小头像
|
||||
static const double avatarSm = __;
|
||||
/// 默认头像
|
||||
static const double avatarMd = __;
|
||||
/// 大头像
|
||||
static const double avatarLg = __;
|
||||
|
||||
// -------- 导航栏 --------
|
||||
/// AppBar 高度
|
||||
static const double appBarHeight = __;
|
||||
/// TabBar 高度
|
||||
static const double tabBarHeight = __;
|
||||
/// BottomNavigationBar 高度
|
||||
static const double bottomNavHeight = __;
|
||||
}
|
||||