refactor(editor): 将画布样式相关功能移至独立文件 fix(editor): 修复描边坐标偏移问题 feat(export): 新增applyCanvasStyle后处理方法 feat(settings): 实现CanvasStyleModel持久化存储 fix(feed): 互动操作增加登录检查避免401错误 feat(wallpaper): 实现无限下拉加载功能 fix(text): 修复富文本编辑器初始化问题
350 lines
9.8 KiB
Dart
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|