fix some page
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">
|
<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 -->
|
<!-- 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 -->
|
<!-- Outer frame -->
|
||||||
<rect class="frame" x="6" y="6" width="20" height="20"/>
|
<rect fill="#1E3A5F" x="6" y="6" width="20" height="20"/>
|
||||||
<rect class="frame" x="4" y="8" width="2" height="16"/>
|
<rect fill="#1E3A5F" x="4" y="8" width="2" height="16"/>
|
||||||
<rect class="frame" x="26" y="8" width="2" height="16"/>
|
<rect fill="#1E3A5F" x="26" y="8" width="2" height="16"/>
|
||||||
<rect class="frame" x="8" y="4" width="16" height="2"/>
|
<rect fill="#1E3A5F" x="8" y="4" width="16" height="2"/>
|
||||||
<rect class="frame" x="8" y="26" width="16" height="2"/>
|
<rect fill="#1E3A5F" x="8" y="26" width="16" height="2"/>
|
||||||
|
|
||||||
<!-- Screen glow -->
|
<!-- 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 -->
|
<!-- 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 -->
|
<!-- Pixel face on screen -->
|
||||||
<rect class="pixel" x="12" y="12" width="2" height="2"/>
|
<rect fill="#FFFFFF" x="12" y="12" width="2" height="2"/>
|
||||||
<rect class="pixel" x="18" y="12" width="2" height="2"/>
|
<rect fill="#FFFFFF" x="18" y="12" width="2" height="2"/>
|
||||||
<rect class="pixel" x="12" y="18" width="2" height="2"/>
|
<rect fill="#FFFFFF" x="12" y="18" width="2" height="2"/>
|
||||||
<rect class="pixel" x="14" y="20" width="4" height="2"/>
|
<rect fill="#FFFFFF" x="14" y="20" width="4" height="2"/>
|
||||||
<rect class="pixel" x="18" y="18" width="2" height="2"/>
|
<rect fill="#FFFFFF" x="18" y="18" width="2" height="2"/>
|
||||||
|
|
||||||
<!-- Corner highlights -->
|
<!-- 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>
|
</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">
|
<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) -->
|
<!-- 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 -->
|
<!-- Outer frame -->
|
||||||
<rect class="frame" x="6" y="6" width="20" height="20"/>
|
<rect fill="#475569" x="6" y="6" width="20" height="20"/>
|
||||||
<rect class="frame" x="4" y="8" width="2" height="16"/>
|
<rect fill="#475569" x="4" y="8" width="2" height="16"/>
|
||||||
<rect class="frame" x="26" y="8" width="2" height="16"/>
|
<rect fill="#475569" x="26" y="8" width="2" height="16"/>
|
||||||
<rect class="frame" x="8" y="4" width="16" height="2"/>
|
<rect fill="#475569" x="8" y="4" width="16" height="2"/>
|
||||||
<rect class="frame" x="8" y="26" width="16" height="2"/>
|
<rect fill="#475569" x="8" y="26" width="16" height="2"/>
|
||||||
|
|
||||||
<!-- Screen -->
|
<!-- 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 -->
|
<!-- Simple pixel pattern on screen -->
|
||||||
<rect class="pixel" x="12" y="12" width="2" height="2"/>
|
<rect fill="#F1F5F9" x="12" y="12" width="2" height="2"/>
|
||||||
<rect class="pixel" x="18" y="12" width="2" height="2"/>
|
<rect fill="#F1F5F9" x="18" y="12" width="2" height="2"/>
|
||||||
<rect class="pixel" x="14" y="16" width="4" height="2"/>
|
<rect fill="#F1F5F9" 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="18" width="8" height="2"/>
|
||||||
|
|
||||||
<!-- Corner highlights -->
|
<!-- 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>
|
</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">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="128" height="128">
|
||||||
<!-- Pixel Capybara - 8-bit style -->
|
<!-- Pixel Capybara - 8-bit style -->
|
||||||
<style>
|
|
||||||
.body { fill: #D4A574; }
|
|
||||||
.dark { fill: #A67B5B; }
|
|
||||||
.nose { fill: #8B5A3C; }
|
|
||||||
.eye { fill: #2D2D2D; }
|
|
||||||
.cheek { fill: #FFCCCB; }
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<!-- Body -->
|
<!-- Body -->
|
||||||
<rect class="body" x="8" y="14" width="16" height="12"/>
|
<rect fill="#D4A574" x="8" y="14" width="16" height="12"/>
|
||||||
<rect class="body" x="6" y="16" width="2" height="8"/>
|
<rect fill="#D4A574" x="6" y="16" width="2" height="8"/>
|
||||||
<rect class="body" x="24" y="16" width="2" height="8"/>
|
<rect fill="#D4A574" x="24" y="16" width="2" height="8"/>
|
||||||
|
|
||||||
<!-- Head -->
|
<!-- Head -->
|
||||||
<rect class="body" x="10" y="8" width="12" height="8"/>
|
<rect fill="#D4A574" x="10" y="8" width="12" height="8"/>
|
||||||
<rect class="body" x="8" y="10" width="2" height="4"/>
|
<rect fill="#D4A574" x="8" y="10" width="2" height="4"/>
|
||||||
<rect class="body" x="22" y="10" width="2" height="4"/>
|
<rect fill="#D4A574" x="22" y="10" width="2" height="4"/>
|
||||||
|
|
||||||
<!-- Ears -->
|
<!-- Ears -->
|
||||||
<rect class="dark" x="8" y="6" width="4" height="4"/>
|
<rect fill="#A67B5B" x="8" y="6" width="4" height="4"/>
|
||||||
<rect class="dark" x="20" y="6" width="4" height="4"/>
|
<rect fill="#A67B5B" x="20" y="6" width="4" height="4"/>
|
||||||
|
|
||||||
<!-- Eyes -->
|
<!-- Eyes -->
|
||||||
<rect class="eye" x="12" y="10" width="2" height="2"/>
|
<rect fill="#2D2D2D" x="12" y="10" width="2" height="2"/>
|
||||||
<rect class="eye" x="18" y="10" width="2" height="2"/>
|
<rect fill="#2D2D2D" x="18" y="10" width="2" height="2"/>
|
||||||
|
|
||||||
<!-- Nose -->
|
<!-- Nose -->
|
||||||
<rect class="nose" x="14" y="12" width="4" height="2"/>
|
<rect fill="#8B5A3C" x="14" y="12" width="4" height="2"/>
|
||||||
|
|
||||||
<!-- Cheeks -->
|
<!-- Cheeks -->
|
||||||
<rect class="cheek" x="10" y="12" width="2" height="2" opacity="0.6"/>
|
<rect fill="#FFCCCB" 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="20" y="12" width="2" height="2" opacity="0.6"/>
|
||||||
|
|
||||||
<!-- Legs -->
|
<!-- Legs -->
|
||||||
<rect class="dark" x="10" y="24" width="4" height="4"/>
|
<rect fill="#A67B5B" x="10" y="24" width="4" height="4"/>
|
||||||
<rect class="dark" x="18" y="24" width="4" height="4"/>
|
<rect fill="#A67B5B" x="18" y="24" width="4" height="4"/>
|
||||||
</svg>
|
</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">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="120" height="120">
|
||||||
<!-- Pixel Mystery Box - 8-bit Mario style -->
|
<!-- 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 -->
|
<!-- 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 -->
|
<!-- Top highlight -->
|
||||||
<rect class="gold-light" x="4" y="4" width="24" height="4"/>
|
<rect fill="#FCD34D" 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="4" height="24"/>
|
||||||
|
|
||||||
<!-- Bottom shadow -->
|
<!-- Bottom shadow -->
|
||||||
<rect class="gold-dark" x="4" y="24" width="24" height="4"/>
|
<rect fill="#D97706" x="4" y="24" width="24" height="4"/>
|
||||||
<rect class="gold-dark" x="24" y="4" width="4" height="24"/>
|
<rect fill="#D97706" x="24" y="4" width="4" height="24"/>
|
||||||
|
|
||||||
<!-- Corner details -->
|
<!-- Corner details -->
|
||||||
<rect class="gold-shadow" x="24" y="24" width="4" height="4"/>
|
<rect fill="#92400E" x="24" y="24" width="4" height="4"/>
|
||||||
<rect class="gold-light" x="4" y="4" width="4" height="4"/>
|
<rect fill="#FCD34D" x="4" y="4" width="4" height="4"/>
|
||||||
|
|
||||||
<!-- Inner border -->
|
<!-- Inner border -->
|
||||||
<rect class="gold-dark" x="6" y="6" width="20" height="2"/>
|
<rect fill="#D97706" x="6" y="6" width="20" height="2"/>
|
||||||
<rect class="gold-dark" x="6" y="24" width="20" height="2"/>
|
<rect fill="#D97706" x="6" y="24" width="20" height="2"/>
|
||||||
<rect class="gold-dark" x="6" y="6" width="2" height="20"/>
|
<rect fill="#D97706" x="6" y="6" width="2" height="20"/>
|
||||||
<rect class="gold-dark" x="24" y="6" width="2" height="20"/>
|
<rect fill="#D97706" x="24" y="6" width="2" height="20"/>
|
||||||
|
|
||||||
<!-- Question mark - pixel style -->
|
<!-- Question mark - pixel style -->
|
||||||
<rect class="question" x="12" y="10" width="8" height="2"/>
|
<rect fill="#92400E" x="12" y="10" width="8" height="2"/>
|
||||||
<rect class="question" x="18" y="10" width="2" height="6"/>
|
<rect fill="#92400E" x="18" y="10" width="2" height="6"/>
|
||||||
<rect class="question" x="14" y="14" width="4" height="2"/>
|
<rect fill="#92400E" x="14" y="14" width="4" height="2"/>
|
||||||
<rect class="question" x="14" y="16" width="2" height="2"/>
|
<rect fill="#92400E" x="14" y="16" width="2" height="2"/>
|
||||||
<rect class="question" x="14" y="20" width="2" height="2"/>
|
<rect fill="#92400E" x="14" y="20" width="2" height="2"/>
|
||||||
</svg>
|
</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:
|
PODS:
|
||||||
- Flutter (1.0.0)
|
- 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):
|
- permission_handler_apple (9.3.0):
|
||||||
- Flutter
|
- Flutter
|
||||||
- webview_flutter_wkwebview (0.0.1):
|
- webview_flutter_wkwebview (0.0.1):
|
||||||
@ -8,12 +13,18 @@ PODS:
|
|||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
- Flutter (from `Flutter`)
|
- 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`)
|
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||||
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`)
|
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`)
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
Flutter:
|
Flutter:
|
||||||
:path: 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:
|
permission_handler_apple:
|
||||||
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
||||||
webview_flutter_wkwebview:
|
webview_flutter_wkwebview:
|
||||||
@ -21,6 +32,8 @@ EXTERNAL SOURCES:
|
|||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||||
|
flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3
|
||||||
|
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
|
||||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
||||||
webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d
|
webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,14 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'pages/login_page.dart';
|
import 'pages/login_page.dart';
|
||||||
import 'pages/webview_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 'theme/app_theme.dart';
|
||||||
|
|
||||||
|
import 'pages/profile/profile_page.dart'; // Import ProfilePage
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
runApp(const AirhubApp());
|
runApp(const AirhubApp());
|
||||||
}
|
}
|
||||||
@ -17,11 +23,17 @@ class AirhubApp extends StatelessWidget {
|
|||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: AppTheme.lightTheme,
|
theme: AppTheme.lightTheme,
|
||||||
// Initial Route
|
// Initial Route
|
||||||
home: const LoginPage(),
|
home: const DeviceControlPage(),
|
||||||
// Named Routes
|
// Named Routes
|
||||||
routes: {
|
routes: {
|
||||||
'/login': (context) => const LoginPage(),
|
'/login': (context) => const LoginPage(),
|
||||||
'/home': (context) => const WebViewPage(),
|
'/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
|
// Handle unknown routes
|
||||||
onUnknownRoute: (settings) {
|
onUnknownRoute: (settings) {
|
||||||
|
|||||||
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
|
||||||
import '../theme/app_colors.dart';
|
import '../theme/app_colors.dart';
|
||||||
import '../widgets/gradient_button.dart';
|
import '../widgets/gradient_button.dart';
|
||||||
|
|
||||||
@ -55,7 +56,7 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
|
|||||||
// Title
|
// Title
|
||||||
Text(
|
Text(
|
||||||
'服务协议',
|
'服务协议',
|
||||||
style: GoogleFonts.inter(
|
style: TextStyle(fontFamily: 'Inter',
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: const Color(0xFF1F2937),
|
color: const Color(0xFF1F2937),
|
||||||
@ -66,7 +67,7 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
|
|||||||
Text.rich(
|
Text.rich(
|
||||||
TextSpan(
|
TextSpan(
|
||||||
text: '请先阅读并同意',
|
text: '请先阅读并同意',
|
||||||
style: GoogleFonts.inter(
|
style: TextStyle(fontFamily: 'Inter',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: const Color(0xFF6B7280),
|
color: const Color(0xFF6B7280),
|
||||||
height: 1.6,
|
height: 1.6,
|
||||||
@ -74,12 +75,12 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
|
|||||||
children: [
|
children: [
|
||||||
TextSpan(
|
TextSpan(
|
||||||
text: '《用户协议》',
|
text: '《用户协议》',
|
||||||
style: GoogleFonts.inter(color: const Color(0xFF6366F1)),
|
style: TextStyle(fontFamily: 'Inter', color: const Color(0xFF6366F1)),
|
||||||
),
|
),
|
||||||
const TextSpan(text: '和'),
|
const TextSpan(text: '和'),
|
||||||
TextSpan(
|
TextSpan(
|
||||||
text: '《隐私政策》',
|
text: '《隐私政策》',
|
||||||
style: GoogleFonts.inter(color: const Color(0xFF6366F1)),
|
style: TextStyle(fontFamily: 'Inter', color: const Color(0xFF6366F1)),
|
||||||
),
|
),
|
||||||
const TextSpan(text: ',以便为您提供更好的服务。'),
|
const TextSpan(text: ',以便为您提供更好的服务。'),
|
||||||
],
|
],
|
||||||
@ -103,7 +104,7 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
|
|||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
child: Text(
|
child: Text(
|
||||||
'再想想',
|
'再想想',
|
||||||
style: GoogleFonts.inter(
|
style: TextStyle(fontFamily: 'Inter',
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: const Color(0xFF6B7280),
|
color: const Color(0xFF6B7280),
|
||||||
@ -136,7 +137,7 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
|
|||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
child: Text(
|
child: Text(
|
||||||
'同意并继续',
|
'同意并继续',
|
||||||
style: GoogleFonts.inter(
|
style: TextStyle(fontFamily: 'Inter',
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
@ -342,14 +343,15 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
|
|||||||
return SafeArea(
|
return SafeArea(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// Logo - padding-top: calc(env(safe-area-inset-top) + 20px)
|
// Logo - padding-top: calc(env(safe-area-inset-top) + 60px)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 20),
|
padding: const EdgeInsets.only(top: 60),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Airhub',
|
'Airhub',
|
||||||
style: GoogleFonts.pressStart2p(
|
style: GoogleFonts.pressStart2p(
|
||||||
fontSize: 26, // Exact match
|
fontSize: 26,
|
||||||
color: const Color(0xFF4B2E83),
|
color: const Color(0xFF4B2E83),
|
||||||
|
letterSpacing: 2,
|
||||||
shadows: [
|
shadows: [
|
||||||
Shadow(
|
Shadow(
|
||||||
offset: const Offset(0, 2),
|
offset: const Offset(0, 2),
|
||||||
@ -388,7 +390,7 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
|
|||||||
onTap: _handleSmsLinkTap,
|
onTap: _handleSmsLinkTap,
|
||||||
child: Text(
|
child: Text(
|
||||||
'使用验证码登录',
|
'使用验证码登录',
|
||||||
style: GoogleFonts.inter(
|
style: TextStyle(fontFamily: 'Inter',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: const Color(0xFF4B2E83).withOpacity(0.7),
|
color: const Color(0xFF4B2E83).withOpacity(0.7),
|
||||||
),
|
),
|
||||||
@ -455,7 +457,7 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
|
|||||||
child: Text.rich(
|
child: Text.rich(
|
||||||
TextSpan(
|
TextSpan(
|
||||||
text: '我已阅读并同意',
|
text: '我已阅读并同意',
|
||||||
style: GoogleFonts.inter(
|
style: TextStyle(fontFamily: 'Inter',
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: const Color(0xFF4B2E83).withOpacity(0.6),
|
color: const Color(0xFF4B2E83).withOpacity(0.6),
|
||||||
height: 1.6,
|
height: 1.6,
|
||||||
@ -463,12 +465,12 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
|
|||||||
children: [
|
children: [
|
||||||
TextSpan(
|
TextSpan(
|
||||||
text: '《用户协议》',
|
text: '《用户协议》',
|
||||||
style: GoogleFonts.inter(color: const Color(0xFF6366F1)),
|
style: TextStyle(fontFamily: 'Inter', color: const Color(0xFF6366F1)),
|
||||||
),
|
),
|
||||||
const TextSpan(text: '和'),
|
const TextSpan(text: '和'),
|
||||||
TextSpan(
|
TextSpan(
|
||||||
text: '《隐私政策》',
|
text: '《隐私政策》',
|
||||||
style: GoogleFonts.inter(color: const Color(0xFF6366F1)),
|
style: TextStyle(fontFamily: 'Inter', color: const Color(0xFF6366F1)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -516,7 +518,7 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
|
|||||||
// Heading - font-size: 32px, font-weight: 700
|
// Heading - font-size: 32px, font-weight: 700
|
||||||
Text(
|
Text(
|
||||||
'欢迎使用 Airhub',
|
'欢迎使用 Airhub',
|
||||||
style: GoogleFonts.inter(
|
style: TextStyle(fontFamily: 'Inter',
|
||||||
fontSize: 32,
|
fontSize: 32,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: const Color(0xFF4B2E83),
|
color: const Color(0xFF4B2E83),
|
||||||
@ -527,7 +529,7 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
|
|||||||
// Subheading - font-size: 15px
|
// Subheading - font-size: 15px
|
||||||
Text(
|
Text(
|
||||||
'请输入您的手机号验证登录',
|
'请输入您的手机号验证登录',
|
||||||
style: GoogleFonts.inter(
|
style: TextStyle(fontFamily: 'Inter',
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: FontWeight.w400,
|
fontWeight: FontWeight.w400,
|
||||||
color: const Color(0xFF4B2E83).withOpacity(0.6),
|
color: const Color(0xFF4B2E83).withOpacity(0.6),
|
||||||
@ -611,7 +613,7 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
|
|||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
'+86',
|
'+86',
|
||||||
style: GoogleFonts.inter(
|
style: TextStyle(fontFamily: 'Inter',
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: const Color(0xFF4B2E83),
|
color: const Color(0xFF4B2E83),
|
||||||
@ -624,7 +626,7 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
|
|||||||
controller: _phoneController,
|
controller: _phoneController,
|
||||||
keyboardType: TextInputType.phone,
|
keyboardType: TextInputType.phone,
|
||||||
maxLength: 11,
|
maxLength: 11,
|
||||||
style: GoogleFonts.inter(
|
style: TextStyle(fontFamily: 'Inter',
|
||||||
fontSize: 17,
|
fontSize: 17,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: const Color(0xFF1F2937),
|
color: const Color(0xFF1F2937),
|
||||||
@ -632,7 +634,7 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
|
|||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
border: InputBorder.none,
|
border: InputBorder.none,
|
||||||
hintText: '请输入手机号',
|
hintText: '请输入手机号',
|
||||||
hintStyle: GoogleFonts.inter(
|
hintStyle: TextStyle(fontFamily: 'Inter',
|
||||||
fontSize: 17,
|
fontSize: 17,
|
||||||
fontWeight: FontWeight.w400,
|
fontWeight: FontWeight.w400,
|
||||||
color: const Color(0xFF4B2E83).withOpacity(0.35),
|
color: const Color(0xFF4B2E83).withOpacity(0.35),
|
||||||
@ -672,7 +674,7 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
|
|||||||
controller: _codeController,
|
controller: _codeController,
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
maxLength: 6,
|
maxLength: 6,
|
||||||
style: GoogleFonts.inter(
|
style: TextStyle(fontFamily: 'Inter',
|
||||||
fontSize: 17,
|
fontSize: 17,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: const Color(0xFF1F2937),
|
color: const Color(0xFF1F2937),
|
||||||
@ -680,7 +682,7 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
|
|||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
border: InputBorder.none,
|
border: InputBorder.none,
|
||||||
hintText: '输入验证码',
|
hintText: '输入验证码',
|
||||||
hintStyle: GoogleFonts.inter(
|
hintStyle: TextStyle(fontFamily: 'Inter',
|
||||||
fontSize: 17,
|
fontSize: 17,
|
||||||
fontWeight: FontWeight.w400,
|
fontWeight: FontWeight.w400,
|
||||||
color: const Color(0xFF4B2E83).withOpacity(0.35),
|
color: const Color(0xFF4B2E83).withOpacity(0.35),
|
||||||
@ -704,7 +706,7 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
|
|||||||
onTap: _countdown > 0 ? null : _sendCode,
|
onTap: _countdown > 0 ? null : _sendCode,
|
||||||
child: Text(
|
child: Text(
|
||||||
_countdown > 0 ? '${_countdown}s' : '获取验证码',
|
_countdown > 0 ? '${_countdown}s' : '获取验证码',
|
||||||
style: GoogleFonts.inter(
|
style: TextStyle(fontFamily: 'Inter',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: _countdown > 0
|
color: _countdown > 0
|
||||||
@ -755,7 +757,7 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
|
|||||||
)
|
)
|
||||||
: Text(
|
: Text(
|
||||||
'立即登录',
|
'立即登录',
|
||||||
style: GoogleFonts.inter(
|
style: TextStyle(fontFamily: 'Inter',
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
|
|||||||
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)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -53,6 +53,33 @@ Page resource error:
|
|||||||
isForMainFrame: ${error.isForMainFrame}
|
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(
|
..loadFlutterAsset(
|
||||||
|
|||||||
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -13,6 +13,15 @@ class AppColors {
|
|||||||
static const Color primaryPink = Color(0xFFF9A8D4);
|
static const Color primaryPink = Color(0xFFF9A8D4);
|
||||||
static const Color primaryIndigo = Color(0xFF6366F1);
|
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
|
// Additional Primary Colors from Button Gradient
|
||||||
static const Color cyan = Color(0xFF22D3EE); // #22D3EE
|
static const Color cyan = Color(0xFF22D3EE); // #22D3EE
|
||||||
static const Color deepPurple = Color(0xFF8B5CF6); // #8B5CF6
|
static const Color deepPurple = Color(0xFF8B5CF6); // #8B5CF6
|
||||||
@ -47,6 +56,39 @@ class AppColors {
|
|||||||
blurRadius: 32,
|
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
|
// Gradients
|
||||||
static const LinearGradient btnPrimaryGradient = LinearGradient(
|
static const LinearGradient btnPrimaryGradient = LinearGradient(
|
||||||
begin: Alignment.centerLeft,
|
begin: Alignment.centerLeft,
|
||||||
@ -59,4 +101,10 @@ class AppColors {
|
|||||||
],
|
],
|
||||||
stops: [0.0, 0.35, 0.65, 1.0],
|
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)],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -7,21 +7,71 @@ class GradientButton extends StatelessWidget {
|
|||||||
final double width;
|
final double width;
|
||||||
final double height;
|
final double height;
|
||||||
final bool isLoading;
|
final bool isLoading;
|
||||||
|
final Gradient? gradient;
|
||||||
|
|
||||||
const GradientButton({
|
const GradientButton({
|
||||||
super.key,
|
super.key,
|
||||||
required this.text,
|
required this.text,
|
||||||
this.onPressed,
|
this.onPressed,
|
||||||
this.width = double.infinity,
|
this.width = double.infinity,
|
||||||
this.height = 56.0,
|
this.height = 50.0, // Changed from 56 to 50 to match CSS
|
||||||
this.isLoading = false,
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Determine if button is disabled strictly by onPressed being null
|
|
||||||
// But we still want to show gradient for disabled state? Usually disabled is grey.
|
|
||||||
// Let's stick to the design where it might just opacity down.
|
|
||||||
final bool isDisabled = onPressed == null || isLoading;
|
final bool isDisabled = onPressed == null || isLoading;
|
||||||
|
|
||||||
return Opacity(
|
return Opacity(
|
||||||
@ -31,58 +81,69 @@ class GradientButton extends StatelessWidget {
|
|||||||
height: height,
|
height: height,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(height / 2),
|
borderRadius: BorderRadius.circular(height / 2),
|
||||||
gradient: AppColors.btnPrimaryGradient,
|
gradient: gradient ?? AppColors.btnPrimaryGradient,
|
||||||
boxShadow: [
|
boxShadow: _boxShadows,
|
||||||
// 0 4px 20px rgba(99, 102, 241, 0.4)
|
|
||||||
BoxShadow(
|
|
||||||
color: const Color(0xFF6366F1).withOpacity(0.4),
|
|
||||||
offset: const Offset(0, 4),
|
|
||||||
blurRadius: 20,
|
|
||||||
),
|
|
||||||
// 0 0 40px rgba(139, 92, 246, 0.2)
|
|
||||||
BoxShadow(
|
|
||||||
color: const Color(0xFF8B5CF6).withOpacity(0.2),
|
|
||||||
offset: const Offset(0, 0),
|
|
||||||
blurRadius: 40,
|
|
||||||
),
|
|
||||||
// inset 0 1px 0 rgba(255, 255, 255, 0.2) -> Not directly supported in simple BoxShadow
|
|
||||||
// can use a top border or inner shadow container trick if needed.
|
|
||||||
// For now, these outer shadows are sufficient for the "Glow".
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
child: Material(
|
child: Stack(
|
||||||
color: Colors.transparent,
|
children: [
|
||||||
child: InkWell(
|
// Shine overlay (top half gradient)
|
||||||
onTap: isDisabled ? null : onPressed,
|
Positioned.fill(
|
||||||
borderRadius: BorderRadius.circular(height / 2),
|
child: ClipRRect(
|
||||||
splashColor: Colors.white.withOpacity(0.2),
|
borderRadius: BorderRadius.circular(height / 2),
|
||||||
highlightColor: Colors.white.withOpacity(0.1),
|
child: DecoratedBox(
|
||||||
child: Center(
|
decoration: BoxDecoration(
|
||||||
child: isLoading
|
gradient: LinearGradient(
|
||||||
? const SizedBox(
|
begin: Alignment.topCenter,
|
||||||
width: 24,
|
end: Alignment.bottomCenter,
|
||||||
height: 24,
|
colors: [
|
||||||
child: CircularProgressIndicator(
|
Colors.white.withOpacity(0.15),
|
||||||
color: Colors.white,
|
Colors.transparent,
|
||||||
strokeWidth: 2.5,
|
],
|
||||||
),
|
stops: const [0.0, 0.5],
|
||||||
)
|
|
||||||
: Text(
|
|
||||||
text,
|
|
||||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
|
||||||
fontSize: 17,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
shadows: [
|
|
||||||
const Shadow(
|
|
||||||
offset: Offset(0, 1),
|
|
||||||
blurRadius: 2,
|
|
||||||
color: Colors.black12,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
// 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
|
# Generated by pub
|
||||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
packages:
|
packages:
|
||||||
|
args:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: args
|
||||||
|
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.7.0"
|
||||||
async:
|
async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -9,6 +17,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.13.0"
|
version: "2.13.0"
|
||||||
|
bluez:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: bluez
|
||||||
|
sha256: "61a7204381925896a374301498f2f5399e59827c6498ae1e924aaa598751b545"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.8.3"
|
||||||
boolean_selector:
|
boolean_selector:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -49,6 +65,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.19.1"
|
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:
|
crypto:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -57,6 +81,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.7"
|
version: "3.0.7"
|
||||||
|
dbus:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dbus
|
||||||
|
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.12"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -81,11 +113,91 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.1"
|
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:
|
flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
flutter_lints:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
@ -94,6 +206,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.2"
|
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:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
@ -144,6 +272,70 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
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:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -208,6 +400,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.17.0"
|
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:
|
native_toolchain_c:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -232,6 +432,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.1"
|
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:
|
path_provider:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -328,6 +536,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.2.1"
|
version: "0.2.1"
|
||||||
|
petitparser:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: petitparser
|
||||||
|
sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.0.1"
|
||||||
platform:
|
platform:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -352,6 +568,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.2.0"
|
||||||
|
rxdart:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: rxdart
|
||||||
|
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.28.0"
|
||||||
sky_engine:
|
sky_engine:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
@ -413,6 +637,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
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:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -477,6 +725,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.0"
|
version: "1.1.0"
|
||||||
|
xml:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: xml
|
||||||
|
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.6.1"
|
||||||
yaml:
|
yaml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@ -33,6 +33,9 @@ dependencies:
|
|||||||
webview_flutter: ^4.4.2
|
webview_flutter: ^4.4.2
|
||||||
permission_handler: ^11.0.0 # Good practice for future
|
permission_handler: ^11.0.0 # Good practice for future
|
||||||
google_fonts: ^6.1.0 # For 'Inter' and 'Press Start 2P' fonts
|
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:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
@ -46,7 +49,18 @@ flutter:
|
|||||||
- assets/www/icons/
|
- assets/www/icons/
|
||||||
- assets/www/storybook_videos/
|
- assets/www/storybook_videos/
|
||||||
- assets/www/story_covers/
|
- 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
|
# An image asset can refer to one or more resolution-specific "variants", see
|
||||||
# https://flutter.dev/to/resolution-aware-images
|
# 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 = __;
|
||||||
|
}
|
||||||