Files
nj/app/lib/widgets/responsive_scaffold.dart
iven 750605e479
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
feat(app): 全局离线提示横幅 — 网络不可用时显示黄色警告
- 新增 OfflineBanner widget: 监听 connectivity_plus 自动显示/隐藏
- AnimatedCrossFade 滑入滑出动画 + warmCurve 弹性曲线
- 黄色警告条: wifi_off 图标 + '网络不可用,部分功能受限'
- 嵌入 ResponsiveScaffold 的 body 上方 (手机/平板/桌面三端)
- 只在离线时显示,恢复网络后自动消失
2026-06-07 13:44:40 +08:00

504 lines
16 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 暖记响应式骨架 — 三级自适应布局
// 手机: 底部 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';
import 'offline_banner.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<int> onDestinationSelected;
/// 主内容区
final Widget body;
/// 中心写日记按钮回调
final VoidCallback? onCenterButtonPressed;
final String? appBarTitle;
final Widget? secondaryBody;
@override
State<ResponsiveScaffold> createState() => _ResponsiveScaffoldState();
}
class _ResponsiveScaffoldState extends State<ResponsiveScaffold> {
@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<int> 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: OfflineBanner(child: 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<int> 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<int> 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<int> 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!,
),
],
],
),
);
}
}