fix some page

This commit is contained in:
zyc 2026-02-06 16:03:32 +08:00
parent 54f13da9e3
commit 3c97eb7326
43 changed files with 8982 additions and 158 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"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="128" height="128">
<!-- Pixel Badge AI - 8-bit style circular screen device --> <!-- Pixel Badge AI - 8-bit style circular screen device -->
<style>
.frame { fill: #1E3A5F; }
.screen { fill: #3B82F6; }
.glow { fill: #60A5FA; }
.pixel { fill: #FFFFFF; }
</style>
<!-- Outer frame --> <!-- Outer frame -->
<rect class="frame" x="6" y="6" width="20" height="20"/> <rect fill="#1E3A5F" x="6" y="6" width="20" height="20"/>
<rect class="frame" x="4" y="8" width="2" height="16"/> <rect fill="#1E3A5F" x="4" y="8" width="2" height="16"/>
<rect class="frame" x="26" y="8" width="2" height="16"/> <rect fill="#1E3A5F" x="26" y="8" width="2" height="16"/>
<rect class="frame" x="8" y="4" width="16" height="2"/> <rect fill="#1E3A5F" x="8" y="4" width="16" height="2"/>
<rect class="frame" x="8" y="26" width="16" height="2"/> <rect fill="#1E3A5F" x="8" y="26" width="16" height="2"/>
<!-- Screen glow --> <!-- Screen glow -->
<rect class="glow" x="8" y="8" width="16" height="16" opacity="0.3"/> <rect fill="#60A5FA" x="8" y="8" width="16" height="16" opacity="0.3"/>
<!-- Screen --> <!-- Screen -->
<rect class="screen" x="10" y="10" width="12" height="12"/> <rect fill="#3B82F6" x="10" y="10" width="12" height="12"/>
<!-- Pixel face on screen --> <!-- Pixel face on screen -->
<rect class="pixel" x="12" y="12" width="2" height="2"/> <rect fill="#FFFFFF" x="12" y="12" width="2" height="2"/>
<rect class="pixel" x="18" y="12" width="2" height="2"/> <rect fill="#FFFFFF" x="18" y="12" width="2" height="2"/>
<rect class="pixel" x="12" y="18" width="2" height="2"/> <rect fill="#FFFFFF" x="12" y="18" width="2" height="2"/>
<rect class="pixel" x="14" y="20" width="4" height="2"/> <rect fill="#FFFFFF" x="14" y="20" width="4" height="2"/>
<rect class="pixel" x="18" y="18" width="2" height="2"/> <rect fill="#FFFFFF" x="18" y="18" width="2" height="2"/>
<!-- Corner highlights --> <!-- Corner highlights -->
<rect class="glow" x="6" y="6" width="2" height="2" opacity="0.5"/> <rect fill="#60A5FA" x="6" y="6" width="2" height="2" opacity="0.5"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

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

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 965 B

View File

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

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

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

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

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

View File

@ -1,8 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'pages/login_page.dart'; import 'pages/login_page.dart';
import 'pages/webview_page.dart'; import 'pages/webview_page.dart';
import 'pages/home_page.dart';
import 'pages/bluetooth_page.dart';
import 'pages/wifi_config_page.dart';
import 'pages/device_control_page.dart';
import 'theme/app_theme.dart'; import 'theme/app_theme.dart';
import 'pages/profile/profile_page.dart'; // Import ProfilePage
void main() { void main() {
runApp(const AirhubApp()); runApp(const AirhubApp());
} }
@ -17,11 +23,17 @@ class AirhubApp extends StatelessWidget {
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: AppTheme.lightTheme, theme: AppTheme.lightTheme,
// Initial Route // Initial Route
home: const LoginPage(), home: const DeviceControlPage(),
// Named Routes // Named Routes
routes: { routes: {
'/login': (context) => const LoginPage(), '/login': (context) => const LoginPage(),
'/home': (context) => const WebViewPage(), '/home': (context) => const HomePage(), // Native Home
'/profile': (context) => const ProfilePage(), // Added Profile Route
'/webview_fallback': (context) =>
const WebViewPage(), // Keep for fallback
'/bluetooth': (context) => const BluetoothPage(),
'/wifi-config': (context) => const WifiConfigPage(),
'/device-control': (context) => const DeviceControlPage(),
}, },
// Handle unknown routes // Handle unknown routes
onUnknownRoute: (settings) { onUnknownRoute: (settings) {

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

@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
import '../widgets/gradient_button.dart'; import '../widgets/gradient_button.dart';
@ -55,7 +56,7 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
// Title // Title
Text( Text(
'服务协议', '服务协议',
style: GoogleFonts.inter( style: TextStyle(fontFamily: 'Inter',
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: const Color(0xFF1F2937), color: const Color(0xFF1F2937),
@ -66,7 +67,7 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
Text.rich( Text.rich(
TextSpan( TextSpan(
text: '请先阅读并同意', text: '请先阅读并同意',
style: GoogleFonts.inter( style: TextStyle(fontFamily: 'Inter',
fontSize: 14, fontSize: 14,
color: const Color(0xFF6B7280), color: const Color(0xFF6B7280),
height: 1.6, height: 1.6,
@ -74,12 +75,12 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
children: [ children: [
TextSpan( TextSpan(
text: '《用户协议》', text: '《用户协议》',
style: GoogleFonts.inter(color: const Color(0xFF6366F1)), style: TextStyle(fontFamily: 'Inter', color: const Color(0xFF6366F1)),
), ),
const TextSpan(text: ''), const TextSpan(text: ''),
TextSpan( TextSpan(
text: '《隐私政策》', text: '《隐私政策》',
style: GoogleFonts.inter(color: const Color(0xFF6366F1)), style: TextStyle(fontFamily: 'Inter', color: const Color(0xFF6366F1)),
), ),
const TextSpan(text: ',以便为您提供更好的服务。'), const TextSpan(text: ',以便为您提供更好的服务。'),
], ],
@ -103,7 +104,7 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
alignment: Alignment.center, alignment: Alignment.center,
child: Text( child: Text(
'再想想', '再想想',
style: GoogleFonts.inter( style: TextStyle(fontFamily: 'Inter',
fontSize: 15, fontSize: 15,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: const Color(0xFF6B7280), color: const Color(0xFF6B7280),
@ -136,7 +137,7 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
alignment: Alignment.center, alignment: Alignment.center,
child: Text( child: Text(
'同意并继续', '同意并继续',
style: GoogleFonts.inter( style: TextStyle(fontFamily: 'Inter',
fontSize: 15, fontSize: 15,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: Colors.white, color: Colors.white,
@ -342,14 +343,15 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
return SafeArea( return SafeArea(
child: Column( child: Column(
children: [ children: [
// Logo - padding-top: calc(env(safe-area-inset-top) + 20px) // Logo - padding-top: calc(env(safe-area-inset-top) + 60px)
Padding( Padding(
padding: const EdgeInsets.only(top: 20), padding: const EdgeInsets.only(top: 60),
child: Text( child: Text(
'Airhub', 'Airhub',
style: GoogleFonts.pressStart2p( style: GoogleFonts.pressStart2p(
fontSize: 26, // Exact match fontSize: 26,
color: const Color(0xFF4B2E83), color: const Color(0xFF4B2E83),
letterSpacing: 2,
shadows: [ shadows: [
Shadow( Shadow(
offset: const Offset(0, 2), offset: const Offset(0, 2),
@ -388,7 +390,7 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
onTap: _handleSmsLinkTap, onTap: _handleSmsLinkTap,
child: Text( child: Text(
'使用验证码登录', '使用验证码登录',
style: GoogleFonts.inter( style: TextStyle(fontFamily: 'Inter',
fontSize: 14, fontSize: 14,
color: const Color(0xFF4B2E83).withOpacity(0.7), color: const Color(0xFF4B2E83).withOpacity(0.7),
), ),
@ -455,7 +457,7 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
child: Text.rich( child: Text.rich(
TextSpan( TextSpan(
text: '我已阅读并同意', text: '我已阅读并同意',
style: GoogleFonts.inter( style: TextStyle(fontFamily: 'Inter',
fontSize: 12, fontSize: 12,
color: const Color(0xFF4B2E83).withOpacity(0.6), color: const Color(0xFF4B2E83).withOpacity(0.6),
height: 1.6, height: 1.6,
@ -463,12 +465,12 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
children: [ children: [
TextSpan( TextSpan(
text: '《用户协议》', text: '《用户协议》',
style: GoogleFonts.inter(color: const Color(0xFF6366F1)), style: TextStyle(fontFamily: 'Inter', color: const Color(0xFF6366F1)),
), ),
const TextSpan(text: ''), const TextSpan(text: ''),
TextSpan( TextSpan(
text: '《隐私政策》', text: '《隐私政策》',
style: GoogleFonts.inter(color: const Color(0xFF6366F1)), style: TextStyle(fontFamily: 'Inter', color: const Color(0xFF6366F1)),
), ),
], ],
), ),
@ -516,7 +518,7 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
// Heading - font-size: 32px, font-weight: 700 // Heading - font-size: 32px, font-weight: 700
Text( Text(
'欢迎使用 Airhub', '欢迎使用 Airhub',
style: GoogleFonts.inter( style: TextStyle(fontFamily: 'Inter',
fontSize: 32, fontSize: 32,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: const Color(0xFF4B2E83), color: const Color(0xFF4B2E83),
@ -527,7 +529,7 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
// Subheading - font-size: 15px // Subheading - font-size: 15px
Text( Text(
'请输入您的手机号验证登录', '请输入您的手机号验证登录',
style: GoogleFonts.inter( style: TextStyle(fontFamily: 'Inter',
fontSize: 15, fontSize: 15,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
color: const Color(0xFF4B2E83).withOpacity(0.6), color: const Color(0xFF4B2E83).withOpacity(0.6),
@ -611,7 +613,7 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
), ),
child: Text( child: Text(
'+86', '+86',
style: GoogleFonts.inter( style: TextStyle(fontFamily: 'Inter',
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: const Color(0xFF4B2E83), color: const Color(0xFF4B2E83),
@ -624,7 +626,7 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
controller: _phoneController, controller: _phoneController,
keyboardType: TextInputType.phone, keyboardType: TextInputType.phone,
maxLength: 11, maxLength: 11,
style: GoogleFonts.inter( style: TextStyle(fontFamily: 'Inter',
fontSize: 17, fontSize: 17,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: const Color(0xFF1F2937), color: const Color(0xFF1F2937),
@ -632,7 +634,7 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
decoration: InputDecoration( decoration: InputDecoration(
border: InputBorder.none, border: InputBorder.none,
hintText: '请输入手机号', hintText: '请输入手机号',
hintStyle: GoogleFonts.inter( hintStyle: TextStyle(fontFamily: 'Inter',
fontSize: 17, fontSize: 17,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
color: const Color(0xFF4B2E83).withOpacity(0.35), color: const Color(0xFF4B2E83).withOpacity(0.35),
@ -672,7 +674,7 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
controller: _codeController, controller: _codeController,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
maxLength: 6, maxLength: 6,
style: GoogleFonts.inter( style: TextStyle(fontFamily: 'Inter',
fontSize: 17, fontSize: 17,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: const Color(0xFF1F2937), color: const Color(0xFF1F2937),
@ -680,7 +682,7 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
decoration: InputDecoration( decoration: InputDecoration(
border: InputBorder.none, border: InputBorder.none,
hintText: '输入验证码', hintText: '输入验证码',
hintStyle: GoogleFonts.inter( hintStyle: TextStyle(fontFamily: 'Inter',
fontSize: 17, fontSize: 17,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
color: const Color(0xFF4B2E83).withOpacity(0.35), color: const Color(0xFF4B2E83).withOpacity(0.35),
@ -704,7 +706,7 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
onTap: _countdown > 0 ? null : _sendCode, onTap: _countdown > 0 ? null : _sendCode,
child: Text( child: Text(
_countdown > 0 ? '${_countdown}s' : '获取验证码', _countdown > 0 ? '${_countdown}s' : '获取验证码',
style: GoogleFonts.inter( style: TextStyle(fontFamily: 'Inter',
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: _countdown > 0 color: _countdown > 0
@ -755,7 +757,7 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
) )
: Text( : Text(
'立即登录', '立即登录',
style: GoogleFonts.inter( style: TextStyle(fontFamily: 'Inter',
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: Colors.white, color: Colors.white,

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

@ -53,6 +53,33 @@ Page resource error:
isForMainFrame: ${error.isForMainFrame} isForMainFrame: ${error.isForMainFrame}
'''); ''');
}, },
onNavigationRequest: (NavigationRequest request) {
if (request.url.contains('bluetooth.html')) {
// Intercept bluetooth.html and navigate to native BluetoothPage
debugPrint(
'Intercepting navigation to bluetooth.html -> Native Route',
);
// We need context to navigate, but initState doesn't have it easily available
// inside this callback unless we store a reference or use a GlobalKey.
// However, since we are in a State object, we can use 'context' if mounted?
// Actually, NavigationDelegate callbacks are not bound to context directly.
// We should probably move the controller creation or use a helper.
// BUT, since this is a callback, 'context' of the State is available in the closure!
// Warning: don't use 'context' across async gaps without checking mounted.
// Since this is synchronous, it should be fine to schedule a navigation.
// We must return NavigationDecision.prevent to stop WebView.
// And execute navigation asynchronously to avoid blocking.
Future.microtask(() {
if (mounted) {
Navigator.of(context).pushNamed('/bluetooth');
}
});
return NavigationDecision.prevent;
}
return NavigationDecision.navigate;
},
), ),
) )
..loadFlutterAsset( ..loadFlutterAsset(

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

@ -13,6 +13,15 @@ class AppColors {
static const Color primaryPink = Color(0xFFF9A8D4); static const Color primaryPink = Color(0xFFF9A8D4);
static const Color primaryIndigo = Color(0xFF6366F1); static const Color primaryIndigo = Color(0xFF6366F1);
// Capybara Theme Colors
static const Color capybaraSand = Color(0xFFFDF9F3);
static const Color capybaraBrown = Color(0xFF4B2404);
static const Color capybaraWarmGrey = Color(0xFF4B5563);
static const Color capybaraAmber = Color(0xFFEA9A3E); // Selected state ring color
static const Color capybaraSelectedBg = Color(0xFFFFF7ED); // Selected card background
static const Color capybaraPlushLight = Color(0xFFECCFA8); // Plush gradient start
static const Color capybaraPlushDark = Color(0xFFC99672); // Plush gradient end
// Additional Primary Colors from Button Gradient // Additional Primary Colors from Button Gradient
static const Color cyan = Color(0xFF22D3EE); // #22D3EE static const Color cyan = Color(0xFF22D3EE); // #22D3EE
static const Color deepPurple = Color(0xFF8B5CF6); // #8B5CF6 static const Color deepPurple = Color(0xFF8B5CF6); // #8B5CF6
@ -47,6 +56,39 @@ class AppColors {
blurRadius: 32, blurRadius: 32,
); );
// Plush button shadows (warm brown glow for Capybara theme)
static final List<BoxShadow> shadowPlushButton = [
BoxShadow(
color: const Color(0xFFC99672).withOpacity(0.35),
offset: Offset.zero,
blurRadius: 15,
),
BoxShadow(
color: const Color(0xFFC99672).withOpacity(0.25),
offset: Offset.zero,
blurRadius: 30,
),
BoxShadow(
color: const Color(0xFFC99672).withOpacity(0.4),
offset: const Offset(0, 6),
blurRadius: 20,
),
];
// Primary button shadows (purple/indigo glow)
static final List<BoxShadow> shadowPrimaryButton = [
BoxShadow(
color: const Color(0xFF6366F1).withOpacity(0.4),
offset: const Offset(0, 4),
blurRadius: 20,
),
BoxShadow(
color: const Color(0xFF8B5CF6).withOpacity(0.2),
offset: Offset.zero,
blurRadius: 40,
),
];
// Gradients // Gradients
static const LinearGradient btnPrimaryGradient = LinearGradient( static const LinearGradient btnPrimaryGradient = LinearGradient(
begin: Alignment.centerLeft, begin: Alignment.centerLeft,
@ -59,4 +101,10 @@ class AppColors {
], ],
stops: [0.0, 0.35, 0.65, 1.0], stops: [0.0, 0.35, 0.65, 1.0],
); );
static const LinearGradient btnPlushGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFECCFA8), Color(0xFFC99672)],
);
} }

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

@ -7,21 +7,71 @@ class GradientButton extends StatelessWidget {
final double width; final double width;
final double height; final double height;
final bool isLoading; final bool isLoading;
final Gradient? gradient;
const GradientButton({ const GradientButton({
super.key, super.key,
required this.text, required this.text,
this.onPressed, this.onPressed,
this.width = double.infinity, this.width = double.infinity,
this.height = 56.0, this.height = 50.0, // Changed from 56 to 50 to match CSS
this.isLoading = false, this.isLoading = false,
this.gradient,
}); });
// Check if using plush/capybara gradient
bool get _isPlushGradient {
if (gradient == null) return false;
if (gradient is LinearGradient) {
final lg = gradient as LinearGradient;
// Check if colors match plush gradient colors
if (lg.colors.length >= 2) {
return lg.colors.first.value == 0xFFECCFA8 ||
lg.colors.last.value == 0xFFC99672;
}
}
return false;
}
List<BoxShadow> get _boxShadows {
if (_isPlushGradient) {
// Warm brown glow for Capybara plush gradient
return [
BoxShadow(
color: const Color(0xFFC99672).withOpacity(0.35),
offset: Offset.zero,
blurRadius: 15,
),
BoxShadow(
color: const Color(0xFFC99672).withOpacity(0.25),
offset: Offset.zero,
blurRadius: 30,
),
BoxShadow(
color: const Color(0xFFC99672).withOpacity(0.4),
offset: const Offset(0, 6),
blurRadius: 20,
),
];
} else {
// Purple/indigo glow for primary gradient
return [
BoxShadow(
color: const Color(0xFF6366F1).withOpacity(0.4),
offset: const Offset(0, 4),
blurRadius: 20,
),
BoxShadow(
color: const Color(0xFF8B5CF6).withOpacity(0.2),
offset: Offset.zero,
blurRadius: 40,
),
];
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Determine if button is disabled strictly by onPressed being null
// But we still want to show gradient for disabled state? Usually disabled is grey.
// Let's stick to the design where it might just opacity down.
final bool isDisabled = onPressed == null || isLoading; final bool isDisabled = onPressed == null || isLoading;
return Opacity( return Opacity(
@ -31,26 +81,32 @@ class GradientButton extends StatelessWidget {
height: height, height: height,
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(height / 2), borderRadius: BorderRadius.circular(height / 2),
gradient: AppColors.btnPrimaryGradient, gradient: gradient ?? AppColors.btnPrimaryGradient,
boxShadow: [ boxShadow: _boxShadows,
// 0 4px 20px rgba(99, 102, 241, 0.4)
BoxShadow(
color: const Color(0xFF6366F1).withOpacity(0.4),
offset: const Offset(0, 4),
blurRadius: 20,
), ),
// 0 0 40px rgba(139, 92, 246, 0.2) child: Stack(
BoxShadow( children: [
color: const Color(0xFF8B5CF6).withOpacity(0.2), // Shine overlay (top half gradient)
offset: const Offset(0, 0), Positioned.fill(
blurRadius: 40, child: ClipRRect(
), borderRadius: BorderRadius.circular(height / 2),
// inset 0 1px 0 rgba(255, 255, 255, 0.2) -> Not directly supported in simple BoxShadow child: DecoratedBox(
// can use a top border or inner shadow container trick if needed. decoration: BoxDecoration(
// For now, these outer shadows are sufficient for the "Glow". gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.white.withOpacity(0.15),
Colors.transparent,
], ],
stops: const [0.0, 0.5],
), ),
child: Material( ),
),
),
),
// Button content
Material(
color: Colors.transparent, color: Colors.transparent,
child: InkWell( child: InkWell(
onTap: isDisabled ? null : onPressed, onTap: isDisabled ? null : onPressed,
@ -69,11 +125,14 @@ class GradientButton extends StatelessWidget {
) )
: Text( : Text(
text, text,
style: Theme.of(context).textTheme.labelLarge?.copyWith( style: TextStyle(
fontSize: 17, fontFamily: 'Inter',
fontWeight: FontWeight.w600, fontSize: _isPlushGradient ? 18 : 17,
shadows: [ fontWeight:
const Shadow( _isPlushGradient ? FontWeight.w700 : FontWeight.w600,
color: Colors.white,
shadows: const [
Shadow(
offset: Offset(0, 1), offset: Offset(0, 1),
blurRadius: 2, blurRadius: 2,
color: Colors.black12, color: Colors.black12,
@ -84,6 +143,8 @@ class GradientButton extends StatelessWidget {
), ),
), ),
), ),
],
),
), ),
); );
} }

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 # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: packages:
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async: async:
dependency: transitive dependency: transitive
description: description:
@ -9,6 +17,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.13.0" version: "2.13.0"
bluez:
dependency: transitive
description:
name: bluez
sha256: "61a7204381925896a374301498f2f5399e59827c6498ae1e924aaa598751b545"
url: "https://pub.dev"
source: hosted
version: "0.8.3"
boolean_selector: boolean_selector:
dependency: transitive dependency: transitive
description: description:
@ -49,6 +65,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.19.1" version: "1.19.1"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
url: "https://pub.dev"
source: hosted
version: "0.3.5+2"
crypto: crypto:
dependency: transitive dependency: transitive
description: description:
@ -57,6 +81,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.7" version: "3.0.7"
dbus:
dependency: transitive
description:
name: dbus
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
url: "https://pub.dev"
source: hosted
version: "0.7.12"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
@ -81,11 +113,91 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.1" version: "7.0.1"
file_selector_linux:
dependency: transitive
description:
name: file_selector_linux
sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0"
url: "https://pub.dev"
source: hosted
version: "0.9.4"
file_selector_macos:
dependency: transitive
description:
name: file_selector_macos
sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a"
url: "https://pub.dev"
source: hosted
version: "0.9.5"
file_selector_platform_interface:
dependency: transitive
description:
name: file_selector_platform_interface
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
url: "https://pub.dev"
source: hosted
version: "2.7.0"
file_selector_windows:
dependency: transitive
description:
name: file_selector_windows
sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd"
url: "https://pub.dev"
source: hosted
version: "0.9.3+5"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_blue_plus:
dependency: "direct main"
description:
name: flutter_blue_plus
sha256: "69a8c87c11fc792e8cf0f997d275484fbdb5143ac9f0ac4d424429700cb4e0ed"
url: "https://pub.dev"
source: hosted
version: "1.36.8"
flutter_blue_plus_android:
dependency: transitive
description:
name: flutter_blue_plus_android
sha256: "6f7fe7e69659c30af164a53730707edc16aa4d959e4c208f547b893d940f853d"
url: "https://pub.dev"
source: hosted
version: "7.0.4"
flutter_blue_plus_darwin:
dependency: transitive
description:
name: flutter_blue_plus_darwin
sha256: "682982862c1d964f4d54a3fb5fccc9e59a066422b93b7e22079aeecd9c0d38f8"
url: "https://pub.dev"
source: hosted
version: "7.0.3"
flutter_blue_plus_linux:
dependency: transitive
description:
name: flutter_blue_plus_linux
sha256: "56b0c45edd0a2eec8f85bd97a26ac3cd09447e10d0094fed55587bf0592e3347"
url: "https://pub.dev"
source: hosted
version: "7.0.3"
flutter_blue_plus_platform_interface:
dependency: transitive
description:
name: flutter_blue_plus_platform_interface
sha256: "84fbd180c50a40c92482f273a92069960805ce324e3673ad29c41d2faaa7c5c2"
url: "https://pub.dev"
source: hosted
version: "7.0.0"
flutter_blue_plus_web:
dependency: transitive
description:
name: flutter_blue_plus_web
sha256: a1aceee753d171d24c0e0cdadb37895b5e9124862721f25f60bb758e20b72c99
url: "https://pub.dev"
source: hosted
version: "7.0.2"
flutter_lints: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@ -94,6 +206,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.2" version: "3.0.2"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1
url: "https://pub.dev"
source: hosted
version: "2.0.33"
flutter_svg:
dependency: "direct main"
description:
name: flutter_svg
sha256: "87fbd7c534435b6c5d9d98b01e1fd527812b82e68ddd8bd35fc45ed0fa8f0a95"
url: "https://pub.dev"
source: hosted
version: "2.2.3"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@ -144,6 +272,70 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.2" version: "4.1.2"
image_picker:
dependency: "direct main"
description:
name: image_picker
sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
image_picker_android:
dependency: transitive
description:
name: image_picker_android
sha256: "518a16108529fc18657a3e6dde4a043dc465d16596d20ab2abd49a4cac2e703d"
url: "https://pub.dev"
source: hosted
version: "0.8.13+13"
image_picker_for_web:
dependency: transitive
description:
name: image_picker_for_web
sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214"
url: "https://pub.dev"
source: hosted
version: "3.1.1"
image_picker_ios:
dependency: transitive
description:
name: image_picker_ios
sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588
url: "https://pub.dev"
source: hosted
version: "0.8.13+6"
image_picker_linux:
dependency: transitive
description:
name: image_picker_linux
sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4"
url: "https://pub.dev"
source: hosted
version: "0.2.2"
image_picker_macos:
dependency: transitive
description:
name: image_picker_macos
sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91"
url: "https://pub.dev"
source: hosted
version: "0.2.2+1"
image_picker_platform_interface:
dependency: transitive
description:
name: image_picker_platform_interface
sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c"
url: "https://pub.dev"
source: hosted
version: "2.11.1"
image_picker_windows:
dependency: transitive
description:
name: image_picker_windows
sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae
url: "https://pub.dev"
source: hosted
version: "0.2.2"
leak_tracker: leak_tracker:
dependency: transitive dependency: transitive
description: description:
@ -208,6 +400,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.17.0" version: "1.17.0"
mime:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
native_toolchain_c: native_toolchain_c:
dependency: transitive dependency: transitive
description: description:
@ -232,6 +432,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.1" version: "1.9.1"
path_parsing:
dependency: transitive
description:
name: path_parsing
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
path_provider: path_provider:
dependency: transitive dependency: transitive
description: description:
@ -328,6 +536,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.2.1" version: "0.2.1"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1"
url: "https://pub.dev"
source: hosted
version: "7.0.1"
platform: platform:
dependency: transitive dependency: transitive
description: description:
@ -352,6 +568,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" version: "2.2.0"
rxdart:
dependency: transitive
description:
name: rxdart
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
url: "https://pub.dev"
source: hosted
version: "0.28.0"
sky_engine: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@ -413,6 +637,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.0"
vector_graphics:
dependency: transitive
description:
name: vector_graphics
sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6
url: "https://pub.dev"
source: hosted
version: "1.1.19"
vector_graphics_codec:
dependency: transitive
description:
name: vector_graphics_codec
sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146"
url: "https://pub.dev"
source: hosted
version: "1.1.13"
vector_graphics_compiler:
dependency: transitive
description:
name: vector_graphics_compiler
sha256: "201e876b5d52753626af64b6359cd13ac6011b80728731428fd34bc840f71c9b"
url: "https://pub.dev"
source: hosted
version: "1.1.20"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
@ -477,6 +725,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.0" version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.dev"
source: hosted
version: "6.6.1"
yaml: yaml:
dependency: transitive dependency: transitive
description: description:

View File

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

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