diff --git a/airhub_app/assets/fonts/Inter-Bold.ttf b/airhub_app/assets/fonts/Inter-Bold.ttf new file mode 100644 index 0000000..9fb9b75 Binary files /dev/null and b/airhub_app/assets/fonts/Inter-Bold.ttf differ diff --git a/airhub_app/assets/fonts/Inter-Medium.ttf b/airhub_app/assets/fonts/Inter-Medium.ttf new file mode 100644 index 0000000..458cd06 Binary files /dev/null and b/airhub_app/assets/fonts/Inter-Medium.ttf differ diff --git a/airhub_app/assets/fonts/Inter-Regular.ttf b/airhub_app/assets/fonts/Inter-Regular.ttf new file mode 100644 index 0000000..b7aaca8 Binary files /dev/null and b/airhub_app/assets/fonts/Inter-Regular.ttf differ diff --git a/airhub_app/assets/fonts/Inter-SemiBold.ttf b/airhub_app/assets/fonts/Inter-SemiBold.ttf new file mode 100644 index 0000000..47f8ab1 Binary files /dev/null and b/airhub_app/assets/fonts/Inter-SemiBold.ttf differ diff --git a/airhub_app/assets/www/icons/pixel-badge-ai.svg b/airhub_app/assets/www/icons/pixel-badge-ai.svg index b287fb9..94858d6 100644 --- a/airhub_app/assets/www/icons/pixel-badge-ai.svg +++ b/airhub_app/assets/www/icons/pixel-badge-ai.svg @@ -1,32 +1,27 @@ - + - - - - - + + + + + - + - + - - - - - + + + + + - + diff --git a/airhub_app/assets/www/icons/pixel-badge-basic.svg b/airhub_app/assets/www/icons/pixel-badge-basic.svg index 7445837..e75ec53 100644 --- a/airhub_app/assets/www/icons/pixel-badge-basic.svg +++ b/airhub_app/assets/www/icons/pixel-badge-basic.svg @@ -1,28 +1,23 @@ - + - - - - - + + + + + - + - - - - + + + + - + diff --git a/airhub_app/assets/www/icons/pixel-capybara.svg b/airhub_app/assets/www/icons/pixel-capybara.svg index 4285867..2fa411b 100644 --- a/airhub_app/assets/www/icons/pixel-capybara.svg +++ b/airhub_app/assets/www/icons/pixel-capybara.svg @@ -1,39 +1,33 @@ - + - - - + + + - - - + + + - - + + - - + + - + - - + + - - + + diff --git a/airhub_app/assets/www/icons/pixel-mystery-box.svg b/airhub_app/assets/www/icons/pixel-mystery-box.svg index 46f487e..11d19d1 100644 --- a/airhub_app/assets/www/icons/pixel-mystery-box.svg +++ b/airhub_app/assets/www/icons/pixel-mystery-box.svg @@ -1,38 +1,32 @@ - + - + - - + + - - + + - - + + - - - - + + + + - - - - - + + + + + diff --git a/airhub_app/fix_svg_styles.py b/airhub_app/fix_svg_styles.py new file mode 100644 index 0000000..6886a27 --- /dev/null +++ b/airhub_app/fix_svg_styles.py @@ -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'', 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'', '', 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() diff --git a/airhub_app/ios/Podfile.lock b/airhub_app/ios/Podfile.lock index 193edb5..6968536 100644 --- a/airhub_app/ios/Podfile.lock +++ b/airhub_app/ios/Podfile.lock @@ -1,5 +1,10 @@ PODS: - Flutter (1.0.0) + - flutter_blue_plus_darwin (0.0.2): + - Flutter + - FlutterMacOS + - image_picker_ios (0.0.1): + - Flutter - permission_handler_apple (9.3.0): - Flutter - webview_flutter_wkwebview (0.0.1): @@ -8,12 +13,18 @@ PODS: DEPENDENCIES: - Flutter (from `Flutter`) + - flutter_blue_plus_darwin (from `.symlinks/plugins/flutter_blue_plus_darwin/darwin`) + - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`) EXTERNAL SOURCES: Flutter: :path: Flutter + flutter_blue_plus_darwin: + :path: ".symlinks/plugins/flutter_blue_plus_darwin/darwin" + image_picker_ios: + :path: ".symlinks/plugins/image_picker_ios/ios" permission_handler_apple: :path: ".symlinks/plugins/permission_handler_apple/ios" webview_flutter_wkwebview: @@ -21,6 +32,8 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3 + image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d diff --git a/airhub_app/lib/main.dart b/airhub_app/lib/main.dart index 8d461c6..ce3a144 100644 --- a/airhub_app/lib/main.dart +++ b/airhub_app/lib/main.dart @@ -1,8 +1,14 @@ import 'package:flutter/material.dart'; import 'pages/login_page.dart'; import 'pages/webview_page.dart'; +import 'pages/home_page.dart'; +import 'pages/bluetooth_page.dart'; +import 'pages/wifi_config_page.dart'; +import 'pages/device_control_page.dart'; import 'theme/app_theme.dart'; +import 'pages/profile/profile_page.dart'; // Import ProfilePage + void main() { runApp(const AirhubApp()); } @@ -17,11 +23,17 @@ class AirhubApp extends StatelessWidget { debugShowCheckedModeBanner: false, theme: AppTheme.lightTheme, // Initial Route - home: const LoginPage(), + home: const DeviceControlPage(), // Named Routes routes: { '/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 onUnknownRoute: (settings) { diff --git a/airhub_app/lib/pages/bluetooth_page.dart b/airhub_app/lib/pages/bluetooth_page.dart new file mode 100644 index 0000000..cb409ee --- /dev/null +++ b/airhub_app/lib/pages/bluetooth_page.dart @@ -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 createState() => _BluetoothPageState(); +} + +class _BluetoothPageState extends State + with TickerProviderStateMixin { + // 状态 + bool _isSearching = true; + List _devices = []; + int _currentIndex = 0; + bool _isAnimating = false; + + // 动画控制器 + late AnimationController _searchAnimController; + late AnimationController _cardAnimController; + late Animation _cardAnimation; + + // 模拟设备数据 + static const List _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(begin: 0, end: 1).animate( + CurvedAnimation(parent: _cardAnimController, curve: Curves.easeOutCubic), + ); + + // 模拟搜索延迟 + _startSearch(); + } + + @override + void dispose() { + _searchAnimController.dispose(); + _cardAnimController.dispose(); + super.dispose(); + } + + /// 开始搜索 (模拟) + Future _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 _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, + ), + ), + ], + ), + ), + ), + ], + ], + ), + ); + } +} diff --git a/airhub_app/lib/pages/device_control_page.dart b/airhub_app/lib/pages/device_control_page.dart new file mode 100644 index 0000000..af6e6f1 --- /dev/null +++ b/airhub_app/lib/pages/device_control_page.dart @@ -0,0 +1,1081 @@ +import 'dart:math'; +import 'dart:ui'; +import 'package:flutter/material.dart'; +// import 'package:google_fonts/google_fonts.dart'; (Removed) +import 'package:flutter_svg/flutter_svg.dart'; +import 'story_detail_page.dart'; +import 'product_selection_page.dart'; +import 'settings_page.dart'; +import '../widgets/glass_dialog.dart'; +import '../widgets/story_generator_modal.dart'; +import 'story_loading_page.dart'; +import 'profile/profile_page.dart'; +import '../theme/design_tokens.dart'; +import '../widgets/dashed_rect.dart'; + +class DeviceControlPage extends StatefulWidget { + const DeviceControlPage({super.key}); + + @override + State createState() => _DeviceControlPageState(); +} + +class _DeviceControlPageState extends State + with SingleTickerProviderStateMixin { + int _currentIndex = 0; // 0: Home, 1: Story, 2: Music, 3: User + + // Animation for mascot + late AnimationController _mascotAnimController; + + // PageController for bookshelf scroll tracking + late PageController _bookshelfController; + double _bookshelfScrollOffset = 0.0; + + // Animation for new book + int? _newBookIndex; + + final List> _mockStories = [ + { + 'title': '卡皮巴拉的奇幻漂流', + 'cover': 'assets/www/story_covers/capybara_adventure.png', + 'locked': false, + }, + { + 'title': '勇敢的小裁缝', + 'cover': 'assets/www/story_covers/brave_tailor.png', + 'locked': false, + }, + { + 'title': '小红帽与大灰狼', + 'cover': 'assets/www/story_covers/red_riding_hood.png', + 'locked': false, + }, + { + 'title': '杰克与魔豆', + 'cover': 'assets/www/story_covers/jack_and_beanstalk.png', + 'locked': false, + }, + { + 'title': '糖果屋历险记', + 'cover': 'assets/www/story_covers/hansel_and_gretel.png', + 'locked': false, + }, + ]; + + @override + void initState() { + super.initState(); + _mascotAnimController = AnimationController( + vsync: this, + duration: const Duration(seconds: 4), + )..repeat(reverse: true); + + // Initialize bookshelf PageController + _bookshelfController = PageController(viewportFraction: 0.85); + _bookshelfController.addListener(() { + setState(() { + _bookshelfScrollOffset = _bookshelfController.page ?? 0.0; + }); + }); + } + + @override + void dispose() { + _mascotAnimController.dispose(); + _bookshelfController.dispose(); + super.dispose(); + } + + void _onTabTapped(int index) { + setState(() { + _currentIndex = index; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + body: Stack( + children: [ + // Global Gradient Background + _buildGradientBackground(), + + // Main Content Area + // Main Content Area + IndexedStack( + index: _currentIndex, + children: [ + SafeArea(bottom: false, child: _buildHomeView()), + SafeArea(bottom: false, child: _buildStoryView()), + SafeArea( + bottom: false, + child: _buildPlaceholderView('Music Coming Soon'), + ), + const ProfilePage(), // No SafeArea here to allow full background + ], + ), + + // Header (Visible on Home and Story tabs, but maybe different style?) + // For now, keep it fixed on top for both, as per design. + // Note: In story view, header might overlay content. + // Header (Only visible on Home tab) + if (_currentIndex == 0) + Positioned(top: 0, left: 0, right: 0, child: _buildHeader()), + + // Custom Bottom Navigation Bar + Positioned( + left: 0, + right: 0, + bottom: MediaQuery.of(context).padding.bottom, + child: _buildBottomNavBar(), + ), + ], + ), + ); + } + + Widget _buildGradientBackground() { + 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, + ), + ), + ), + ), + ], + ), + ); + } + + // --- Header --- HTML: padding-top: calc(env(safe-area-inset-top) + 48px) + Widget _buildHeader() { + return Container( + padding: EdgeInsets.fromLTRB( + 20, + MediaQuery.of(context).padding.top + 48, // HTML: +48px + 20, + 10, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Switch Device Button + _buildIconBtn( + 'assets/www/icons/icon-switch.svg', + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const ProductSelectionPage(), + ), + ); + }, + ), + + // Add Animation Trigger Logic for testing or real use + // We'll hook this up to the Generator Modal return value. + + // Status Pill + Container( + height: 44, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.25), + borderRadius: BorderRadius.circular(24), + border: Border.all(color: Colors.white.withOpacity(0.4)), + ), + child: Row( + children: [ + // Live Dot + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: const Color(0xFF22C55E), // Green + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: const Color(0xFF22C55E).withOpacity(0.2), + blurRadius: 0, + spreadRadius: 2, + ), + ], + ), + ), + const SizedBox(width: 8), + Text( + '在线', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 13, + fontWeight: FontWeight.w600, + color: const Color(0xFF1F2937), + ), + ), + // Divider + Container( + margin: const EdgeInsets.symmetric(horizontal: 12), + width: 1, + height: 16, + color: Colors.black.withOpacity(0.1), + ), + // Battery + SvgPicture.asset( + 'assets/www/icons/icon-battery-full.svg', + width: 18, + height: 18, + colorFilter: const ColorFilter.mode( + Color(0xFF1F2937), + BlendMode.srcIn, + ), + ), + const SizedBox(width: 4), + Text( + '85%', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 13, + fontWeight: FontWeight.w600, + color: const Color(0xFF1F2937), + ), + ), + ], + ), + ), + + // Settings Button + _buildIconBtn( + 'assets/www/icons/icon-settings-pixel.svg', + onTap: () { + Navigator.of(context).push( + PageRouteBuilder( + pageBuilder: (context, animation, secondaryAnimation) => + const SettingsPage(), + transitionsBuilder: + (context, animation, secondaryAnimation, child) { + const begin = Offset(0.0, 1.0); + const end = Offset.zero; + const curve = Cubic(0.2, 0.8, 0.2, 1.0); + var tween = Tween( + begin: begin, + end: end, + ).chain(CurveTween(curve: curve)); + return SlideTransition( + position: animation.drive(tween), + child: child, + ); + }, + ), + ); + }, + ), + ], + ), + ); + } + + Widget _buildIconBtn(String iconPath, {VoidCallback? onTap}) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.25), + borderRadius: BorderRadius.circular(22), + border: Border.all(color: Colors.white.withOpacity(0.4)), + ), + alignment: Alignment.center, + child: SvgPicture.asset( + iconPath, + width: 20, + height: 20, + colorFilter: const ColorFilter.mode( + Color(0xFF1F2937), + BlendMode.srcIn, + ), + ), + ), + ); + } + + // --- Home View --- + Widget _buildHomeView() { + return Center( + child: AnimatedBuilder( + animation: _mascotAnimController, + builder: (context, child) { + return Transform.translate( + offset: Offset( + 0, + 10 * _mascotAnimController.value - 5, + ), // Float +/- 5 + child: child, + ); + }, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Mascot Image + Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: const Color(0xFF8B5CF6).withOpacity(0.2), + blurRadius: 50, + spreadRadius: 10, + ), + ], + ), + child: Image.asset( + 'assets/www/Capybara.png', + width: 250, + fit: BoxFit.contain, + errorBuilder: (_, __, ___) => + const Icon(Icons.smart_toy, size: 150, color: Colors.amber), + ), + ), + ], + ), + ), + ); + } + + // --- Story View --- + Widget _buildStoryView() { + return Stack( + children: [ + // Main Content Column + Column( + children: [ + // Top Spacer - HTML: .story-header-spacer { height: 40px } + const SizedBox(height: 40), + + // Bookshelf PageView - Fixed height instead of Expanded + SizedBox( + height: 510 + 24, // bookshelf height + bottom margin + child: PageView.builder( + controller: _bookshelfController, + clipBehavior: Clip.none, + padEnds: false, + itemCount: 2, + itemBuilder: (context, index) { + if (index == 0) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: _buildBookshelfSlide( + '我的故事书 #1', + '${_mockStories.length}/10', + _mockStories, + ), + ); + } else { + // Pass scroll offset for position animation + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: _buildLockedShelf(), + ); + } + }, + ), + ), + + // Flexible spacer to push content up + const Spacer(), + ], + ), + + // Create Story Button (.story-actions-wrapper) + Positioned( + bottom: 120, // env(safe-area-inset-bottom) + 120px + left: 0, + right: 0, + child: Center(child: _buildCreateStoryButton()), + ), + ], + ); + } + + // Create Story Button per PRD (.create-story-btn) + Widget _buildCreateStoryButton() { + return GestureDetector( + onTap: () async { + final result = await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => const StoryGeneratorModal(), + ); + + if (result == 'start_generation') { + final saveResult = await Navigator.of(context).push( + MaterialPageRoute(builder: (context) => const StoryLoadingPage()), + ); + if (saveResult == 'saved') { + _addNewBookWithAnimation(); + } + } + }, + child: Container( + padding: StoryBookSpacing.createBtnPadding, + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: AppColors.btnCapybaraGradient, + ), + borderRadius: BorderRadius.circular(StoryBookRadius.createBtn), + boxShadow: AppShadows.createBtn, + ), + child: Stack( + children: [ + // PRD: ::before shine effect + Positioned.fill( + child: ClipRRect( + borderRadius: BorderRadius.circular(StoryBookRadius.createBtn), + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.center, + colors: [ + Colors.white.withOpacity(0.15), + Colors.transparent, + ], + ), + ), + ), + ), + ), + // Button content + Row( + mainAxisSize: MainAxisSize.min, + children: [ + // PRD: .btn-icon { font-size: 18px; font-weight: 700; } + const Text( + '+', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + const SizedBox(width: 8), + Text('创作新故事', style: AppTextStyles.createStoryBtn), + ], + ), + ], + ), + ), + ); + } + + Widget _buildBookshelfSlide( + String title, + String count, + List> stories, + ) { + // PRD: .bookshelf-container height: 600px, .story-book height: 100% + // Adjusted for mobile viewport - 510px for proper 5-row grid proportions + return Container( + margin: const EdgeInsets.only(bottom: 24), + height: 510, // Adjusted height + decoration: BoxDecoration( + color: AppColors.bookshelfBg, // .story-book bg rgba(255,255,255,0.55) + borderRadius: BorderRadius.circular(24), // 24px + border: Border.all( + color: AppColors.bookshelfBorder, + ), // 1px solid rgba(255,255,255,0.6) + boxShadow: const [ + BoxShadow( + color: Color(0x08000000), // rgba(0,0,0,0.03) + blurRadius: 40, + offset: Offset(0, 10), + ), + ], + ), + padding: const EdgeInsets.all(24), // .story-book padding + child: Column( + children: [ + // Header (.book-cover) + Padding( + padding: const EdgeInsets.only(bottom: 20), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(title, style: AppTextStyles.bookTitle), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: AppColors.bookCountBg, + borderRadius: BorderRadius.circular(12), + ), + child: Text(count, style: AppTextStyles.bookCount), + ), + ], + ), + ), + + // Grid (.story-grid) 2 cols, 5 rows + // PRD: grid-template-rows: repeat(5, minmax(0, 1fr)) + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + // Calculate aspect ratio based on available space + // 5 rows with 12px gaps (4 gaps total = 48px) + final gridHeight = constraints.maxHeight; + final gridWidth = constraints.maxWidth; + final rowHeight = (gridHeight - 48) / 5; // 5 rows, 4 gaps + final colWidth = (gridWidth - 12) / 2; // 2 cols, 1 gap + final aspectRatio = colWidth / rowHeight; + + return GridView.builder( + padding: EdgeInsets.zero, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + childAspectRatio: aspectRatio, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + ), + itemCount: 10, // Fixed 10 slots per book (2x5) + itemBuilder: (context, index) { + if (index < stories.length) { + // Check if this is a newly added book + final isNewBook = _newBookIndex == index; + return _buildStorySlot(stories[index], isNew: isNewBook); + } else { + // Empty clickable slot with + + return _buildStorySlot({'type': 'empty_slot'}); + } + }, + ); + }, + ), + ), + ], + ), + ); + } + + Widget _buildStorySlot(Map story, {bool isNew = false}) { + bool isFilled = story.containsKey('cover') && story['cover'] != null; + + // Empty/Clickable Slot (.story-slot.clickable) + // PRD: border: 1px dashed rgba(0, 0, 0, 0.05) + if (!isFilled) { + return GestureDetector( + onTap: () async { + final result = await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => const StoryGeneratorModal(), + ); + + if (result == 'start_generation') { + final saveResult = await Navigator.of(context).push( + MaterialPageRoute(builder: (context) => const StoryLoadingPage()), + ); + if (saveResult == 'saved') { + _addNewBookWithAnimation(); + } + } + }, + child: DashedRect( + color: AppColors.slotBorder, // rgba(0, 0, 0, 0.05) + strokeWidth: 1, + gap: 4, + borderRadius: BorderRadius.circular(StoryBookRadius.slot), + child: Container( + decoration: BoxDecoration( + color: AppColors.slotClickableBg, // rgba(255,255,255,0.4) + borderRadius: BorderRadius.circular(StoryBookRadius.slot), + ), + alignment: Alignment.center, + child: Text('+', style: AppTextStyles.emptyPlus), + ), + ), + ); + } + + // Filled Slot (.story-slot.filled) + Widget slotContent = GestureDetector( + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => StoryDetailPage(story: story), + ), + ); + }, + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(StoryBookRadius.slot), + boxShadow: const [AppShadows.storySlotFilled], + ), + clipBehavior: Clip.antiAlias, + child: Stack( + children: [ + // Cover Image (.story-cover-img) + Positioned.fill( + child: Image.asset( + story['cover'], + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => + Container(color: Colors.grey.shade200), + ), + ), + // Title Bar (.story-title-bar) + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + color: AppColors.slotTitleBarBg, + padding: StoryBookSpacing.titleBarPadding, + child: Text( + story['title'] ?? '', + style: AppTextStyles.slotTitle, + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + ), + ), + ); + + // Wrap with animation if this is a new book + // PRD: animation: bookPop 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards + if (isNew) { + return _NewBookAnimation(child: slotContent); + } + + return slotContent; + } + + // Locked Bookshelf Placeholder per PRD (.add-book-placeholder) + // Animates from left-aligned to centered based on scroll position + Widget _buildLockedShelf() { + // Calculate alignment based on scroll offset + // At offset 0 (viewing first bookshelf): align to left edge (-1.0) + // At offset 1 (viewing this bookshelf): align center (0) + final scrollProgress = _bookshelfScrollOffset.clamp(0.0, 1.0); + // Interpolate from -1.0 (left edge) to 0 (center) + final alignX = -1.0 * (1.0 - scrollProgress); + + return GestureDetector( + onTap: _showUnlockDialog, + child: Container( + height: 510, // Match bookshelf height + margin: const EdgeInsets.only(bottom: 24), + child: DashedRect( + color: const Color(0x80C99672), // rgba(201,150,114,0.5) + strokeWidth: 2, + gap: 6, + borderRadius: BorderRadius.circular(20), + child: Container( + decoration: BoxDecoration( + color: const Color(0x66FFFFFF), // rgba(255,255,255,0.4) + borderRadius: BorderRadius.circular(20), + ), + // Animate alignment from left edge to center + alignment: Alignment(alignX, 0), + padding: const EdgeInsets.only(left: 16), // Stick close to left edge + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // .add-icon + const Text( + '+', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w300, + color: Color(0xFF9CA3AF), + ), + ), + const SizedBox(height: 4), + // .placeholder-text (解锁\n新书架) + const Text( + '解锁\n新书架', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Color(0xFF9CA3AF), + height: 1.3, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildPlaceholderView(String title) { + return Center( + child: Text( + title, + style: TextStyle(fontFamily: 'Inter', fontSize: 16, color: Colors.grey), + ), + ); + } + + Widget _buildBottomNavBar() { + return Center( + child: ClipRRect( + borderRadius: BorderRadius.circular(32), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + width: 320, // HTML: max-width 320px + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.6), + borderRadius: BorderRadius.circular(32), + border: Border.all(color: Colors.white.withOpacity(0.8)), + boxShadow: [ + BoxShadow( + color: const Color(0xFF8B5CF6).withOpacity(0.15), + offset: const Offset(0, 10), + blurRadius: 30, + ), + ], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _buildNavItem(0, 'home', Icons.home), + _buildNavItem(1, 'story', Icons.auto_stories), + _buildNavItem(2, 'music', Icons.music_note), + _buildNavItem(3, 'user', Icons.person), + ], + ), + ), + ), + ), + ); + } + + Widget _buildNavItem(int index, String id, IconData defaultIcon) { + bool isActive = _currentIndex == index; + String iconPath = 'assets/www/icons/icon-$id-pixel.svg'; + if (id == 'home') iconPath = 'assets/www/icons/icon-home-capybara.svg'; + + return GestureDetector( + onTap: () => _onTabTapped(index), + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + width: 56, + height: 56, + decoration: BoxDecoration( + color: isActive ? null : Colors.transparent, + gradient: isActive + ? const LinearGradient( + colors: [ + Color(0xFFE6B98D), + Color(0xFFD4A373), + Color(0xFFB07D5A), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ) + : null, + borderRadius: BorderRadius.circular(28), + boxShadow: isActive + ? [ + BoxShadow( + color: const Color(0xFFD4A373).withOpacity(0.4), + offset: const Offset(0, 4), + blurRadius: 15, + ), + ] + : null, + ), + alignment: Alignment.center, + child: SvgPicture.asset( + iconPath, + width: isActive ? 30 : 28, + height: isActive ? 30 : 28, + colorFilter: ColorFilter.mode( + isActive ? Colors.white : const Color(0xFF6B7280).withOpacity(0.6), + BlendMode.srcIn, + ), + placeholderBuilder: (_) => Icon( + defaultIcon, + color: isActive ? Colors.white : const Color(0xFF6B7280), + size: 24, + ), + ), + ), + ); + } + + void _showUnlockDialog() { + showGlassDialog( + context: context, + title: '解锁新书架', + description: '确认消耗 500 积分以永久解锁该书架?', + confirmText: '确认解锁', + onConfirm: () { + Navigator.pop(context); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('解锁成功!'))); + }, + // Insert custom icon if needed? GlassDialog supports 'content'. + // But GlassDialog design logic (Step 1590) puts content *after* description. + // Unlock dialog had an Icon above title. + // GlassDialog puts Title first. + // If strict 1:1, title should mean text. Icon is separate. + // I can add Icon to 'content' but GlassDialog specific layout puts content BELOW desc. + // If I want Icon ABOVE title, I need to modify GlassDialog or accept Title First. + // Web CSS .modal-title is top. + // Web HTML lines 201-209: .modal-title, .modal-desc, .modal-actions. + // NO ICON in Web HTML structure! + // So my previous Icon(Icons.lock_open) was EXTRA? + // User said "1:1". Web HTML has NO Icon. + // So I should REMOVE the Icon to match Web. + // So just Title + Desc + Buttons. + // This matches showGlassDialog perfectly. + ); + } + + void _addNewBookWithAnimation() { + setState(() { + _mockStories.add({ + 'title': '星际忍者的茶话会', + 'cover': + 'assets/www/story_covers/brave_tailor.png', // Temporary mock cover + 'type': 'new', + 'locked': false, + }); + _newBookIndex = _mockStories.length - 1; + }); + + // Clear animation flag after animation completes + Future.delayed(const Duration(milliseconds: 800), () { + if (mounted) { + setState(() { + _newBookIndex = null; + }); + } + }); + } +} + +/// New Book Animation Widget matching PRD +/// PRD: animation: bookPop 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards +/// Plus magic particle effect with sparkleFloat animation +class _NewBookAnimation extends StatefulWidget { + final Widget child; + + const _NewBookAnimation({required this.child}); + + @override + State<_NewBookAnimation> createState() => _NewBookAnimationState(); +} + +class _NewBookAnimationState extends State<_NewBookAnimation> + with TickerProviderStateMixin { + late AnimationController _popController; + late AnimationController _particleController; + late Animation _scaleAnimation; + late Animation _opacityAnimation; + + // PRD: 20 particles with random angles/distances + final List<_Particle> _particles = []; + + // PRD particle colors: [#FFD700, #FF6B6B, #4ECDC4, #A78BFA, #FCD34D] + static const List _particleColors = [ + Color(0xFFFFD700), // Gold + Color(0xFFFF6B6B), // Coral + Color(0xFF4ECDC4), // Teal + Color(0xFFA78BFA), // Purple + Color(0xFFFCD34D), // Yellow + ]; + + @override + void initState() { + super.initState(); + + // PRD: bookPop 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275) + _popController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 600), + ); + + // PRD: sparkleFloat 0.8s + _particleController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 800), + ); + + // PRD cubic-bezier(0.175, 0.885, 0.32, 1.275) - overshoot curve + const prdCurve = Cubic(0.175, 0.885, 0.32, 1.275); + + _scaleAnimation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation(parent: _popController, curve: prdCurve), + ); + + _opacityAnimation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation( + parent: _popController, + curve: const Interval(0.0, 0.5, curve: Curves.easeOut), + ), + ); + + // Generate 20 particles with random properties + _generateParticles(); + + // Start animations + _popController.forward(); + _particleController.forward(); + } + + void _generateParticles() { + final random = Random(); + for (int i = 0; i < 20; i++) { + // PRD: random angle 0-360, distance 50-100px, size 5-10px + final angle = random.nextDouble() * 2 * pi; // 0-360 degrees in radians + final distance = 50.0 + random.nextDouble() * 50; // 50-100px + final size = 5.0 + random.nextDouble() * 5; // 5-10px + final colorIndex = random.nextInt(_particleColors.length); + final delay = random.nextDouble() * 0.3; // 0-0.3s delay + + _particles.add(_Particle( + angle: angle, + distance: distance, + size: size, + color: _particleColors[colorIndex], + delay: delay, + )); + } + } + + @override + void dispose() { + _popController.dispose(); + _particleController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: Listenable.merge([_popController, _particleController]), + builder: (context, child) { + return Stack( + clipBehavior: Clip.none, + children: [ + // Main book with pop animation + Transform.scale( + scale: _scaleAnimation.value, + child: Opacity( + opacity: _opacityAnimation.value.clamp(0.0, 1.0), + child: widget.child, + ), + ), + + // Magic particles overlay + ..._particles.map((particle) { + // PRD sparkleFloat: 0% scale(0) opacity(0), 50% opacity(1), 100% scale(0) opacity(0) + final progress = _particleController.value; + final adjustedProgress = + ((progress - particle.delay) / (1 - particle.delay)) + .clamp(0.0, 1.0); + + // Calculate opacity: 0 -> 1 -> 0 + double opacity; + if (adjustedProgress < 0.5) { + opacity = adjustedProgress * 2; + } else { + opacity = (1 - adjustedProgress) * 2; + } + + // Calculate scale: 0 -> 1 -> 0 + double scale; + if (adjustedProgress < 0.5) { + scale = adjustedProgress * 2; + } else { + scale = (1 - adjustedProgress) * 2; + } + + // Calculate position using proper trigonometry + // Particles radiate outward from center + final dx = cos(particle.angle) * particle.distance * adjustedProgress; + final dy = sin(particle.angle) * particle.distance * adjustedProgress; + + return Positioned( + left: 0, + right: 0, + top: 0, + bottom: 0, + child: Center( + child: Transform.translate( + offset: Offset(dx, dy), + child: Transform.scale( + scale: scale, + child: Opacity( + opacity: opacity.clamp(0.0, 1.0), + child: Container( + width: particle.size, + height: particle.size, + decoration: BoxDecoration( + color: particle.color, + shape: BoxShape.circle, + ), + ), + ), + ), + ), + ), + ); + }), + ], + ); + }, + ); + } +} + +class _Particle { + final double angle; + final double distance; + final double size; + final Color color; + final double delay; + + _Particle({ + required this.angle, + required this.distance, + required this.size, + required this.color, + required this.delay, + }); +} diff --git a/airhub_app/lib/pages/home_page.dart b/airhub_app/lib/pages/home_page.dart new file mode 100644 index 0000000..363bd9b --- /dev/null +++ b/airhub_app/lib/pages/home_page.dart @@ -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 createState() => _HomePageState(); +} + +class _HomePageState extends State + with SingleTickerProviderStateMixin { + late AnimationController _mascotController; + late Animation _mascotAnimation; + + @override + void initState() { + super.initState(); + // Mascot floating animation + _mascotController = AnimationController( + duration: const Duration(seconds: 4), + vsync: this, + )..repeat(reverse: true); + + _mascotAnimation = Tween(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, + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/airhub_app/lib/pages/login_page.dart b/airhub_app/lib/pages/login_page.dart index 4dcff0a..cb443db 100644 --- a/airhub_app/lib/pages/login_page.dart +++ b/airhub_app/lib/pages/login_page.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; + import '../theme/app_colors.dart'; import '../widgets/gradient_button.dart'; @@ -55,7 +56,7 @@ class _LoginPageState extends State with TickerProviderStateMixin { // Title Text( '服务协议', - style: GoogleFonts.inter( + style: TextStyle(fontFamily: 'Inter', fontSize: 18, fontWeight: FontWeight.w600, color: const Color(0xFF1F2937), @@ -66,7 +67,7 @@ class _LoginPageState extends State with TickerProviderStateMixin { Text.rich( TextSpan( text: '请先阅读并同意', - style: GoogleFonts.inter( + style: TextStyle(fontFamily: 'Inter', fontSize: 14, color: const Color(0xFF6B7280), height: 1.6, @@ -74,12 +75,12 @@ class _LoginPageState extends State with TickerProviderStateMixin { children: [ TextSpan( text: '《用户协议》', - style: GoogleFonts.inter(color: const Color(0xFF6366F1)), + style: TextStyle(fontFamily: 'Inter', color: const Color(0xFF6366F1)), ), const TextSpan(text: '和'), TextSpan( text: '《隐私政策》', - style: GoogleFonts.inter(color: const Color(0xFF6366F1)), + style: TextStyle(fontFamily: 'Inter', color: const Color(0xFF6366F1)), ), const TextSpan(text: ',以便为您提供更好的服务。'), ], @@ -103,7 +104,7 @@ class _LoginPageState extends State with TickerProviderStateMixin { alignment: Alignment.center, child: Text( '再想想', - style: GoogleFonts.inter( + style: TextStyle(fontFamily: 'Inter', fontSize: 15, fontWeight: FontWeight.w500, color: const Color(0xFF6B7280), @@ -136,7 +137,7 @@ class _LoginPageState extends State with TickerProviderStateMixin { alignment: Alignment.center, child: Text( '同意并继续', - style: GoogleFonts.inter( + style: TextStyle(fontFamily: 'Inter', fontSize: 15, fontWeight: FontWeight.w500, color: Colors.white, @@ -342,14 +343,15 @@ class _LoginPageState extends State with TickerProviderStateMixin { return SafeArea( child: Column( children: [ - // Logo - padding-top: calc(env(safe-area-inset-top) + 20px) + // Logo - padding-top: calc(env(safe-area-inset-top) + 60px) Padding( - padding: const EdgeInsets.only(top: 20), + padding: const EdgeInsets.only(top: 60), child: Text( 'Airhub', style: GoogleFonts.pressStart2p( - fontSize: 26, // Exact match + fontSize: 26, color: const Color(0xFF4B2E83), + letterSpacing: 2, shadows: [ Shadow( offset: const Offset(0, 2), @@ -388,7 +390,7 @@ class _LoginPageState extends State with TickerProviderStateMixin { onTap: _handleSmsLinkTap, child: Text( '使用验证码登录', - style: GoogleFonts.inter( + style: TextStyle(fontFamily: 'Inter', fontSize: 14, color: const Color(0xFF4B2E83).withOpacity(0.7), ), @@ -455,7 +457,7 @@ class _LoginPageState extends State with TickerProviderStateMixin { child: Text.rich( TextSpan( text: '我已阅读并同意', - style: GoogleFonts.inter( + style: TextStyle(fontFamily: 'Inter', fontSize: 12, color: const Color(0xFF4B2E83).withOpacity(0.6), height: 1.6, @@ -463,12 +465,12 @@ class _LoginPageState extends State with TickerProviderStateMixin { children: [ TextSpan( text: '《用户协议》', - style: GoogleFonts.inter(color: const Color(0xFF6366F1)), + style: TextStyle(fontFamily: 'Inter', color: const Color(0xFF6366F1)), ), const TextSpan(text: '和'), TextSpan( text: '《隐私政策》', - style: GoogleFonts.inter(color: const Color(0xFF6366F1)), + style: TextStyle(fontFamily: 'Inter', color: const Color(0xFF6366F1)), ), ], ), @@ -516,7 +518,7 @@ class _LoginPageState extends State with TickerProviderStateMixin { // Heading - font-size: 32px, font-weight: 700 Text( '欢迎使用 Airhub', - style: GoogleFonts.inter( + style: TextStyle(fontFamily: 'Inter', fontSize: 32, fontWeight: FontWeight.w700, color: const Color(0xFF4B2E83), @@ -527,7 +529,7 @@ class _LoginPageState extends State with TickerProviderStateMixin { // Subheading - font-size: 15px Text( '请输入您的手机号验证登录', - style: GoogleFonts.inter( + style: TextStyle(fontFamily: 'Inter', fontSize: 15, fontWeight: FontWeight.w400, color: const Color(0xFF4B2E83).withOpacity(0.6), @@ -611,7 +613,7 @@ class _LoginPageState extends State with TickerProviderStateMixin { ), child: Text( '+86', - style: GoogleFonts.inter( + style: TextStyle(fontFamily: 'Inter', fontSize: 16, fontWeight: FontWeight.w600, color: const Color(0xFF4B2E83), @@ -624,7 +626,7 @@ class _LoginPageState extends State with TickerProviderStateMixin { controller: _phoneController, keyboardType: TextInputType.phone, maxLength: 11, - style: GoogleFonts.inter( + style: TextStyle(fontFamily: 'Inter', fontSize: 17, fontWeight: FontWeight.w500, color: const Color(0xFF1F2937), @@ -632,7 +634,7 @@ class _LoginPageState extends State with TickerProviderStateMixin { decoration: InputDecoration( border: InputBorder.none, hintText: '请输入手机号', - hintStyle: GoogleFonts.inter( + hintStyle: TextStyle(fontFamily: 'Inter', fontSize: 17, fontWeight: FontWeight.w400, color: const Color(0xFF4B2E83).withOpacity(0.35), @@ -672,7 +674,7 @@ class _LoginPageState extends State with TickerProviderStateMixin { controller: _codeController, keyboardType: TextInputType.number, maxLength: 6, - style: GoogleFonts.inter( + style: TextStyle(fontFamily: 'Inter', fontSize: 17, fontWeight: FontWeight.w500, color: const Color(0xFF1F2937), @@ -680,7 +682,7 @@ class _LoginPageState extends State with TickerProviderStateMixin { decoration: InputDecoration( border: InputBorder.none, hintText: '输入验证码', - hintStyle: GoogleFonts.inter( + hintStyle: TextStyle(fontFamily: 'Inter', fontSize: 17, fontWeight: FontWeight.w400, color: const Color(0xFF4B2E83).withOpacity(0.35), @@ -704,7 +706,7 @@ class _LoginPageState extends State with TickerProviderStateMixin { onTap: _countdown > 0 ? null : _sendCode, child: Text( _countdown > 0 ? '${_countdown}s' : '获取验证码', - style: GoogleFonts.inter( + style: TextStyle(fontFamily: 'Inter', fontSize: 14, fontWeight: FontWeight.w600, color: _countdown > 0 @@ -755,7 +757,7 @@ class _LoginPageState extends State with TickerProviderStateMixin { ) : Text( '立即登录', - style: GoogleFonts.inter( + style: TextStyle(fontFamily: 'Inter', fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white, diff --git a/airhub_app/lib/pages/product_selection_page.dart b/airhub_app/lib/pages/product_selection_page.dart new file mode 100644 index 0000000..7ece407 --- /dev/null +++ b/airhub_app/lib/pages/product_selection_page.dart @@ -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 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, + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/airhub_app/lib/pages/profile/agent_manage_page.dart b/airhub_app/lib/pages/profile/agent_manage_page.dart new file mode 100644 index 0000000..511cb87 --- /dev/null +++ b/airhub_app/lib/pages/profile/agent_manage_page.dart @@ -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 createState() => _AgentManagePageState(); +} + +class _AgentManagePageState extends State { + // Mock data matching HTML + final List> _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 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'))); + } +} diff --git a/airhub_app/lib/pages/profile/guide_feeding_page.dart b/airhub_app/lib/pages/profile/guide_feeding_page.dart new file mode 100644 index 0000000..f662b1a --- /dev/null +++ b/airhub_app/lib/pages/profile/guide_feeding_page.dart @@ -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 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)), + ), + ), + ], + ], + ), + ); + } +} diff --git a/airhub_app/lib/pages/profile/help_page.dart b/airhub_app/lib/pages/profile/help_page.dart new file mode 100644 index 0000000..5181d41 --- /dev/null +++ b/airhub_app/lib/pages/profile/help_page.dart @@ -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); +} diff --git a/airhub_app/lib/pages/profile/profile_info_page.dart b/airhub_app/lib/pages/profile/profile_info_page.dart new file mode 100644 index 0000000..7f28595 --- /dev/null +++ b/airhub_app/lib/pages/profile/profile_info_page.dart @@ -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 createState() => _ProfileInfoPageState(); +} + +class _ProfileInfoPageState extends State { + 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 _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')}"; + }); + } + }); + } +} diff --git a/airhub_app/lib/pages/profile/profile_page.dart b/airhub_app/lib/pages/profile/profile_page.dart new file mode 100644 index 0000000..7b82e87 --- /dev/null +++ b/airhub_app/lib/pages/profile/profile_page.dart @@ -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 + ], + ), + ); + } +} diff --git a/airhub_app/lib/pages/profile/settings_page.dart b/airhub_app/lib/pages/profile/settings_page.dart new file mode 100644 index 0000000..05894d3 --- /dev/null +++ b/airhub_app/lib/pages/profile/settings_page.dart @@ -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 createState() => _SettingsPageState(); +} + +class _SettingsPageState extends State { + 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 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个工作日内处理。'); + }, + ); + } +} diff --git a/airhub_app/lib/pages/profile/settings_sub_pages.dart b/airhub_app/lib/pages/profile/settings_sub_pages.dart new file mode 100644 index 0000000..3229a64 --- /dev/null +++ b/airhub_app/lib/pages/profile/settings_sub_pages.dart @@ -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 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 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服务商:提供推送、地图等基础服务', '云服务商:提供数据存储和计算服务']), + ], + ); +} diff --git a/airhub_app/lib/pages/settings_page.dart b/airhub_app/lib/pages/settings_page.dart new file mode 100644 index 0000000..b8e7641 --- /dev/null +++ b/airhub_app/lib/pages/settings_page.dart @@ -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 createState() => _SettingsPageState(); +} + +class _SettingsPageState extends State { + // 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: