7b3a0ecdeb
设备管理「价签·编辑」进入独立编辑器屏 /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
1057 lines
35 KiB
Dart
1057 lines
35 KiB
Dart
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)),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|