Files
jiu/client/lib/screens/devices/label_template_editor_screen.dart
T
wangjia 7b3a0ecdeb feat(client): 标签模板编辑器 P2 · 编辑器屏+存储+打印联动(原型同提交)
设备管理「价签·编辑」进入独立编辑器屏 /devices/label-template:
- 左实时预览走真实 renderLabelPreview 渲染路径(示例数据, debounce 重渲)
- 右属性面板:多模板管理(编辑对象/打印默认分离)、显示字段手风琴、
  高级区域位置、打印参数
- 存 shop.custom_fields.label_templates + label_template_active(零后端改)
- Option A:同物理行两字段(编号+型号 / 版本+日期)共用字号·加粗·对齐,
  各自独立开关;仅品名支持自动字号;字体不做(热敏单色)
- 打印联动:LabelPreviewDialog 改 ConsumerStatefulWidget, 未显式传模板时
  自动取本店 active 模板→所有打印入口(商品详情/出入库列表/编辑抽屉)一致生效

守护:
- 默认输出逐像素零变化由 label_render_golden_test 守(改内置默认常量才触发)
- 存储读写由 label_template_storage_test 单测守(纯内存, 不触真实库)
- 编辑器挂载由 label_template_editor_smoke_test 守
- 本屏不入自动 fidelity/golden 闸(左预览 Picture.toImage 需 runAsync,
  pumpAndSettle 骨架驱动不了), 原因+替代守法记入 CONTRACT 块4

design-first:原型 label-template-editor.html 去字体选择器、加 Option A 配对
镜像, 与真实屏同提交。真机热敏打印坐标/条码可扫须用户在打印机验收。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bupi8Kdqkfx2N5acFsHTx5
2026-08-30 09:56:23 +08:00

1057 lines
35 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.
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:qr_flutter/qr_flutter.dart';
import '../../core/responsive/responsive.dart';
import '../../core/theme/app_dims.g.dart';
import '../../core/theme/app_tokens.dart';
import '../../core/theme/context_tokens.dart';
import '../../core/utils/dialog_util.dart';
import '../../core/utils/label_template.dart';
import '../../core/utils/print_util.dart' show renderLabelPreview, LabelData;
import '../../providers/shop_provider.dart';
import '../../widgets/ds/ds_atoms.dart';
import '../../widgets/ds/ds_switch.dart';
import '../../widgets/ds/ds_toast.dart';
/// 标签价签模板编辑器(原型 `design/prototype/screens/label-template-editor.html`)。
///
/// 左实时预览(走真实 [renderLabelPreview] 渲染路径,示例数据)+ 右属性面板:
/// 模板管理(编辑对象/打印默认分离)· 显示字段(手风琴,Option A:同物理行两字段
/// 共用字号/加粗/对齐,各自可独立开关)· 高级区域位置 · 打印参数。
///
/// 存储:`shop.custom_fields.label_templates`List+ `label_template_active`id),
/// 照抄 `device_management_screen._savePeripherals` 的 updateInfo 增量写,零后端改动。
class LabelTemplateEditorScreen extends ConsumerStatefulWidget {
const LabelTemplateEditorScreen({super.key});
@override
ConsumerState<LabelTemplateEditorScreen> createState() =>
_LabelTemplateEditorScreenState();
}
/// 副字段行的规范化分组(与 builtinDefault 一致):编辑器把 subLines 归一到这四行,
/// 每行的样式(字号/加粗/对齐)为组内共用(Option A)。
const List<List<LabelBinding>> _lineGroups = [
[LabelBinding.code, LabelBinding.series], // 行 0
[LabelBinding.spec, LabelBinding.productionDate], // 行 1
[LabelBinding.batchNo], // 行 2
[LabelBinding.remark], // 行 3
];
enum _FKind { name, header, sub, qr, barcode }
class _Fd {
final String key;
final String label;
final _FKind kind;
final int line; // sub 专用,其余 -1
final LabelBinding? binding; // sub 专用
const _Fd(this.key, this.label, this.kind, {this.line = -1, this.binding});
}
const List<_Fd> _fields = [
_Fd('name', '品名', _FKind.name),
_Fd('shop', '店名(抬头)', _FKind.header),
_Fd('code', '编号', _FKind.sub, line: 0, binding: LabelBinding.code),
_Fd('series', '型号 / 度数', _FKind.sub, line: 0, binding: LabelBinding.series),
_Fd('spec', '版本 / 规格', _FKind.sub, line: 1, binding: LabelBinding.spec),
_Fd('date', '生产日期', _FKind.sub,
line: 1, binding: LabelBinding.productionDate),
_Fd('batch', '批次号', _FKind.sub, line: 2, binding: LabelBinding.batchNo),
_Fd('remark', '备注', _FKind.sub, line: 3, binding: LabelBinding.remark),
_Fd('qr', '二维码', _FKind.qr),
_Fd('barcode', '条形码', _FKind.barcode),
];
const Map<LabelAlign, String> _alignLabel = {
LabelAlign.left: '',
LabelAlign.center: '居中',
LabelAlign.right: '',
};
class _LabelTemplateEditorScreenState
extends ConsumerState<LabelTemplateEditorScreen> {
List<LabelTemplate> _templates = [];
String _editingId = '';
String _activeId = '';
bool _loaded = false;
bool _saving = false;
Uint8List? _qrBytes;
Uint8List? _previewPng;
double _zoom = 1.0;
String? _openField;
Timer? _renderDebounce;
final Map<String, TextEditingController> _ctrls = {};
final TextEditingController _sizeCtrl = TextEditingController();
LabelTemplate get _t =>
_templates.firstWhere((t) => t.id == _editingId, orElse: () => _templates.first);
@override
void initState() {
super.initState();
_bootstrap();
}
@override
void dispose() {
_renderDebounce?.cancel();
for (final c in _ctrls.values) {
c.dispose();
}
_sizeCtrl.dispose();
super.dispose();
}
Future<void> _bootstrap() async {
// 门店先到即可点亮主界面(不阻塞在二维码光栅化上,首帧更快)。
final shop = await ref.read(shopInfoProvider.future);
final list = readLabelTemplates(shop.customFields)
.map((t) => _normalize(t))
.toList();
final activeId = activeLabelTemplate(shop.customFields).id;
if (!mounted) return;
setState(() {
_templates = list;
_activeId = activeId;
_editingId = activeId;
_loaded = true;
});
// 示例二维码(与真实标签同一渲染路径,仅数据是示例)。光栅化异步,好则渲染预览。
final painter = QrPainter(
data: 'https://jiu.51yanmei.com/p/sample',
version: QrVersions.auto,
gapless: true,
);
final img = await painter.toImageData(240);
if (!mounted || img == null) return;
_qrBytes = img.buffer.asUint8List();
_renderPreview();
}
/// 把任意模板的副字段行归一到 [_lineGroups] 四行(保留原行样式,缺行补空行),
/// 使字段增删=绑定的加减,同物理行样式天然共用。空行渲染时被跳过,出参不变。
LabelTemplate _normalize(LabelTemplate src) {
final t = src.copyWith();
final present = <LabelBinding>{};
final styleOf = <LabelBinding, LabelSubLine>{};
for (final line in t.textStack.subLines) {
for (final b in line.bindings) {
present.add(b);
styleOf[b] = line;
}
}
final canon = <LabelSubLine>[];
for (final group in _lineGroups) {
LabelSubLine? ref;
for (final b in group) {
if (styleOf[b] != null) {
ref = styleOf[b];
break;
}
}
canon.add(LabelSubLine(
bindings: group.where(present.contains).toList(),
fontSize: ref?.fontSize ?? 14,
bold: ref?.bold ?? false,
align: ref?.align ?? LabelAlign.center,
));
}
t.textStack.subLines = canon;
return t;
}
// ── 预览渲染(debounce) ─────────────────────────────────────────────
void _scheduleRender() {
_renderDebounce?.cancel();
_renderDebounce =
Timer(const Duration(milliseconds: 150), _renderPreview);
}
Future<void> _renderPreview() async {
final qr = _qrBytes;
if (qr == null) return;
final shopName =
ref.read(shopInfoProvider).valueOrNull?.name ?? '鼎晟酒行';
final sample = LabelData(
qrBytes: qr,
name: '贵州茅台酒',
code: 'P1001',
series: '飞天53度',
spec: '500ml',
productionDate: '2024-05-18',
batchNo: '20240518-01',
remark: '整箱现货',
shopName: shopName,
);
final png = await renderLabelPreview(sample, template: _t);
if (mounted && png != null) setState(() => _previewPng = png);
}
void _mutate(VoidCallback fn) {
setState(fn);
_scheduleRender();
}
// ── 字段读写(Option A:sub 按物理行共用样式) ────────────────────────
bool _onOf(_Fd fd) => switch (fd.kind) {
_FKind.name => _t.textStack.name.show,
_FKind.header => _t.header.show,
_FKind.qr => _t.qr.show,
_FKind.barcode => _t.barcode.show,
_FKind.sub =>
_t.textStack.subLines[fd.line].bindings.contains(fd.binding),
};
void _setOn(_Fd fd, bool v) => _mutate(() {
switch (fd.kind) {
case _FKind.name:
_t.textStack.name.show = v;
case _FKind.header:
_t.header.show = v;
case _FKind.qr:
_t.qr.show = v;
case _FKind.barcode:
_t.barcode.show = v;
case _FKind.sub:
final group = _lineGroups[fd.line];
final line = _t.textStack.subLines[fd.line];
final cur = line.bindings.toSet();
if (v) {
cur.add(fd.binding!);
} else {
cur.remove(fd.binding!);
}
line.bindings = group.where(cur.contains).toList();
}
});
bool _isAuto(_Fd fd) =>
fd.kind == _FKind.name && _t.textStack.name.fontSize == null;
double _sizeOf(_Fd fd) => switch (fd.kind) {
_FKind.name => _t.textStack.name.fontSize ?? _t.textStack.name.fontMax,
_FKind.header => _t.header.fontSize,
_FKind.sub => _t.textStack.subLines[fd.line].fontSize,
_ => 14,
};
void _setSize(_Fd fd, double v) => _mutate(() {
switch (fd.kind) {
case _FKind.name:
_t.textStack.name.fontSize = v;
case _FKind.header:
_t.header.fontSize = v;
case _FKind.sub:
_t.textStack.subLines[fd.line].fontSize = v;
default:
break;
}
});
void _setAuto(_Fd fd, bool v) => _mutate(() {
_t.textStack.name.fontSize = v ? null : _t.textStack.name.fontMax;
});
bool _boldOf(_Fd fd) => switch (fd.kind) {
_FKind.name => _t.textStack.name.bold,
_FKind.header => _t.header.bold,
_FKind.sub => _t.textStack.subLines[fd.line].bold,
_ => false,
};
void _setBold(_Fd fd, bool v) => _mutate(() {
switch (fd.kind) {
case _FKind.name:
_t.textStack.name.bold = v;
case _FKind.header:
_t.header.bold = v;
case _FKind.sub:
_t.textStack.subLines[fd.line].bold = v;
default:
break;
}
});
LabelAlign _alignOf(_Fd fd) => switch (fd.kind) {
_FKind.name => _t.textStack.name.align,
_FKind.header => _t.header.align,
_FKind.sub => _t.textStack.subLines[fd.line].align,
_ => LabelAlign.center,
};
void _setAlign(_Fd fd, LabelAlign a) => _mutate(() {
switch (fd.kind) {
case _FKind.name:
_t.textStack.name.align = a;
case _FKind.header:
_t.header.align = a;
case _FKind.sub:
_t.textStack.subLines[fd.line].align = a;
default:
break;
}
});
String _metaText(_Fd fd) {
if (fd.kind == _FKind.qr || fd.kind == _FKind.barcode) {
return '图形 · 位置见高级';
}
final parts = <String>[
_isAuto(fd) ? '自动字号' : '${_sizeOf(fd).round()}px',
];
if (_boldOf(fd)) parts.add('加粗');
parts.add(_alignLabel[_alignOf(fd)]!);
return parts.join(' · ');
}
// ── 模板管理 ─────────────────────────────────────────────────────────
void _switchEditing(String id) {
setState(() {
_editingId = id;
_openField = null;
});
_scheduleRender();
}
String _uniqueName(String base) {
final names = _templates.map((t) => t.name).toSet();
if (!names.contains(base)) return base;
var i = 2;
while (names.contains('$base $i')) {
i++;
}
return '$base $i';
}
String _newId() =>
'tpl-${DateTime.now().microsecondsSinceEpoch.toRadixString(36)}';
void _newTemplate() {
final t = _normalize(LabelTemplate.builtinDefault())
.copyWith(id: _newId(), name: _uniqueName('新模板'));
setState(() {
_templates = [..._templates, t];
_editingId = t.id;
_openField = null;
});
_scheduleRender();
_toast('已新建「${t.name}');
}
void _duplicateTemplate() {
final src = _t;
final t = src.copyWith(id: _newId(), name: _uniqueName('${src.name} 副本'));
setState(() {
_templates = [..._templates, t];
_editingId = t.id;
_openField = null;
});
_scheduleRender();
_toast('已复制为「${t.name}');
}
Future<void> _renameTemplate() async {
final ctrl = TextEditingController(text: _t.name);
final ok = await showAppDialog<bool>(
context: context,
builder: (ctx) => _NameDialog(title: '重命名模板', controller: ctrl),
);
if (ok == true) {
final v = ctrl.text.trim();
if (v.isNotEmpty) {
setState(() => _t.name = v);
_toast('已重命名 ✓');
}
}
ctrl.dispose();
}
void _deleteTemplate() {
if (_templates.length <= 1) {
_toast('至少保留一个模板', err: true);
return;
}
final gone = _t;
setState(() {
_templates = _templates.where((t) => t.id != gone.id).toList();
if (_activeId == gone.id) _activeId = _templates.first.id;
_editingId = _templates.first.id;
_openField = null;
});
_scheduleRender();
_toast('已删除「${gone.name}');
}
void _setDefault() {
setState(() => _activeId = _editingId);
_toast('已设为打印默认:${_t.name}');
}
void _reset() {
final fresh = _normalize(LabelTemplate.builtinDefault())
.copyWith(id: _t.id, name: _t.name);
final idx = _templates.indexWhere((t) => t.id == _editingId);
setState(() {
_templates[idx] = fresh;
_openField = null;
});
_scheduleRender();
_toast('已重置为默认版式');
}
Future<void> _save() async {
if (_saving) return;
setState(() => _saving = true);
try {
final shop = await ref.read(shopInfoProvider.future);
final cf = Map<String, dynamic>.from(shop.customFields)
..['label_templates'] = _templates.map((t) => t.toJson()).toList()
..['label_template_active'] = _activeId;
await ref.read(shopRepositoryProvider).updateInfo({
'name': shop.name,
'address': shop.address,
'phone': shop.phone,
'manager_name': shop.managerName,
'wechat_id': shop.wechatId,
if (shop.logoUrl.isNotEmpty) 'logo_url': shop.logoUrl,
'custom_fields': cf,
});
ref.invalidate(shopInfoProvider);
if (mounted) _toast('模板已保存 ✓');
} catch (e) {
if (mounted) _toast('保存失败:$e', err: true);
} finally {
if (mounted) setState(() => _saving = false);
}
}
void _toast(String msg, {bool err = false}) {
showDsToast(context, msg,
bg: err ? context.tokens.danger : context.tokens.success);
}
// ── build ────────────────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
final t = context.tokens;
if (!_loaded) {
return Container(
color: t.bg,
child: const Center(child: CircularProgressIndicator()),
);
}
final mobile = context.isMobile;
return Container(
color: t.bg,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_header(t),
Expanded(
child: mobile
? SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 20),
child: Column(
children: [
_stage(t, compact: true),
const SizedBox(height: 16),
_panel(t),
],
),
)
: Padding(
padding: const EdgeInsets.fromLTRB(26, 0, 26, 22),
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: _stage(t)),
const SizedBox(width: 22),
SizedBox(
width: 400,
child: SingleChildScrollView(child: _panel(t)),
),
],
),
),
),
],
),
);
}
Widget _header(AppTokens t) => Padding(
padding: const EdgeInsets.fromLTRB(26, 22, 26, 18),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
DsButton('返回',
icon: LucideIcons.arrowLeft,
small: true,
onPressed: () => context.pop()),
const SizedBox(width: 14),
Text('标签模板编辑器',
style: TextStyle(
fontSize: AppDims.fsH1,
fontWeight: FontWeight.w700,
color: t.heading)),
const SizedBox(width: 10),
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text('左侧实时预览 · 右侧属性面板',
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
),
const Spacer(),
DsButton('重置', icon: LucideIcons.refreshCw, onPressed: _reset),
const SizedBox(width: 10),
DsButton(_saving ? '保存中…' : '保存',
icon: LucideIcons.check,
variant: DsBtnVariant.primary,
onPressed: _saving ? null : _save),
],
),
);
// ── 左:实时预览舞台 ──────────────────────────────────────────────────
Widget _stage(AppTokens t, {bool compact = false}) {
final png = _previewPng;
return Container(
decoration: BoxDecoration(
color: t.surface,
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rLg),
),
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('实时预览',
style: TextStyle(
fontSize: AppDims.fsSm,
fontWeight: FontWeight.w600,
color: t.muted)),
SizedBox(height: compact ? 16 : 22),
Center(
child: Container(
width: 320 * _zoom,
height: 160 * _zoom,
decoration: BoxDecoration(
color: Colors.white, // ds-ignore: 价签纸面模拟固定色
borderRadius: BorderRadius.circular(AppDims.rSm),
boxShadow: [
BoxShadow(
color: t.shadow,
blurRadius: 10,
offset: const Offset(0, 4)),
],
),
clipBehavior: Clip.antiAlias,
child: png != null
? Image.memory(png, fit: BoxFit.fill, gaplessPlayback: true)
: Center(
child: Text('预览生成中…',
style: TextStyle(
fontSize: AppDims.fsXs,
color: t.muted))), // 静态占位,~150ms 后被首帧替换
),
),
SizedBox(height: compact ? 16 : 22),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('缩放',
style:
TextStyle(fontSize: AppDims.fsXs, color: t.muted)),
const SizedBox(width: 12),
DsSeg(
items: const ['75%', '100%', '125%'],
index: _zoom == 0.75
? 0
: (_zoom == 1.25 ? 2 : 1),
onChanged: (i) => setState(
() => _zoom = i == 0 ? 0.75 : (i == 2 ? 1.25 : 1.0)),
),
],
),
const SizedBox(height: 12),
Center(
child: Text('40 × 20 mm · 203 dpi · 示例数据',
style: TextStyle(fontSize: AppDims.fsXs, color: t.muted)),
),
],
),
);
}
// ── 右:属性面板 ──────────────────────────────────────────────────────
Widget _panel(AppTokens t) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_templateSection(t),
const SizedBox(height: 14),
_fieldSection(t),
const SizedBox(height: 14),
_advancedSection(t),
const SizedBox(height: 14),
_printSection(t),
],
);
Widget _sectionCard(AppTokens t, String title, IconData icon, Widget body,
{Widget? titleTrailing}) {
return Container(
decoration: BoxDecoration(
color: t.surface,
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rLg),
),
padding: const EdgeInsets.fromLTRB(16, 16, 16, 18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(icon, size: 16, color: t.primary),
const SizedBox(width: 8),
Text(title,
style: TextStyle(
fontSize: AppDims.fsSm,
fontWeight: FontWeight.w700,
color: t.title)),
if (titleTrailing != null) ...[
const Spacer(),
titleTrailing,
],
],
),
const SizedBox(height: 12),
body,
],
),
);
}
Widget _templateSection(AppTokens t) {
final activeName =
_templates.firstWhere((x) => x.id == _activeId, orElse: () => _t).name;
return _sectionCard(
t,
'模板',
LucideIcons.layoutTemplate,
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DsSelect<String>(
value: _editingId,
options: [
for (final tpl in _templates)
(tpl.id, tpl.id == _activeId ? '${tpl.name}(打印默认)' : tpl.name),
],
onChanged: _switchEditing,
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
DsButton('新建',
icon: LucideIcons.plus, small: true, onPressed: _newTemplate),
DsButton('复制',
icon: LucideIcons.copy,
small: true,
onPressed: _duplicateTemplate),
DsButton('重命名',
icon: LucideIcons.pencil,
small: true,
onPressed: _renameTemplate),
DsButton('设默认',
icon: LucideIcons.check,
small: true,
onPressed: _setDefault),
DsButton('删除',
icon: LucideIcons.x,
small: true,
variant: DsBtnVariant.danger,
onPressed: _deleteTemplate),
],
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.only(top: 12),
decoration: BoxDecoration(
border: Border(top: BorderSide(color: t.border)),
),
child: Row(
children: [
Icon(LucideIcons.check, size: 14, color: t.primary),
const SizedBox(width: 6),
Text('出签打印使用:',
style:
TextStyle(fontSize: AppDims.fsXs, color: t.muted)),
Flexible(
child: Text(activeName,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: AppDims.fsXs,
fontWeight: FontWeight.w600,
color: t.primary)),
),
],
),
),
],
),
);
}
Widget _fieldSection(AppTokens t) => _sectionCard(
t,
'显示字段',
LucideIcons.eye,
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final fd in _fields) _fieldRow(t, fd),
],
),
titleTrailing: Text('点字段名展开样式',
style: TextStyle(fontSize: AppDims.fsXs, color: t.muted)),
);
Widget _fieldRow(AppTokens t, _Fd fd) {
final on = _onOf(fd);
final open = _openField == fd.key;
final graphic = fd.kind == _FKind.qr || fd.kind == _FKind.barcode;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
InkWell(
borderRadius: BorderRadius.circular(AppDims.rMd),
onTap: () => setState(() {
if (open) {
_openField = null;
} else {
_openField = fd.key;
_sizeCtrl.text =
graphic || _isAuto(fd) ? '' : _sizeOf(fd).round().toString();
}
}),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 9),
decoration: BoxDecoration(
color: open ? t.accentSoft : null,
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: Row(
children: [
DsSwitch(value: on, onChanged: (v) => _setOn(fd, v)),
const SizedBox(width: 10),
Expanded(
child: Text(fd.label,
style: TextStyle(
fontSize: AppDims.fsBody,
color: on ? t.text : t.muted,
decoration:
on ? null : TextDecoration.lineThrough)),
),
Text(_metaText(fd),
style:
TextStyle(fontSize: AppDims.fsXs, color: t.muted)),
const SizedBox(width: 8),
AnimatedRotation(
turns: open ? 0.5 : 0,
duration: const Duration(milliseconds: 180),
child: Icon(LucideIcons.chevronDown,
size: 16, color: t.muted),
),
],
),
),
),
if (open)
Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 8, 12),
child: graphic ? _graphicBody(t) : _textBody(t, fd),
),
],
);
}
Widget _graphicBody(AppTokens t) => Text(
'图形字段,无文字样式。位置与尺寸在下方「高级 · 区域位置」中调整。',
style: TextStyle(
fontSize: AppDims.fsXs, color: t.muted, height: 1.6),
);
Widget _textBody(AppTokens t, _Fd fd) {
final auto = _isAuto(fd);
final isName = fd.kind == _FKind.name;
final align = _alignOf(fd);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 字号(品名可切自动)
Row(
children: [
SizedBox(
width: 44,
child: Text('字号',
style: TextStyle(
fontSize: AppDims.fsSm, color: t.muted))),
SizedBox(
width: 88,
child: DsInput(
controller: _sizeCtrl,
enabled: !auto,
keyboardType:
const TextInputType.numberWithOptions(decimal: false),
onChanged: (v) {
final n = int.tryParse(v.trim());
if (n != null && n > 0) _setSize(fd, n.toDouble());
},
),
),
if (isName) ...[
const SizedBox(width: 12),
Text('自动',
style:
TextStyle(fontSize: AppDims.fsXs, color: t.muted)),
const SizedBox(width: 6),
DsSwitch(
value: auto,
onChanged: (v) {
_setAuto(fd, v);
_sizeCtrl.text = v ? '' : _sizeOf(fd).round().toString();
},
),
],
],
),
const SizedBox(height: 12),
Row(
children: [
SizedBox(
width: 44,
child: Text('加粗',
style: TextStyle(
fontSize: AppDims.fsSm, color: t.muted))),
DsSwitch(value: _boldOf(fd), onChanged: (v) => _setBold(fd, v)),
],
),
const SizedBox(height: 12),
Row(
children: [
SizedBox(
width: 44,
child: Text('对齐',
style: TextStyle(
fontSize: AppDims.fsSm, color: t.muted))),
DsSeg(
items: const ['', '居中', ''],
index: align.index,
onChanged: (i) => _setAlign(fd, LabelAlign.values[i]),
),
],
),
],
);
}
// ── 高级 · 区域位置 ───────────────────────────────────────────────────
Widget _advancedSection(AppTokens t) {
return Container(
decoration: BoxDecoration(
color: t.surface,
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rLg),
),
clipBehavior: Clip.antiAlias,
child: Material(
type: MaterialType.transparency,
child: Theme(
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
tilePadding: const EdgeInsets.symmetric(horizontal: 16),
childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
title: Row(
children: [
Icon(LucideIcons.layoutTemplate, size: 16, color: t.primary),
const SizedBox(width: 8),
Text('高级 · 区域位置',
style: TextStyle(
fontSize: AppDims.fsSm,
fontWeight: FontWeight.w700,
color: t.title)),
],
),
children: [
_subLabel(t, '二维码'),
_grid2([
_num('qrX', 'X', _t.qr.x, (v) => _t.qr.x = v),
_num('qrY', 'Y', _t.qr.y, (v) => _t.qr.y = v),
_num('qrSize', '尺寸', _t.qr.size, (v) => _t.qr.size = v),
]),
const SizedBox(height: 14),
_subLabel(t, '条形码'),
_grid2([
_num('barX', 'X', _t.barcode.x, (v) => _t.barcode.x = v),
_num('barY', 'Y', _t.barcode.y, (v) => _t.barcode.y = v),
_num('barW', '', _t.barcode.width, (v) => _t.barcode.width = v),
_num('barH', '', _t.barcode.height,
(v) => _t.barcode.height = v),
]),
const SizedBox(height: 14),
_subLabel(t, '抬头'),
_grid2([
_num('hdH', '高度', _t.header.height, (v) => _t.header.height = v),
]),
],
),
),
),
);
}
Widget _printSection(AppTokens t) => _sectionCard(
t,
'打印参数',
LucideIcons.settings,
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_grid2([
_num('pw', '纸张宽 (mm)', _t.paper.widthMm,
(v) => _t.paper.widthMm = v),
_num('ph', '纸张高 (mm)', _t.paper.heightMm,
(v) => _t.paper.heightMm = v),
_num('dpi', 'DPI', _t.paper.dpi.toDouble(),
(v) => _t.paper.dpi = v.round()),
_num('gap', '间隔 (mm)', _t.print.gapMm,
(v) => _t.print.gapMm = v),
_num('den', '打印密度', _t.print.density.toDouble(),
(v) => _t.print.density = v.round()),
_num('spd', '打印速度', _t.print.speed.toDouble(),
(v) => _t.print.speed = v.round()),
_num('cop', '份数', _t.print.copies.toDouble(),
(v) => _t.print.copies = v.round()),
]),
const SizedBox(height: 14),
Row(
children: [
SizedBox(
width: 64,
child: Text('打印方向',
style: TextStyle(
fontSize: AppDims.fsSm, color: t.muted))),
DsSeg(
items: const ['正向', '反向'],
index: _t.print.direction == 0 ? 1 : 0,
onChanged: (i) =>
_mutate(() => _t.print.direction = i == 0 ? 1 : 0),
),
],
),
],
),
);
Widget _subLabel(AppTokens t, String s) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Align(
alignment: Alignment.centerLeft,
child: Text(s,
style: TextStyle(fontSize: AppDims.fsXs, color: t.muted)),
),
);
Widget _grid2(List<Widget> children) => Wrap(
spacing: 14,
runSpacing: 10,
children: [
for (final c in children)
SizedBox(width: 158, child: c),
],
);
Widget _num(String key, String label, double value, void Function(double) set,
{bool decimal = false}) {
final ck = '$_editingId/$key';
final c = _ctrls.putIfAbsent(
ck,
() => TextEditingController(
text: decimal ? _fmt(value) : value.round().toString()));
return DsField(
label,
input: DsInput(
controller: c,
keyboardType: TextInputType.numberWithOptions(decimal: decimal),
onChanged: (v) {
final n = double.tryParse(v.trim());
if (n != null) _mutate(() => set(n));
},
),
);
}
String _fmt(double v) =>
v == v.roundToDouble() ? v.round().toString() : v.toString();
}
/// 简单的重命名/命名对话框:DsInput + 取消/保存。
class _NameDialog extends StatelessWidget {
final String title;
final TextEditingController controller;
const _NameDialog({required this.title, required this.controller});
@override
Widget build(BuildContext context) {
final t = context.tokens;
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Container(
width: context.dialogWidth(420),
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(title,
style: TextStyle(
fontSize: AppDims.fsBody,
fontWeight: FontWeight.w700,
color: t.heading)),
const SizedBox(height: 16),
DsField('模板名称',
input: DsInput(
controller: controller,
onSubmitted: (_) => Navigator.of(context).pop(true),
)),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
DsButton('取消',
onPressed: () => Navigator.of(context).pop(false)),
const SizedBox(width: 8),
DsButton('保存',
variant: DsBtnVariant.primary,
onPressed: () => Navigator.of(context).pop(true)),
],
),
],
),
),
);
}
}