// 暖记响应式骨架 — 三级自适应布局 // 手机: 底部 TabBar + 中心凸起写日记按钮 | 平板: 侧边导航 | 桌面: 三栏 // Web 平台始终使用底部 TabBar(移动端布局)以保证导航交互正常 // 对齐 Open Design 原型稿: 首页/日历/写日记(FAB)/发现/我的 import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import '../core/constants/breakpoints.dart'; import '../core/theme/app_colors.dart'; import '../core/theme/app_typography.dart'; import '../core/theme/app_radius.dart'; /// 导航项数量(不含中心 FAB) const int kNavItemCount = 4; /// 暖记自适应 Scaffold class ResponsiveScaffold extends StatefulWidget { const ResponsiveScaffold({ super.key, required this.selectedIndex, required this.onDestinationSelected, required this.body, this.onCenterButtonPressed, this.appBarTitle, this.secondaryBody, }); /// 当前选中的导航索引 (0-3,对应首页/日历/发现/我的) final int selectedIndex; /// 导航项被选中回调 final ValueChanged onDestinationSelected; /// 主内容区 final Widget body; /// 中心写日记按钮回调 final VoidCallback? onCenterButtonPressed; final String? appBarTitle; final Widget? secondaryBody; @override State createState() => _ResponsiveScaffoldState(); } class _ResponsiveScaffoldState extends State { @override Widget build(BuildContext context) { final width = MediaQuery.sizeOf(context).width; // Web 平台:始终使用移动端底部 TabBar 布局 final deviceType = kIsWeb ? DeviceType.mobile : Breakpoints.getDeviceType(width); switch (deviceType) { case DeviceType.mobile: return _MobileLayout( selectedIndex: widget.selectedIndex, onDestinationSelected: widget.onDestinationSelected, body: widget.body, onCenterButtonPressed: widget.onCenterButtonPressed, appBarTitle: widget.appBarTitle, ); case DeviceType.tablet: return _TabletLayout( selectedIndex: widget.selectedIndex, onDestinationSelected: widget.onDestinationSelected, body: widget.body, onCenterButtonPressed: widget.onCenterButtonPressed, appBarTitle: widget.appBarTitle, ); case DeviceType.desktop: return _DesktopLayout( selectedIndex: widget.selectedIndex, onDestinationSelected: widget.onDestinationSelected, body: widget.body, onCenterButtonPressed: widget.onCenterButtonPressed, secondaryBody: widget.secondaryBody, appBarTitle: widget.appBarTitle, ); } } } // ===== 导航项定义(平板/桌面 NavigationRail)===== const _railItems = [ NavigationRailDestination( icon: Icon(Icons.home_outlined), selectedIcon: Icon(Icons.home), label: Text('首页'), ), NavigationRailDestination( icon: Icon(Icons.calendar_month_outlined), selectedIcon: Icon(Icons.calendar_month), label: Text('日历'), ), NavigationRailDestination( icon: Icon(Icons.explore_outlined), selectedIcon: Icon(Icons.explore), label: Text('发现'), ), NavigationRailDestination( icon: Icon(Icons.person_outline), selectedIcon: Icon(Icons.person), label: Text('我的'), ), ]; // ===== 中心写日记 FAB 按钮(spec §2.2 凸起按钮)===== // 尺寸 48x48, 圆形, accent 色, shadow-accent class _CenterFabButton extends StatelessWidget { const _CenterFabButton({required this.onPressed}); final VoidCallback onPressed; @override Widget build(BuildContext context) { return Material( color: AppColors.accent, shape: const CircleBorder(), elevation: 4, shadowColor: AppColors.accent.withValues(alpha: 0.4), child: InkWell( onTap: onPressed, customBorder: const CircleBorder(), child: SizedBox( width: 48, height: 48, child: Center( child: Icon(Icons.add_rounded, size: 24, color: AppColors.bgLight), ), ), ), ); } } // ===== 手机布局 — 底部 TabBar (90px) + 中心凸起 FAB ===== // // spec §2.2 规定: // - Tab 栏总高 = 56px (tab-height) + 34px (safe-bottom) = 90px // - 中心"写日记"按钮 margin-top:-16px 凸出 Tab 栏顶部 // - 圆形 48x48, accent 色, shadow-accent // - Tab 项图标 24x24, 文字 11px/500 class _MobileLayout extends StatelessWidget { const _MobileLayout({ required this.selectedIndex, required this.onDestinationSelected, required this.body, this.onCenterButtonPressed, this.appBarTitle, }); final int selectedIndex; final ValueChanged onDestinationSelected; final Widget body; final VoidCallback? onCenterButtonPressed; final String? appBarTitle; @override Widget build(BuildContext context) { return Scaffold( appBar: appBarTitle != null ? AppBar(title: Text(appBarTitle!)) : null, body: body, extendBody: true, // 允许内容延伸到 Tab 栏下面(圆角透明效果) bottomNavigationBar: _BottomNavBar( selectedIndex: selectedIndex, onDestinationSelected: onDestinationSelected, onCenterButtonPressed: onCenterButtonPressed, ), ); } } /// 自定义底部导航栏 — 90px 总高 + 中心凸起 FAB class _BottomNavBar extends StatelessWidget { const _BottomNavBar({ required this.selectedIndex, required this.onDestinationSelected, this.onCenterButtonPressed, }); final int selectedIndex; final ValueChanged onDestinationSelected; final VoidCallback? onCenterButtonPressed; @override Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; final bottomPadding = MediaQuery.of(context).padding.bottom; // spec §2.2: 总高 = 56 (tab-height) + bottomPadding (safe-bottom, 通常 34) return Material( color: Colors.transparent, child: SizedBox( height: 56 + bottomPadding, child: Stack( clipBehavior: Clip.none, alignment: Alignment.topCenter, children: [ // Tab 栏主体 Positioned( left: 0, right: 0, top: 0, bottom: 0, child: Container( decoration: BoxDecoration( color: colorScheme.surface, border: Border( top: BorderSide(color: colorScheme.outlineVariant, width: 1), ), ), padding: EdgeInsets.only(bottom: bottomPadding), child: Row( children: [ // 首页 Expanded( child: _NavItem( icon: Icons.home_outlined, activeIcon: Icons.home, label: '首页', isSelected: selectedIndex == 0, color: colorScheme.primary, inactiveColor: colorScheme.onSurfaceVariant, onTap: () => onDestinationSelected(0), ), ), // 日历 Expanded( child: _NavItem( icon: Icons.calendar_month_outlined, activeIcon: Icons.calendar_month, label: '日历', isSelected: selectedIndex == 1, color: colorScheme.primary, inactiveColor: colorScheme.onSurfaceVariant, onTap: () => onDestinationSelected(1), ), ), // 中间 FAB 占位 const SizedBox(width: 64), // 发现 Expanded( child: _NavItem( icon: Icons.explore_outlined, activeIcon: Icons.explore, label: '发现', isSelected: selectedIndex == 2, color: colorScheme.primary, inactiveColor: colorScheme.onSurfaceVariant, onTap: () => onDestinationSelected(2), ), ), // 我的 Expanded( child: _NavItem( icon: Icons.person_outline, activeIcon: Icons.person, label: '我的', isSelected: selectedIndex == 3, color: colorScheme.primary, inactiveColor: colorScheme.onSurfaceVariant, onTap: () => onDestinationSelected(3), ), ), ], ), ), ), // 中心凸起写按钮 — margin-top:-16 凸出 Tab 栏顶部 if (onCenterButtonPressed != null) Positioned( top: -16, child: _CenterFabButton(onPressed: onCenterButtonPressed!), ), ], ), ), ); } } /// 单个底部导航项 class _NavItem extends StatelessWidget { const _NavItem({ required this.icon, required this.activeIcon, required this.label, required this.isSelected, required this.color, required this.inactiveColor, required this.onTap, }); final IconData icon; final IconData activeIcon; final String label; final bool isSelected; final Color color; final Color inactiveColor; final VoidCallback onTap; @override Widget build(BuildContext context) { return InkWell( onTap: onTap, customBorder: const CircleBorder(), child: SizedBox( width: 64, height: 56, child: Column( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ Icon( isSelected ? activeIcon : icon, size: 24, color: isSelected ? color : inactiveColor, ), const SizedBox(height: 2), Text( label, style: TextStyle( fontSize: 11, fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, color: isSelected ? color : inactiveColor, ), ), ], ), ), ); } } // ===== 平板布局 — 侧边 NavigationRail ===== class _TabletLayout extends StatelessWidget { const _TabletLayout({ required this.selectedIndex, required this.onDestinationSelected, required this.body, this.onCenterButtonPressed, this.appBarTitle, }); final int selectedIndex; final ValueChanged onDestinationSelected; final Widget body; final VoidCallback? onCenterButtonPressed; final String? appBarTitle; @override Widget build(BuildContext context) { final theme = Theme.of(context); return Scaffold( appBar: appBarTitle != null ? AppBar(title: Text(appBarTitle!)) : null, body: Row( children: [ NavigationRail( selectedIndex: selectedIndex, onDestinationSelected: onDestinationSelected, destinations: _railItems, leading: Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.edit_note_rounded, size: 32, color: theme.colorScheme.primary, ), const SizedBox(height: 8), if (onCenterButtonPressed != null) FloatingActionButton.small( heroTag: 'rail_write', onPressed: onCenterButtonPressed, backgroundColor: AppColors.accent, foregroundColor: const Color(0xFFFFF8F0), child: const Icon(Icons.edit_rounded, size: 20), ), ], ), ), ), const VerticalDivider(thickness: 1, width: 1), Expanded(child: body), ], ), ); } } // ===== 桌面布局 — 三栏 ===== class _DesktopLayout extends StatelessWidget { const _DesktopLayout({ required this.selectedIndex, required this.onDestinationSelected, required this.body, this.onCenterButtonPressed, this.secondaryBody, this.appBarTitle, }); final int selectedIndex; final ValueChanged onDestinationSelected; final Widget body; final VoidCallback? onCenterButtonPressed; final Widget? secondaryBody; final String? appBarTitle; @override Widget build(BuildContext context) { final theme = Theme.of(context); return Scaffold( appBar: appBarTitle != null ? AppBar(title: Text(appBarTitle!)) : null, body: Row( children: [ NavigationRail( selectedIndex: selectedIndex, onDestinationSelected: onDestinationSelected, destinations: _railItems, extended: true, leading: Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Column( mainAxisSize: MainAxisSize.min, children: [ Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.edit_note_rounded, size: 32, color: theme.colorScheme.primary, ), const SizedBox(width: 12), Text( '暖记', style: theme.textTheme.headlineSmall?.copyWith( fontFamily: AppTypography.handwrittenFont, color: theme.colorScheme.primary, ), ), ], ), const SizedBox(height: 12), if (onCenterButtonPressed != null) Padding( padding: const EdgeInsets.only(right: 16), child: ElevatedButton.icon( onPressed: onCenterButtonPressed, icon: const Icon(Icons.edit_rounded, size: 18), label: const Text('写日记'), style: ElevatedButton.styleFrom( backgroundColor: AppColors.accent, foregroundColor: const Color(0xFFFFF8F0), shape: RoundedRectangleBorder( borderRadius: AppRadius.pillBorder, ), ), ), ), ], ), ), ), const VerticalDivider(thickness: 1, width: 1), // 主内容区 Expanded( flex: 3, child: body, ), // 第二面板(日记详情/预览) if (secondaryBody != null) ...[ const VerticalDivider(thickness: 1, width: 1), Expanded( flex: 2, child: secondaryBody!, ), ], ], ), ); } }