Compare commits

...

2 Commits

Author SHA1 Message Date
zyc
3c97eb7326 fix some page 2026-02-06 16:03:32 +08:00
zyc
54f13da9e3 登录页面dart 重写 2026-02-05 11:46:42 +08:00
44 changed files with 10249 additions and 161 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,32 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="128" height="128">
<!-- Pixel Badge AI - 8-bit style circular screen device -->
<style>
.frame { fill: #1E3A5F; }
.screen { fill: #3B82F6; }
.glow { fill: #60A5FA; }
.pixel { fill: #FFFFFF; }
</style>
<!-- Outer frame -->
<rect class="frame" x="6" y="6" width="20" height="20"/>
<rect class="frame" x="4" y="8" width="2" height="16"/>
<rect class="frame" x="26" y="8" width="2" height="16"/>
<rect class="frame" x="8" y="4" width="16" height="2"/>
<rect class="frame" x="8" y="26" width="16" height="2"/>
<rect fill="#1E3A5F" x="6" y="6" width="20" height="20"/>
<rect fill="#1E3A5F" x="4" y="8" width="2" height="16"/>
<rect fill="#1E3A5F" x="26" y="8" width="2" height="16"/>
<rect fill="#1E3A5F" x="8" y="4" width="16" height="2"/>
<rect fill="#1E3A5F" x="8" y="26" width="16" height="2"/>
<!-- Screen glow -->
<rect class="glow" x="8" y="8" width="16" height="16" opacity="0.3"/>
<rect fill="#60A5FA" x="8" y="8" width="16" height="16" opacity="0.3"/>
<!-- Screen -->
<rect class="screen" x="10" y="10" width="12" height="12"/>
<rect fill="#3B82F6" x="10" y="10" width="12" height="12"/>
<!-- Pixel face on screen -->
<rect class="pixel" x="12" y="12" width="2" height="2"/>
<rect class="pixel" x="18" y="12" width="2" height="2"/>
<rect class="pixel" x="12" y="18" width="2" height="2"/>
<rect class="pixel" x="14" y="20" width="4" height="2"/>
<rect class="pixel" x="18" y="18" width="2" height="2"/>
<rect fill="#FFFFFF" x="12" y="12" width="2" height="2"/>
<rect fill="#FFFFFF" x="18" y="12" width="2" height="2"/>
<rect fill="#FFFFFF" x="12" y="18" width="2" height="2"/>
<rect fill="#FFFFFF" x="14" y="20" width="4" height="2"/>
<rect fill="#FFFFFF" x="18" y="18" width="2" height="2"/>
<!-- Corner highlights -->
<rect class="glow" x="6" y="6" width="2" height="2" opacity="0.5"/>
<rect fill="#60A5FA" x="6" y="6" width="2" height="2" opacity="0.5"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -1,28 +1,23 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="128" height="128">
<!-- Pixel Badge Basic - 8-bit style circular screen device (gray) -->
<style>
.frame { fill: #475569; }
.screen { fill: #94A3B8; }
.light { fill: #CBD5E1; }
.pixel { fill: #F1F5F9; }
</style>
<!-- Outer frame -->
<rect class="frame" x="6" y="6" width="20" height="20"/>
<rect class="frame" x="4" y="8" width="2" height="16"/>
<rect class="frame" x="26" y="8" width="2" height="16"/>
<rect class="frame" x="8" y="4" width="16" height="2"/>
<rect class="frame" x="8" y="26" width="16" height="2"/>
<rect fill="#475569" x="6" y="6" width="20" height="20"/>
<rect fill="#475569" x="4" y="8" width="2" height="16"/>
<rect fill="#475569" x="26" y="8" width="2" height="16"/>
<rect fill="#475569" x="8" y="4" width="16" height="2"/>
<rect fill="#475569" x="8" y="26" width="16" height="2"/>
<!-- Screen -->
<rect class="screen" x="10" y="10" width="12" height="12"/>
<rect fill="#94A3B8" x="10" y="10" width="12" height="12"/>
<!-- Simple pixel pattern on screen -->
<rect class="pixel" x="12" y="12" width="2" height="2"/>
<rect class="pixel" x="18" y="12" width="2" height="2"/>
<rect class="pixel" x="14" y="16" width="4" height="2"/>
<rect class="pixel" x="12" y="18" width="8" height="2"/>
<rect fill="#F1F5F9" x="12" y="12" width="2" height="2"/>
<rect fill="#F1F5F9" x="18" y="12" width="2" height="2"/>
<rect fill="#F1F5F9" x="14" y="16" width="4" height="2"/>
<rect fill="#F1F5F9" x="12" y="18" width="8" height="2"/>
<!-- Corner highlights -->
<rect class="light" x="6" y="6" width="2" height="2" opacity="0.3"/>
<rect fill="#CBD5E1" x="6" y="6" width="2" height="2" opacity="0.3"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 965 B

View File

@ -1,39 +1,33 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="128" height="128">
<!-- Pixel Capybara - 8-bit style -->
<style>
.body { fill: #D4A574; }
.dark { fill: #A67B5B; }
.nose { fill: #8B5A3C; }
.eye { fill: #2D2D2D; }
.cheek { fill: #FFCCCB; }
</style>
<!-- Body -->
<rect class="body" x="8" y="14" width="16" height="12"/>
<rect class="body" x="6" y="16" width="2" height="8"/>
<rect class="body" x="24" y="16" width="2" height="8"/>
<rect fill="#D4A574" x="8" y="14" width="16" height="12"/>
<rect fill="#D4A574" x="6" y="16" width="2" height="8"/>
<rect fill="#D4A574" x="24" y="16" width="2" height="8"/>
<!-- Head -->
<rect class="body" x="10" y="8" width="12" height="8"/>
<rect class="body" x="8" y="10" width="2" height="4"/>
<rect class="body" x="22" y="10" width="2" height="4"/>
<rect fill="#D4A574" x="10" y="8" width="12" height="8"/>
<rect fill="#D4A574" x="8" y="10" width="2" height="4"/>
<rect fill="#D4A574" x="22" y="10" width="2" height="4"/>
<!-- Ears -->
<rect class="dark" x="8" y="6" width="4" height="4"/>
<rect class="dark" x="20" y="6" width="4" height="4"/>
<rect fill="#A67B5B" x="8" y="6" width="4" height="4"/>
<rect fill="#A67B5B" x="20" y="6" width="4" height="4"/>
<!-- Eyes -->
<rect class="eye" x="12" y="10" width="2" height="2"/>
<rect class="eye" x="18" y="10" width="2" height="2"/>
<rect fill="#2D2D2D" x="12" y="10" width="2" height="2"/>
<rect fill="#2D2D2D" x="18" y="10" width="2" height="2"/>
<!-- Nose -->
<rect class="nose" x="14" y="12" width="4" height="2"/>
<rect fill="#8B5A3C" x="14" y="12" width="4" height="2"/>
<!-- Cheeks -->
<rect class="cheek" x="10" y="12" width="2" height="2" opacity="0.6"/>
<rect class="cheek" x="20" y="12" width="2" height="2" opacity="0.6"/>
<rect fill="#FFCCCB" x="10" y="12" width="2" height="2" opacity="0.6"/>
<rect fill="#FFCCCB" x="20" y="12" width="2" height="2" opacity="0.6"/>
<!-- Legs -->
<rect class="dark" x="10" y="24" width="4" height="4"/>
<rect class="dark" x="18" y="24" width="4" height="4"/>
<rect fill="#A67B5B" x="10" y="24" width="4" height="4"/>
<rect fill="#A67B5B" x="18" y="24" width="4" height="4"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -1,38 +1,32 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="120" height="120">
<!-- Pixel Mystery Box - 8-bit Mario style -->
<style>
.gold-light { fill: #FCD34D; }
.gold-main { fill: #F59E0B; }
.gold-dark { fill: #D97706; }
.gold-shadow { fill: #92400E; }
.question { fill: #92400E; }
</style>
<!-- Box body -->
<rect class="gold-main" x="4" y="4" width="24" height="24"/>
<rect fill="#F59E0B" x="4" y="4" width="24" height="24"/>
<!-- Top highlight -->
<rect class="gold-light" x="4" y="4" width="24" height="4"/>
<rect class="gold-light" x="4" y="4" width="4" height="24"/>
<rect fill="#FCD34D" x="4" y="4" width="24" height="4"/>
<rect fill="#FCD34D" x="4" y="4" width="4" height="24"/>
<!-- Bottom shadow -->
<rect class="gold-dark" x="4" y="24" width="24" height="4"/>
<rect class="gold-dark" x="24" y="4" width="4" height="24"/>
<rect fill="#D97706" x="4" y="24" width="24" height="4"/>
<rect fill="#D97706" x="24" y="4" width="4" height="24"/>
<!-- Corner details -->
<rect class="gold-shadow" x="24" y="24" width="4" height="4"/>
<rect class="gold-light" x="4" y="4" width="4" height="4"/>
<rect fill="#92400E" x="24" y="24" width="4" height="4"/>
<rect fill="#FCD34D" x="4" y="4" width="4" height="4"/>
<!-- Inner border -->
<rect class="gold-dark" x="6" y="6" width="20" height="2"/>
<rect class="gold-dark" x="6" y="24" width="20" height="2"/>
<rect class="gold-dark" x="6" y="6" width="2" height="20"/>
<rect class="gold-dark" x="24" y="6" width="2" height="20"/>
<rect fill="#D97706" x="6" y="6" width="20" height="2"/>
<rect fill="#D97706" x="6" y="24" width="20" height="2"/>
<rect fill="#D97706" x="6" y="6" width="2" height="20"/>
<rect fill="#D97706" x="24" y="6" width="2" height="20"/>
<!-- Question mark - pixel style -->
<rect class="question" x="12" y="10" width="8" height="2"/>
<rect class="question" x="18" y="10" width="2" height="6"/>
<rect class="question" x="14" y="14" width="4" height="2"/>
<rect class="question" x="14" y="16" width="2" height="2"/>
<rect class="question" x="14" y="20" width="2" height="2"/>
<rect fill="#92400E" x="12" y="10" width="8" height="2"/>
<rect fill="#92400E" x="18" y="10" width="2" height="6"/>
<rect fill="#92400E" x="14" y="14" width="4" height="2"/>
<rect fill="#92400E" x="14" y="16" width="2" height="2"/>
<rect fill="#92400E" x="14" y="20" width="2" height="2"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

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

View File

@ -1,5 +1,10 @@
PODS:
- Flutter (1.0.0)
- flutter_blue_plus_darwin (0.0.2):
- Flutter
- FlutterMacOS
- image_picker_ios (0.0.1):
- Flutter
- permission_handler_apple (9.3.0):
- Flutter
- webview_flutter_wkwebview (0.0.1):
@ -8,12 +13,18 @@ PODS:
DEPENDENCIES:
- Flutter (from `Flutter`)
- flutter_blue_plus_darwin (from `.symlinks/plugins/flutter_blue_plus_darwin/darwin`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`)
EXTERNAL SOURCES:
Flutter:
:path: Flutter
flutter_blue_plus_darwin:
:path: ".symlinks/plugins/flutter_blue_plus_darwin/darwin"
image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
webview_flutter_wkwebview:
@ -21,6 +32,8 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d

View File

@ -1,90 +1,44 @@
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
// Import for Android features.
import 'package:webview_flutter_android/webview_flutter_android.dart';
// Import for iOS features.
import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart';
import 'pages/login_page.dart';
import 'pages/webview_page.dart';
import 'pages/home_page.dart';
import 'pages/bluetooth_page.dart';
import 'pages/wifi_config_page.dart';
import 'pages/device_control_page.dart';
import 'theme/app_theme.dart';
import 'pages/profile/profile_page.dart'; // Import ProfilePage
void main() {
runApp(const MaterialApp(home: AirhubWebView()));
runApp(const AirhubApp());
}
class AirhubWebView extends StatefulWidget {
const AirhubWebView({super.key});
@override
State<AirhubWebView> createState() => _AirhubWebViewState();
}
class _AirhubWebViewState extends State<AirhubWebView> {
late final WebViewController _controller;
@override
void initState() {
super.initState();
// #docregion platform_features
late final PlatformWebViewControllerCreationParams params;
if (WebViewPlatform.instance is WebKitWebViewPlatform) {
params = WebKitWebViewControllerCreationParams(
allowsInlineMediaPlayback: true,
mediaTypesRequiringUserAction: const <PlaybackMediaTypes>{},
);
} else {
params = const PlatformWebViewControllerCreationParams();
}
final WebViewController controller =
WebViewController.fromPlatformCreationParams(params);
// #enddocregion platform_features
controller
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setBackgroundColor(const Color(0x00000000))
..setNavigationDelegate(
NavigationDelegate(
onProgress: (int progress) {
debugPrint('WebView is loading (progress : $progress%)');
},
onPageStarted: (String url) {
debugPrint('Page started loading: $url');
},
onPageFinished: (String url) {
debugPrint('Page finished loading: $url');
},
onWebResourceError: (WebResourceError error) {
debugPrint('''
Page resource error:
code: ${error.errorCode}
description: ${error.description}
errorType: ${error.errorType}
isForMainFrame: ${error.isForMainFrame}
''');
},
),
)
..loadFlutterAsset('assets/www/login.html');
// #docregion platform_features
if (controller.platform is AndroidWebViewController) {
AndroidWebViewController.enableDebugging(true);
(controller.platform as AndroidWebViewController)
.setMediaPlaybackRequiresUserGesture(false);
}
// #enddocregion platform_features
_controller = controller;
}
class AirhubApp extends StatelessWidget {
const AirhubApp({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
top: false,
bottom: false,
child: WebViewWidget(controller: _controller),
),
return MaterialApp(
title: 'Airhub',
debugShowCheckedModeBanner: false,
theme: AppTheme.lightTheme,
// Initial Route
home: const DeviceControlPage(),
// Named Routes
routes: {
'/login': (context) => const LoginPage(),
'/home': (context) => const HomePage(), // Native Home
'/profile': (context) => const ProfilePage(), // Added Profile Route
'/webview_fallback': (context) =>
const WebViewPage(), // Keep for fallback
'/bluetooth': (context) => const BluetoothPage(),
'/wifi-config': (context) => const WifiConfigPage(),
'/device-control': (context) => const DeviceControlPage(),
},
// Handle unknown routes
onUnknownRoute: (settings) {
return MaterialPageRoute(builder: (_) => const WebViewPage());
},
);
}
}

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

File diff suppressed because it is too large Load Diff

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

View File

@ -0,0 +1,834 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:google_fonts/google_fonts.dart';
import '../theme/app_colors.dart';
import '../widgets/gradient_button.dart';
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
// State
bool _agreed = false;
bool _isLoading = false;
bool _showSmsView = false;
// SMS Login State
final TextEditingController _phoneController = TextEditingController();
final TextEditingController _codeController = TextEditingController();
int _countdown = 0;
Timer? _countdownTimer;
bool _isSmsSubmitting = false;
@override
void dispose() {
_phoneController.dispose();
_codeController.dispose();
_countdownTimer?.cancel();
super.dispose();
}
// ========== Agreement Dialog ==========
void _showAgreementDialog({required String action}) {
showDialog(
context: context,
barrierColor: Colors.black.withOpacity(0.5),
builder: (context) => _buildAgreementModal(action),
);
}
Widget _buildAgreementModal(String action) {
return Dialog(
backgroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Container(
width: 320,
padding: const EdgeInsets.fromLTRB(24, 28, 24, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Title
Text(
'服务协议',
style: TextStyle(fontFamily: 'Inter',
fontSize: 18,
fontWeight: FontWeight.w600,
color: const Color(0xFF1F2937),
),
),
const SizedBox(height: 12),
// Content
Text.rich(
TextSpan(
text: '请先阅读并同意',
style: TextStyle(fontFamily: 'Inter',
fontSize: 14,
color: const Color(0xFF6B7280),
height: 1.6,
),
children: [
TextSpan(
text: '《用户协议》',
style: TextStyle(fontFamily: 'Inter', color: const Color(0xFF6366F1)),
),
const TextSpan(text: ''),
TextSpan(
text: '《隐私政策》',
style: TextStyle(fontFamily: 'Inter', color: const Color(0xFF6366F1)),
),
const TextSpan(text: ',以便为您提供更好的服务。'),
],
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
// Buttons
Row(
children: [
// Cancel
Expanded(
child: GestureDetector(
onTap: () => Navigator.pop(context),
child: Container(
height: 48,
decoration: BoxDecoration(
color: const Color(0xFFF3F4F6),
borderRadius: BorderRadius.circular(24),
),
alignment: Alignment.center,
child: Text(
'再想想',
style: TextStyle(fontFamily: 'Inter',
fontSize: 15,
fontWeight: FontWeight.w500,
color: const Color(0xFF6B7280),
),
),
),
),
),
const SizedBox(width: 12),
// Confirm
Expanded(
child: GestureDetector(
onTap: () {
setState(() => _agreed = true);
Navigator.pop(context);
if (action == 'oneclick') {
_doOneClickLogin();
} else if (action == 'sms') {
setState(() => _showSmsView = true);
}
},
child: Container(
height: 48,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF6366F1), Color(0xFF8B5CF6)],
),
borderRadius: BorderRadius.circular(24),
),
alignment: Alignment.center,
child: Text(
'同意并继续',
style: TextStyle(fontFamily: 'Inter',
fontSize: 15,
fontWeight: FontWeight.w500,
color: Colors.white,
),
),
),
),
),
],
),
],
),
),
);
}
// ========== One-Click Login ==========
void _handleOneClickLogin() {
if (!_agreed) {
_showAgreementDialog(action: 'oneclick');
return;
}
_doOneClickLogin();
}
void _doOneClickLogin() {
setState(() => _isLoading = true);
_showToast('正在获取本机号码...');
Future.delayed(const Duration(milliseconds: 1500), () {
if (mounted) {
_showToast('登录成功');
Future.delayed(const Duration(seconds: 1), () {
if (mounted) {
setState(() => _isLoading = false);
Navigator.of(context).pushReplacementNamed('/home');
}
});
}
});
}
// ========== SMS Login ==========
void _handleSmsLinkTap() {
if (!_agreed) {
_showAgreementDialog(action: 'sms');
return;
}
setState(() => _showSmsView = true);
}
bool _isValidPhone(String phone) {
return RegExp(r'^1[3-9]\d{9}$').hasMatch(phone);
}
bool get _canSubmitSms {
return _isValidPhone(_phoneController.text) &&
_codeController.text.length == 6;
}
void _sendCode() {
if (!_isValidPhone(_phoneController.text)) {
_showToast('请输入正确的手机号');
return;
}
setState(() => _countdown = 60);
_showToast('验证码已发送');
_countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (_countdown <= 1) {
timer.cancel();
if (mounted) setState(() => _countdown = 0);
} else {
if (mounted) setState(() => _countdown--);
}
});
}
void _submitSmsLogin() {
if (!_canSubmitSms) return;
setState(() => _isSmsSubmitting = true);
Future.delayed(const Duration(milliseconds: 1500), () {
if (mounted) {
_showToast('登录成功');
Future.delayed(const Duration(seconds: 1), () {
if (mounted) {
setState(() => _isSmsSubmitting = false);
Navigator.of(context).pushReplacementNamed('/home');
}
});
}
});
}
// ========== Toast ==========
void _showToast(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
),
);
}
// ========== Build ==========
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
resizeToAvoidBottomInset: true,
body: Stack(
children: [
// Background
_buildGradientBackground(),
// Main Login View
_buildMainLoginView(),
// SMS View (overlay)
if (_showSmsView) _buildSmsView(),
],
),
);
}
// ========== Gradient Background ==========
Widget _buildGradientBackground() {
final size = MediaQuery.of(context).size;
return Positioned.fill(
child: Stack(
children: [
// Layer 1 - Pink (bottom-left)
Positioned(
bottom: -size.width * 0.5,
left: -size.width * 0.5,
width: size.width * 2,
height: size.width * 2,
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
const Color(0xFFFFC8DC).withOpacity(0.6),
Colors.transparent,
],
radius: 0.5,
),
),
),
),
// Layer 2 - Cyan (top-right)
Positioned(
top: -size.width * 0.5,
right: -size.width * 0.5,
width: size.width * 2,
height: size.width * 2,
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
const Color(0xFFB4F0F0).withOpacity(0.5),
Colors.transparent,
],
radius: 0.5,
),
),
),
),
// Layer 3 - Lavender (center)
Positioned(
top: size.height * 0.2,
left: size.width * 0.1,
width: size.width * 1.2,
height: size.width * 1.2,
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
const Color(0xFFE6D2FA).withOpacity(0.45),
Colors.transparent,
],
radius: 0.5,
),
),
),
),
],
),
);
}
// ========== Main Login View ==========
Widget _buildMainLoginView() {
final bottomPadding = MediaQuery.of(context).padding.bottom;
final topPadding = MediaQuery.of(context).padding.top;
return SafeArea(
child: Column(
children: [
// Logo - padding-top: calc(env(safe-area-inset-top) + 60px)
Padding(
padding: const EdgeInsets.only(top: 60),
child: Text(
'Airhub',
style: GoogleFonts.pressStart2p(
fontSize: 26,
color: const Color(0xFF4B2E83),
letterSpacing: 2,
shadows: [
Shadow(
offset: const Offset(0, 2),
blurRadius: 10,
color: const Color(0xFF8B5CF6).withOpacity(0.3),
),
Shadow(
offset: const Offset(0, 0),
blurRadius: 40,
color: const Color(0xFF8B5CF6).withOpacity(0.15),
),
],
),
),
),
// Mascot - flex: 1, centered
Expanded(child: Center(child: _FloatingMascot())),
// Bottom Form
Padding(
padding: EdgeInsets.fromLTRB(32, 0, 32, bottomPadding + 40),
child: Column(
children: [
// Primary Button - height: 56px, border-radius: 28px
GradientButton(
text: '本机号码一键登录',
onPressed: _handleOneClickLogin,
isLoading: _isLoading,
height: 56,
),
// SMS Link - margin-top: 20px, font-size: 14px
const SizedBox(height: 20),
GestureDetector(
onTap: _handleSmsLinkTap,
child: Text(
'使用验证码登录',
style: TextStyle(fontFamily: 'Inter',
fontSize: 14,
color: const Color(0xFF4B2E83).withOpacity(0.7),
),
),
),
// Agreement - margin-top: 28px
const SizedBox(height: 28),
_buildAgreementCheckbox(),
],
),
),
],
),
);
}
// ========== Agreement Checkbox ==========
Widget _buildAgreementCheckbox() {
return GestureDetector(
onTap: () => setState(() => _agreed = !_agreed),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Checkbox - 18x18, border-radius: 5px
Container(
width: 18,
height: 18,
margin: const EdgeInsets.only(top: 1), // Fine-tune alignment
decoration: BoxDecoration(
gradient: _agreed
? const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF6366F1), Color(0xFF8B5CF6)],
)
: null,
color: _agreed ? null : const Color(0x99FFFFFF),
borderRadius: BorderRadius.circular(5),
border: Border.all(
color: _agreed
? Colors.transparent
: const Color(0xFF4B2E83).withOpacity(0.3),
width: 1.5,
),
),
child: _agreed
? const Center(
child: Text(
'',
style: TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w700,
),
),
)
: null,
),
const SizedBox(width: 10), // gap: 10px
// Text - font-size: 12px, line-height: 1.6
Flexible(
child: Text.rich(
TextSpan(
text: '我已阅读并同意',
style: TextStyle(fontFamily: 'Inter',
fontSize: 12,
color: const Color(0xFF4B2E83).withOpacity(0.6),
height: 1.6,
),
children: [
TextSpan(
text: '《用户协议》',
style: TextStyle(fontFamily: 'Inter', color: const Color(0xFF6366F1)),
),
const TextSpan(text: ''),
TextSpan(
text: '《隐私政策》',
style: TextStyle(fontFamily: 'Inter', color: const Color(0xFF6366F1)),
),
],
),
),
),
],
),
);
}
// ========== SMS View ==========
Widget _buildSmsView() {
final topPadding = MediaQuery.of(context).padding.top;
final bottomPadding = MediaQuery.of(context).padding.bottom;
return Positioned.fill(
child: Container(
color: Colors.white,
child: Stack(
children: [
// Background
_buildGradientBackground(),
// Content
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header - padding-top: 60px (fixed)
Padding(
padding: const EdgeInsets.fromLTRB(24, 60, 24, 16),
child: _buildBackButton(),
),
// Body
Expanded(
child: SingleChildScrollView(
padding: EdgeInsets.fromLTRB(
32,
60,
32,
bottomPadding + 40,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Heading - font-size: 32px, font-weight: 700
Text(
'欢迎使用 Airhub',
style: TextStyle(fontFamily: 'Inter',
fontSize: 32,
fontWeight: FontWeight.w700,
color: const Color(0xFF4B2E83),
letterSpacing: -0.5,
),
),
const SizedBox(height: 12),
// Subheading - font-size: 15px
Text(
'请输入您的手机号验证登录',
style: TextStyle(fontFamily: 'Inter',
fontSize: 15,
fontWeight: FontWeight.w400,
color: const Color(0xFF4B2E83).withOpacity(0.6),
),
),
const SizedBox(height: 48),
// Phone Input
_buildPhoneInput(),
const SizedBox(height: 24),
// Code Input
_buildCodeInput(),
const SizedBox(height: 48),
// Submit Button - height: 60px, border-radius: 30px
_buildSmsSubmitButton(),
],
),
),
),
],
),
],
),
),
);
}
Widget _buildBackButton() {
return GestureDetector(
onTap: () => setState(() => _showSmsView = false),
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: const Color(0x66FFFFFF),
border: Border.all(color: const Color(0x99FFFFFF)),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.03),
offset: const Offset(0, 4),
blurRadius: 12,
),
],
),
child: const Center(
child: Icon(Icons.arrow_back, size: 22, color: Color(0xFF4B2E83)),
),
),
);
}
Widget _buildPhoneInput() {
return Container(
height: 64,
decoration: BoxDecoration(
color: const Color(0x8CFFFFFF),
border: Border.all(color: const Color(0xCCFFFFFF)),
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: const Color(0xFF8B5CF6).withOpacity(0.03),
offset: const Offset(0, 2),
blurRadius: 10,
),
],
),
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Row(
children: [
// Prefix
Container(
padding: const EdgeInsets.only(right: 16),
margin: const EdgeInsets.only(right: 16),
decoration: const BoxDecoration(
border: Border(
right: BorderSide(color: Color(0x1A4B2E83), width: 1),
),
),
child: Text(
'+86',
style: TextStyle(fontFamily: 'Inter',
fontSize: 16,
fontWeight: FontWeight.w600,
color: const Color(0xFF4B2E83),
),
),
),
// Input
Expanded(
child: TextField(
controller: _phoneController,
keyboardType: TextInputType.phone,
maxLength: 11,
style: TextStyle(fontFamily: 'Inter',
fontSize: 17,
fontWeight: FontWeight.w500,
color: const Color(0xFF1F2937),
),
decoration: InputDecoration(
border: InputBorder.none,
hintText: '请输入手机号',
hintStyle: TextStyle(fontFamily: 'Inter',
fontSize: 17,
fontWeight: FontWeight.w400,
color: const Color(0xFF4B2E83).withOpacity(0.35),
),
counterText: '',
),
cursorColor: const Color(0xFF8B5CF6),
onChanged: (_) => setState(() {}),
),
),
],
),
);
}
Widget _buildCodeInput() {
return Container(
height: 64,
decoration: BoxDecoration(
color: const Color(0x8CFFFFFF),
border: Border.all(color: const Color(0xCCFFFFFF)),
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: const Color(0xFF8B5CF6).withOpacity(0.03),
offset: const Offset(0, 2),
blurRadius: 10,
),
],
),
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Row(
children: [
// Input
Expanded(
child: TextField(
controller: _codeController,
keyboardType: TextInputType.number,
maxLength: 6,
style: TextStyle(fontFamily: 'Inter',
fontSize: 17,
fontWeight: FontWeight.w500,
color: const Color(0xFF1F2937),
),
decoration: InputDecoration(
border: InputBorder.none,
hintText: '输入验证码',
hintStyle: TextStyle(fontFamily: 'Inter',
fontSize: 17,
fontWeight: FontWeight.w400,
color: const Color(0xFF4B2E83).withOpacity(0.35),
),
counterText: '',
),
cursorColor: const Color(0xFF8B5CF6),
onChanged: (_) => setState(() {}),
),
),
// Send Button
Container(
padding: const EdgeInsets.only(left: 14),
margin: const EdgeInsets.only(left: 10),
decoration: const BoxDecoration(
border: Border(
left: BorderSide(color: Color(0x1A4B2E83), width: 1),
),
),
child: GestureDetector(
onTap: _countdown > 0 ? null : _sendCode,
child: Text(
_countdown > 0 ? '${_countdown}s' : '获取验证码',
style: TextStyle(fontFamily: 'Inter',
fontSize: 14,
fontWeight: FontWeight.w600,
color: _countdown > 0
? const Color(0xFF9CA3AF)
: const Color(0xFF6366F1),
),
),
),
),
],
),
);
}
Widget _buildSmsSubmitButton() {
final bool enabled = _canSubmitSms && !_isSmsSubmitting;
return GestureDetector(
onTap: enabled ? _submitSmsLogin : null,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: enabled ? 1.0 : 0.6,
child: Container(
width: double.infinity,
height: 60,
decoration: BoxDecoration(
gradient: AppColors.btnPrimaryGradient,
borderRadius: BorderRadius.circular(30),
boxShadow: enabled
? [
BoxShadow(
color: const Color(0xFF6366F1).withOpacity(0.3),
offset: const Offset(0, 10),
blurRadius: 30,
),
]
: null,
),
alignment: Alignment.center,
child: _isSmsSubmitting
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2.5,
),
)
: Text(
'立即登录',
style: TextStyle(fontFamily: 'Inter',
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
),
);
}
}
// ========== Floating Mascot Widget ==========
class _FloatingMascot extends StatefulWidget {
@override
State<_FloatingMascot> createState() => _FloatingMascotState();
}
class _FloatingMascotState extends State<_FloatingMascot>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(seconds: 5),
vsync: this,
)..repeat(reverse: true);
_animation = Tween<double>(
begin: 0,
end: -15,
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut));
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Transform.translate(
offset: Offset(0, _animation.value),
child: child,
);
},
child: Container(
width: 220,
height: 220,
decoration: BoxDecoration(
boxShadow: [
BoxShadow(
color: const Color(0xFF8B5CF6).withOpacity(0.25),
offset: const Offset(0, 20),
blurRadius: 40,
),
],
),
child: Image.asset(
'assets/www/mascot.png',
width: 220,
height: 220,
fit: BoxFit.contain,
),
),
);
}
}

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

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

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

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

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

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

View 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个工作日内处理。');
},
);
}
}

View 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服务商提供推送、地图等基础服务', '云服务商:提供数据存储和计算服务']),
],
);
}

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

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

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

View File

@ -0,0 +1,109 @@
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:webview_flutter_android/webview_flutter_android.dart';
import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart';
class WebViewPage extends StatefulWidget {
const WebViewPage({super.key});
@override
State<WebViewPage> createState() => _WebViewPageState();
}
class _WebViewPageState extends State<WebViewPage> {
late final WebViewController _controller;
@override
void initState() {
super.initState();
late final PlatformWebViewControllerCreationParams params;
if (WebViewPlatform.instance is WebKitWebViewPlatform) {
params = WebKitWebViewControllerCreationParams(
allowsInlineMediaPlayback: true,
mediaTypesRequiringUserAction: const <PlaybackMediaTypes>{},
);
} else {
params = const PlatformWebViewControllerCreationParams();
}
final WebViewController controller =
WebViewController.fromPlatformCreationParams(params);
controller
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setBackgroundColor(const Color(0x00000000))
..setNavigationDelegate(
NavigationDelegate(
onProgress: (int progress) {
debugPrint('WebView is loading (progress : $progress%)');
},
onPageStarted: (String url) {
debugPrint('Page started loading: $url');
},
onPageFinished: (String url) {
debugPrint('Page finished loading: $url');
},
onWebResourceError: (WebResourceError error) {
debugPrint('''
Page resource error:
code: ${error.errorCode}
description: ${error.description}
errorType: ${error.errorType}
isForMainFrame: ${error.isForMainFrame}
''');
},
onNavigationRequest: (NavigationRequest request) {
if (request.url.contains('bluetooth.html')) {
// Intercept bluetooth.html and navigate to native BluetoothPage
debugPrint(
'Intercepting navigation to bluetooth.html -> Native Route',
);
// We need context to navigate, but initState doesn't have it easily available
// inside this callback unless we store a reference or use a GlobalKey.
// However, since we are in a State object, we can use 'context' if mounted?
// Actually, NavigationDelegate callbacks are not bound to context directly.
// We should probably move the controller creation or use a helper.
// BUT, since this is a callback, 'context' of the State is available in the closure!
// Warning: don't use 'context' across async gaps without checking mounted.
// Since this is synchronous, it should be fine to schedule a navigation.
// We must return NavigationDecision.prevent to stop WebView.
// And execute navigation asynchronously to avoid blocking.
Future.microtask(() {
if (mounted) {
Navigator.of(context).pushNamed('/bluetooth');
}
});
return NavigationDecision.prevent;
}
return NavigationDecision.navigate;
},
),
)
..loadFlutterAsset(
'assets/www/index.html',
); // CHANGED: Load Home directly
if (controller.platform is AndroidWebViewController) {
AndroidWebViewController.enableDebugging(true);
(controller.platform as AndroidWebViewController)
.setMediaPlaybackRequiresUserGesture(false);
}
_controller = controller;
}
@override
Widget build(BuildContext context) {
// We want the WebView to control the full screen, including status bar usually,
// but SafeArea might be needed if the Web content doesn't handle padding.
// Our CSS handles env(safe-area-inset-top), so we can disable SafeArea here
// or keep top:false.
return Scaffold(
backgroundColor: Colors.white,
body: WebViewWidget(controller: _controller),
);
}
}

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

View File

@ -0,0 +1,110 @@
import 'package:flutter/material.dart';
class AppColors {
// Gradient Colors (Backgrounds)
static const Color gradientPink = Color(0xFFFEF0F5);
static const Color gradientLavender = Color(0xFFF5F0FE);
static const Color gradientBlue = Color(0xFFEEF8FC);
static const Color gradientMint = Color(0xFFF0FCFA);
// Primary Colors
static const Color primaryPurple = Color(0xFFA78BFA);
static const Color primaryBlue = Color(0xFF93C5FD);
static const Color primaryPink = Color(0xFFF9A8D4);
static const Color primaryIndigo = Color(0xFF6366F1);
// Capybara Theme Colors
static const Color capybaraSand = Color(0xFFFDF9F3);
static const Color capybaraBrown = Color(0xFF4B2404);
static const Color capybaraWarmGrey = Color(0xFF4B5563);
static const Color capybaraAmber = Color(0xFFEA9A3E); // Selected state ring color
static const Color capybaraSelectedBg = Color(0xFFFFF7ED); // Selected card background
static const Color capybaraPlushLight = Color(0xFFECCFA8); // Plush gradient start
static const Color capybaraPlushDark = Color(0xFFC99672); // Plush gradient end
// Additional Primary Colors from Button Gradient
static const Color cyan = Color(0xFF22D3EE); // #22D3EE
static const Color deepPurple = Color(0xFF8B5CF6); // #8B5CF6
// Text Colors
static const Color textPrimary = Color(0xFF1F2937);
static const Color textSecondary = Color(0xFF6B7280);
static const Color textLight = Color(0xFF9CA3AF);
// Backgrounds
static const Color bgBase = Color(0xFFFAFBFC);
static const Color bgCard = Color(
0xB3FFFFFF,
); // rgba(255, 255, 255, 0.7) -> 0.7 * 255 = 178.5 -> B3
// Shadows
static final BoxShadow shadowSoft = BoxShadow(
color: Colors.black.withOpacity(0.04),
offset: const Offset(0, 4),
blurRadius: 24,
);
static final BoxShadow shadowMedium = BoxShadow(
color: Colors.black.withOpacity(0.08),
offset: const Offset(0, 8),
blurRadius: 32,
);
static final BoxShadow shadowButton = BoxShadow(
color: const Color(0xFFA78BFA).withOpacity(0.3),
offset: const Offset(0, 8),
blurRadius: 32,
);
// Plush button shadows (warm brown glow for Capybara theme)
static final List<BoxShadow> shadowPlushButton = [
BoxShadow(
color: const Color(0xFFC99672).withOpacity(0.35),
offset: Offset.zero,
blurRadius: 15,
),
BoxShadow(
color: const Color(0xFFC99672).withOpacity(0.25),
offset: Offset.zero,
blurRadius: 30,
),
BoxShadow(
color: const Color(0xFFC99672).withOpacity(0.4),
offset: const Offset(0, 6),
blurRadius: 20,
),
];
// Primary button shadows (purple/indigo glow)
static final List<BoxShadow> shadowPrimaryButton = [
BoxShadow(
color: const Color(0xFF6366F1).withOpacity(0.4),
offset: const Offset(0, 4),
blurRadius: 20,
),
BoxShadow(
color: const Color(0xFF8B5CF6).withOpacity(0.2),
offset: Offset.zero,
blurRadius: 40,
),
];
// Gradients
static const LinearGradient btnPrimaryGradient = LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
Color(0xFF22D3EE),
Color(0xFF3B82F6),
Color(0xFF6366F1),
Color(0xFF8B5CF6),
],
stops: [0.0, 0.35, 0.65, 1.0],
);
static const LinearGradient btnPlushGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFECCFA8), Color(0xFFC99672)],
);
}

View File

@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'app_colors.dart';
class AppTheme {
static ThemeData get lightTheme {
return ThemeData(
useMaterial3: true,
scaffoldBackgroundColor: AppColors.bgBase,
primaryColor: AppColors.primaryIndigo,
colorScheme: ColorScheme.fromSeed(
seedColor: AppColors.primaryIndigo,
primary: AppColors.primaryIndigo,
secondary: AppColors.primaryPurple,
surface: AppColors.bgBase,
background: AppColors.bgBase,
),
// We will rely on system fonts for now, to replicate 'Inter' look
// we can adjust weights later.
fontFamilyFallback: const [
'Inter',
'Roboto',
'PingFang SC',
'Helvetica Neue',
],
textTheme: const TextTheme(
// h1 / Large Headings
displayLarge: TextStyle(
color: AppColors.textPrimary,
fontSize: 32,
fontWeight: FontWeight.w700, // Bold
letterSpacing: -0.5,
),
// h2 / Subheadings
displayMedium: TextStyle(
color: AppColors.textPrimary,
fontSize: 24,
fontWeight: FontWeight.w600, // Semi-bold
),
// Body Text
bodyLarge: TextStyle(
color: AppColors.textPrimary,
fontSize: 16,
fontWeight: FontWeight.w400, // Normal
height: 1.5,
),
bodyMedium: TextStyle(
color: AppColors.textSecondary,
fontSize: 14,
fontWeight: FontWeight.w400,
),
// Small captions
bodySmall: TextStyle(color: AppColors.textLight, fontSize: 12),
// Button Text
labelLarge: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
),
),
);
}
// Animation Curves from CSS
// --ease-smooth: cubic-bezier(0.4, 0, 0.2, 1);
static const Curve easeSmooth = Cubic(0.4, 0, 0.2, 1);
// --ease-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
static const Curve easeBounce = Cubic(0.34, 1.56, 0.64, 1);
}

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

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

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

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

View File

@ -0,0 +1,151 @@
import 'package:flutter/material.dart';
import '../theme/app_colors.dart';
class GradientButton extends StatelessWidget {
final String text;
final VoidCallback? onPressed;
final double width;
final double height;
final bool isLoading;
final Gradient? gradient;
const GradientButton({
super.key,
required this.text,
this.onPressed,
this.width = double.infinity,
this.height = 50.0, // Changed from 56 to 50 to match CSS
this.isLoading = false,
this.gradient,
});
// Check if using plush/capybara gradient
bool get _isPlushGradient {
if (gradient == null) return false;
if (gradient is LinearGradient) {
final lg = gradient as LinearGradient;
// Check if colors match plush gradient colors
if (lg.colors.length >= 2) {
return lg.colors.first.value == 0xFFECCFA8 ||
lg.colors.last.value == 0xFFC99672;
}
}
return false;
}
List<BoxShadow> get _boxShadows {
if (_isPlushGradient) {
// Warm brown glow for Capybara plush gradient
return [
BoxShadow(
color: const Color(0xFFC99672).withOpacity(0.35),
offset: Offset.zero,
blurRadius: 15,
),
BoxShadow(
color: const Color(0xFFC99672).withOpacity(0.25),
offset: Offset.zero,
blurRadius: 30,
),
BoxShadow(
color: const Color(0xFFC99672).withOpacity(0.4),
offset: const Offset(0, 6),
blurRadius: 20,
),
];
} else {
// Purple/indigo glow for primary gradient
return [
BoxShadow(
color: const Color(0xFF6366F1).withOpacity(0.4),
offset: const Offset(0, 4),
blurRadius: 20,
),
BoxShadow(
color: const Color(0xFF8B5CF6).withOpacity(0.2),
offset: Offset.zero,
blurRadius: 40,
),
];
}
}
@override
Widget build(BuildContext context) {
final bool isDisabled = onPressed == null || isLoading;
return Opacity(
opacity: isDisabled ? 0.7 : 1.0,
child: Container(
width: width,
height: height,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(height / 2),
gradient: gradient ?? AppColors.btnPrimaryGradient,
boxShadow: _boxShadows,
),
child: Stack(
children: [
// Shine overlay (top half gradient)
Positioned.fill(
child: ClipRRect(
borderRadius: BorderRadius.circular(height / 2),
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.white.withOpacity(0.15),
Colors.transparent,
],
stops: const [0.0, 0.5],
),
),
),
),
),
// Button content
Material(
color: Colors.transparent,
child: InkWell(
onTap: isDisabled ? null : onPressed,
borderRadius: BorderRadius.circular(height / 2),
splashColor: Colors.white.withOpacity(0.2),
highlightColor: Colors.white.withOpacity(0.1),
child: Center(
child: isLoading
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2.5,
),
)
: Text(
text,
style: TextStyle(
fontFamily: 'Inter',
fontSize: _isPlushGradient ? 18 : 17,
fontWeight:
_isPlushGradient ? FontWeight.w700 : FontWeight.w600,
color: Colors.white,
shadows: const [
Shadow(
offset: Offset(0, 1),
blurRadius: 2,
color: Colors.black12,
),
],
),
),
),
),
),
],
),
),
);
}
}

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

View 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 越界问题。

View File

@ -1,6 +1,14 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
@ -9,6 +17,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.13.0"
bluez:
dependency: transitive
description:
name: bluez
sha256: "61a7204381925896a374301498f2f5399e59827c6498ae1e924aaa598751b545"
url: "https://pub.dev"
source: hosted
version: "0.8.3"
boolean_selector:
dependency: transitive
description:
@ -33,6 +49,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.2"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
collection:
dependency: transitive
description:
@ -41,6 +65,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.19.1"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
url: "https://pub.dev"
source: hosted
version: "0.3.5+2"
crypto:
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
dbus:
dependency: transitive
description:
name: dbus
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
url: "https://pub.dev"
source: hosted
version: "0.7.12"
fake_async:
dependency: transitive
description:
@ -49,11 +97,107 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c
url: "https://pub.dev"
source: hosted
version: "2.1.5"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
file_selector_linux:
dependency: transitive
description:
name: file_selector_linux
sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0"
url: "https://pub.dev"
source: hosted
version: "0.9.4"
file_selector_macos:
dependency: transitive
description:
name: file_selector_macos
sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a"
url: "https://pub.dev"
source: hosted
version: "0.9.5"
file_selector_platform_interface:
dependency: transitive
description:
name: file_selector_platform_interface
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
url: "https://pub.dev"
source: hosted
version: "2.7.0"
file_selector_windows:
dependency: transitive
description:
name: file_selector_windows
sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd"
url: "https://pub.dev"
source: hosted
version: "0.9.3+5"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_blue_plus:
dependency: "direct main"
description:
name: flutter_blue_plus
sha256: "69a8c87c11fc792e8cf0f997d275484fbdb5143ac9f0ac4d424429700cb4e0ed"
url: "https://pub.dev"
source: hosted
version: "1.36.8"
flutter_blue_plus_android:
dependency: transitive
description:
name: flutter_blue_plus_android
sha256: "6f7fe7e69659c30af164a53730707edc16aa4d959e4c208f547b893d940f853d"
url: "https://pub.dev"
source: hosted
version: "7.0.4"
flutter_blue_plus_darwin:
dependency: transitive
description:
name: flutter_blue_plus_darwin
sha256: "682982862c1d964f4d54a3fb5fccc9e59a066422b93b7e22079aeecd9c0d38f8"
url: "https://pub.dev"
source: hosted
version: "7.0.3"
flutter_blue_plus_linux:
dependency: transitive
description:
name: flutter_blue_plus_linux
sha256: "56b0c45edd0a2eec8f85bd97a26ac3cd09447e10d0094fed55587bf0592e3347"
url: "https://pub.dev"
source: hosted
version: "7.0.3"
flutter_blue_plus_platform_interface:
dependency: transitive
description:
name: flutter_blue_plus_platform_interface
sha256: "84fbd180c50a40c92482f273a92069960805ce324e3673ad29c41d2faaa7c5c2"
url: "https://pub.dev"
source: hosted
version: "7.0.0"
flutter_blue_plus_web:
dependency: transitive
description:
name: flutter_blue_plus_web
sha256: a1aceee753d171d24c0e0cdadb37895b5e9124862721f25f60bb758e20b72c99
url: "https://pub.dev"
source: hosted
version: "7.0.2"
flutter_lints:
dependency: "direct dev"
description:
@ -62,6 +206,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.2"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1
url: "https://pub.dev"
source: hosted
version: "2.0.33"
flutter_svg:
dependency: "direct main"
description:
name: flutter_svg
sha256: "87fbd7c534435b6c5d9d98b01e1fd527812b82e68ddd8bd35fc45ed0fa8f0a95"
url: "https://pub.dev"
source: hosted
version: "2.2.3"
flutter_test:
dependency: "direct dev"
description: flutter
@ -72,6 +232,110 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
google_fonts:
dependency: "direct main"
description:
name: google_fonts
sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055
url: "https://pub.dev"
source: hosted
version: "6.3.3"
hooks:
dependency: transitive
description:
name: hooks
sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
http:
dependency: transitive
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
image_picker:
dependency: "direct main"
description:
name: image_picker
sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
image_picker_android:
dependency: transitive
description:
name: image_picker_android
sha256: "518a16108529fc18657a3e6dde4a043dc465d16596d20ab2abd49a4cac2e703d"
url: "https://pub.dev"
source: hosted
version: "0.8.13+13"
image_picker_for_web:
dependency: transitive
description:
name: image_picker_for_web
sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214"
url: "https://pub.dev"
source: hosted
version: "3.1.1"
image_picker_ios:
dependency: transitive
description:
name: image_picker_ios
sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588
url: "https://pub.dev"
source: hosted
version: "0.8.13+6"
image_picker_linux:
dependency: transitive
description:
name: image_picker_linux
sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4"
url: "https://pub.dev"
source: hosted
version: "0.2.2"
image_picker_macos:
dependency: transitive
description:
name: image_picker_macos
sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91"
url: "https://pub.dev"
source: hosted
version: "0.2.2+1"
image_picker_platform_interface:
dependency: transitive
description:
name: image_picker_platform_interface
sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c"
url: "https://pub.dev"
source: hosted
version: "2.11.1"
image_picker_windows:
dependency: transitive
description:
name: image_picker_windows
sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae
url: "https://pub.dev"
source: hosted
version: "0.2.2"
leak_tracker:
dependency: transitive
description:
@ -104,6 +368,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
@ -128,6 +400,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.17.0"
mime:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
native_toolchain_c:
dependency: transitive
description:
name: native_toolchain_c
sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac"
url: "https://pub.dev"
source: hosted
version: "0.17.4"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: "983c7fa1501f6dcc0cb7af4e42072e9993cb28d73604d25ebf4dab08165d997e"
url: "https://pub.dev"
source: hosted
version: "9.2.5"
path:
dependency: transitive
description:
@ -136,6 +432,62 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_parsing:
dependency: transitive
description:
name: path_parsing
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
path_provider:
dependency: transitive
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e
url: "https://pub.dev"
source: hosted
version: "2.2.22"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
url: "https://pub.dev"
source: hosted
version: "2.6.0"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.dev"
source: hosted
version: "2.2.1"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
permission_handler:
dependency: "direct main"
description:
@ -184,6 +536,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.2.1"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1"
url: "https://pub.dev"
source: hosted
version: "7.0.1"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
@ -192,6 +560,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.8"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
rxdart:
dependency: transitive
description:
name: rxdart
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
url: "https://pub.dev"
source: hosted
version: "0.28.0"
sky_engine:
dependency: transitive
description: flutter
@ -245,6 +629,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.7"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
vector_graphics:
dependency: transitive
description:
name: vector_graphics
sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6
url: "https://pub.dev"
source: hosted
version: "1.1.19"
vector_graphics_codec:
dependency: transitive
description:
name: vector_graphics_codec
sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146"
url: "https://pub.dev"
source: hosted
version: "1.1.13"
vector_graphics_compiler:
dependency: transitive
description:
name: vector_graphics_compiler
sha256: "201e876b5d52753626af64b6359cd13ac6011b80728731428fd34bc840f71c9b"
url: "https://pub.dev"
source: hosted
version: "1.1.20"
vector_math:
dependency: transitive
description:
@ -301,6 +717,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.23.5"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.dev"
source: hosted
version: "6.6.1"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.10.7 <4.0.0"
flutter: ">=3.35.0"
flutter: ">=3.38.4"

View File

@ -32,6 +32,10 @@ dependencies:
sdk: flutter
webview_flutter: ^4.4.2
permission_handler: ^11.0.0 # Good practice for future
google_fonts: ^6.1.0 # For 'Inter' and 'Press Start 2P' fonts
flutter_blue_plus: ^1.31.0 # For Bluetooth scanning and connection
flutter_svg: ^2.0.9 # For rendering SVG icons
image_picker: ^1.2.1
dev_dependencies:
flutter_test:
@ -45,7 +49,18 @@ flutter:
- assets/www/icons/
- assets/www/storybook_videos/
- assets/www/story_covers/
- assets/fonts/
fonts:
- family: Inter
fonts:
- asset: assets/fonts/Inter-Regular.ttf
- asset: assets/fonts/Inter-Medium.ttf
weight: 500
- asset: assets/fonts/Inter-SemiBold.ttf
weight: 600
- asset: assets/fonts/Inter-Bold.ttf
weight: 700
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images

View 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
...
## 验证截图
(附上关键页面和弹窗的截图)
```

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

View File

@ -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 | __ |
| 悬浮组件 | __ |
| **总计** | __ |

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

View File

@ -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 = __;
}