Files
xianyan/lib/editor/widgets/controls/editor_top_nav.dart
Developer b11d53ca58 feat: 新增画布样式后处理功能及导出一致性优化
refactor(editor): 将画布样式相关功能移至独立文件
fix(editor): 修复描边坐标偏移问题
feat(export): 新增applyCanvasStyle后处理方法
feat(settings): 实现CanvasStyleModel持久化存储
fix(feed): 互动操作增加登录检查避免401错误
feat(wallpaper): 实现无限下拉加载功能
fix(text): 修复富文本编辑器初始化问题
2026-05-05 08:24:28 +08:00

350 lines
9.8 KiB
Dart

// ============================================================
// 闲言APP — 编辑器顶部导航栏
// 创建时间: 2026-05-03
// 更新时间: 2026-05-05
// 作用: 3个浮动胶囊(左:关闭/保存/撤销/重做 中:图片信息 右:工具抽屉/主题/导出)
// 上次更新: 右侧胶囊增加工具抽屉按钮(onToolDrawer)
// ============================================================
import 'dart:typed_data';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:liquid_glass_widgets/liquid_glass_widgets.dart';
import 'package:pro_image_editor/pro_image_editor.dart' as pro;
import 'package:xianyan/editor/services/core/color_tokens.dart';
import 'package:xianyan/editor/services/core/editor_theme_notifier.dart';
import 'package:xianyan/editor/services/image/image_info_service.dart';
import 'package:xianyan/editor/widgets/controls/editor_icon.dart';
class EditorTopNav extends StatefulWidget {
const EditorTopNav({
super.key,
required this.editor,
required this.imageBytes,
required this.safeTop,
required this.onClose,
required this.onSave,
required this.onUndo,
required this.onRedo,
required this.onThemeToggle,
required this.onExport,
this.onSettings,
this.onToolDrawer,
this.onCanvasStyle,
});
final pro.ProImageEditorState editor;
final Uint8List imageBytes;
final double safeTop;
final VoidCallback onClose;
final VoidCallback onSave;
final VoidCallback onUndo;
final VoidCallback onRedo;
final VoidCallback onThemeToggle;
final VoidCallback onExport;
final VoidCallback? onSettings;
final VoidCallback? onToolDrawer;
final VoidCallback? onCanvasStyle;
@override
State<EditorTopNav> createState() => _EditorTopNavState();
}
class _EditorTopNavState extends State<EditorTopNav> {
ImageInfoResult? _imageInfo;
@override
void initState() {
super.initState();
_loadImageInfo();
}
void _loadImageInfo() {
final info = ImageInfoService.getImageInfoFromBytes(widget.imageBytes);
if (mounted) setState(() => _imageInfo = info);
}
@override
Widget build(BuildContext context) {
final p = EditorThemeNotifier.of(context);
final isLandscape =
MediaQuery.of(context).orientation == Orientation.landscape;
return Padding(
padding: EdgeInsets.only(top: widget.safeTop + 4, left: 12, right: 12),
child: Align(
alignment: Alignment.topCenter,
child: isLandscape ? _buildLandscapeLayout(p) : _buildPortraitLayout(p),
),
);
}
Widget _buildLandscapeLayout(EditorPalette p) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [_buildLeftPill(p), _buildCenterPill(p), _buildRightPill(p)],
);
}
Widget _buildPortraitLayout(EditorPalette p) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [_buildLeftPill(p), _buildCenterPill(p)],
),
const SizedBox(height: 6),
Align(alignment: Alignment.centerRight, child: _buildRightPill(p)),
],
);
}
Widget _buildLeftPill(EditorPalette p) {
return _NavPill(
palette: p,
children: [
_NavBtn(
icon: EditorIcon.cupertino('xmark', size: 16, color: p.textPrimary),
semanticsLabel: '关闭',
onTap: widget.onClose,
palette: p,
),
_PillDivider(palette: p),
_NavBtn(
icon: EditorIcon.svg('save', size: 16, color: p.textPrimary),
semanticsLabel: '保存',
onTap: widget.onSave,
palette: p,
isAccent: true,
),
_PillDivider(palette: p),
_NavBtn(
icon: EditorIcon.cupertino(
'arrow_uturn_left',
size: 16,
color: p.textPrimary,
),
semanticsLabel: '撤销',
onTap: widget.onUndo,
palette: p,
),
_NavBtn(
icon: EditorIcon.cupertino(
'arrow_uturn_right',
size: 16,
color: p.textPrimary,
),
semanticsLabel: '重做',
onTap: widget.onRedo,
palette: p,
),
],
);
}
Widget _buildCenterPill(EditorPalette p) {
return _NavPill(
palette: p,
children: [_NavCenterInfo(imageInfo: _imageInfo, palette: p)],
);
}
Widget _buildRightPill(EditorPalette p) {
return _NavPill(
palette: p,
children: [
if (widget.onToolDrawer != null)
_NavBtn(
icon: EditorIcon.cupertino(
'sidebar_left',
size: 16,
color: p.textPrimary,
),
semanticsLabel: '工具抽屉',
onTap: widget.onToolDrawer!,
palette: p,
),
if (widget.onToolDrawer != null) _PillDivider(palette: p),
if (widget.onCanvasStyle != null)
_NavBtn(
icon: EditorIcon.cupertino(
'rectangle',
size: 16,
color: p.textPrimary,
),
semanticsLabel: '画布样式',
onTap: widget.onCanvasStyle!,
palette: p,
),
if (widget.onCanvasStyle != null) _PillDivider(palette: p),
_NavBtn(
icon: EditorIcon.svg('moon', size: 16, color: p.textPrimary),
semanticsLabel: '切换主题',
onTap: widget.onThemeToggle,
palette: p,
),
if (widget.onSettings != null)
_NavBtn(
icon: EditorIcon.svg('settings', size: 16, color: p.textPrimary),
semanticsLabel: '设置',
onTap: widget.onSettings!,
palette: p,
),
_NavBtn(
icon: EditorIcon.svg(
'share',
size: 16,
color: EditorColorTokens.primaryLight,
),
semanticsLabel: '导出',
onTap: widget.onExport,
palette: p,
isAccent: true,
),
],
);
}
}
class _NavPill extends StatelessWidget {
const _NavPill({required this.palette, required this.children});
final EditorPalette palette;
final List<Widget> children;
@override
Widget build(BuildContext context) {
return AdaptiveLiquidGlassLayer(
settings: const LiquidGlassSettings(
blur: 1.0,
refractiveIndex: 1.45,
chromaticAberration: 0.6,
lightIntensity: 1.0,
saturation: 1.0,
ambientStrength: 0.5,
glassColor: Color.from(alpha: 0.06, red: 1, green: 1, blue: 1),
),
shape: const LiquidRoundedSuperellipse(borderRadius: 9999),
child: Container(
height: 44,
padding: const EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(
border: Border.all(color: palette.borderPrimary, width: 0.5),
borderRadius: BorderRadius.circular(9999),
boxShadow: [palette.boxShadowBorder],
),
child: Row(mainAxisSize: MainAxisSize.min, children: children),
),
);
}
}
class _NavBtn extends StatefulWidget {
const _NavBtn({
required this.icon,
required this.semanticsLabel,
required this.onTap,
required this.palette,
this.isAccent = false,
});
final Widget icon;
final String semanticsLabel;
final VoidCallback onTap;
final EditorPalette palette;
final bool isAccent;
@override
State<_NavBtn> createState() => _NavBtnState();
}
class _NavBtnState extends State<_NavBtn> {
bool _isPressed = false;
@override
Widget build(BuildContext context) {
final p = widget.palette;
final scale = _isPressed ? 0.90 : 1.0;
return GestureDetector(
onTapDown: (_) => setState(() => _isPressed = true),
onTapUp: (_) => setState(() => _isPressed = false),
onTapCancel: () => setState(() => _isPressed = false),
onTap: widget.onTap,
behavior: HitTestBehavior.opaque,
child: Semantics(
button: true,
label: widget.semanticsLabel,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
transform: Matrix4.diagonal3Values(scale, scale, 1),
transformAlignment: Alignment.center,
width: 34,
height: 34,
alignment: Alignment.center,
decoration: BoxDecoration(
color: _isPressed
? p.bgGlassActive
: widget.isAccent
? EditorColorTokens.primary.withValues(alpha: 0.12)
: Colors.transparent,
borderRadius: BorderRadius.circular(17),
),
child: widget.icon,
),
),
);
}
}
class _PillDivider extends StatelessWidget {
const _PillDivider({required this.palette});
final EditorPalette palette;
@override
Widget build(BuildContext context) {
return Container(
width: 0.5,
height: 20,
margin: const EdgeInsets.symmetric(horizontal: 2),
color: palette.divider,
);
}
}
class _NavCenterInfo extends StatelessWidget {
const _NavCenterInfo({required this.imageInfo, required this.palette});
final ImageInfoResult? imageInfo;
final EditorPalette palette;
@override
Widget build(BuildContext context) {
if (imageInfo == null) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(imageInfo!.formatEmoji, style: const TextStyle(fontSize: 10)),
const SizedBox(width: 3),
Text(
'${imageInfo!.format} · ${imageInfo!.sizeText}',
style: TextStyle(
color: palette.textSecondary,
fontSize: 10,
fontWeight: FontWeight.w500,
),
),
],
),
);
}
}