Files
jiu/client/lib/screens/devices/label_template_editor_screen.dart
T
wangjia 7dceefc55e feat(client): 价签默认模板品名默认展示 + 二维码放大留小边框
- 默认模板改为代码只读基准:readLabelTemplates 加载时强制用
  builtinDefault() 覆盖 builtin-default 项,忽略存储里的旧脏几何
  (旧默认品名被关/旧 QR 坐标等一律自愈回代码基准)
- 二维码放大 x198/y44/尺寸96 → x192/y32/尺寸120,四周留 ~1mm 小边框
- 原型 label-template-editor.html 高级 QR 输入同步;golden 重生
- 新增存储测试钉死「内置默认永远回代码基准」

原型与代码同提交(design-first 双边一致)
2026-08-31 12:07:22 +08:00

1185 lines
40 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';
import '../../widgets/label_preview_dialog.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
];
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('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();
/// 系统自带的默认模板 id(受保护基准版式)。
static const String _builtinDefaultId = 'builtin-default';
LabelTemplate get _t =>
_templates.firstWhere((t) => t.id == _editingId, orElse: () => _templates.first);
/// 默认模板只读:字段/字号/位置/打印参数一律不可改,要改必须先「复制」。
bool get _isLocked => _t.id == _builtinDefaultId;
@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);
}
/// 预览/打印共用的示例数据(QR 已在 bootstrap 生成)。
/// 预览示例的默认内容(仅用于左侧预览;真实出签永远取商品真实数据)。
String _sampleDefault(String key) {
switch (key) {
case 'name':
return '贵州茅台酒';
case 'code':
return 'P1001';
case 'series':
return '飞天53度';
case 'spec':
return '500ml';
case 'date':
return '2024-05-18';
case 'shop':
return ref.read(shopInfoProvider).valueOrNull?.name ?? '鼎晟酒行';
}
return '';
}
/// 字段内容编辑框的控制器(复用 [_ctrls] 池,`sample/` 命名空间,随店级预览而非模板)。
TextEditingController _contentCtrl(_Fd fd) => _ctrls.putIfAbsent(
'sample/${fd.key}',
() => TextEditingController(text: _sampleDefault(fd.key)));
/// 当前预览示例取值:编辑框非空用编辑框,否则回默认。
String _sampleOf(String key) {
final v = _ctrls['sample/$key']?.text.trim() ?? '';
return v.isNotEmpty ? v : _sampleDefault(key);
}
LabelData? _sampleLabel() {
final qr = _qrBytes;
if (qr == null) return null;
return LabelData(
qrBytes: qr,
name: _sampleOf('name'),
code: _sampleOf('code'),
series: _sampleOf('series'),
spec: _sampleOf('spec'),
productionDate: _sampleOf('date'),
batchNo: '20240518-01',
remark: '整箱现货',
shopName: _sampleOf('shop'),
);
}
Future<void> _renderPreview() async {
final sample = _sampleLabel();
if (sample == null) return;
final png = await renderLabelPreview(sample, template: _t);
if (mounted && png != null) setState(() => _previewPng = png);
}
/// 打印当前预览:复用出签弹窗(选打印机/份数),传入正在编辑的模板 [_t]。
void _print() {
final sample = _sampleLabel();
if (sample == null) {
_toast('预览尚未就绪,请稍候', err: true);
return;
}
showDialog(
context: context,
builder: (_) => LabelPreviewDialog(labels: [sample], template: _t),
);
}
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;
});
/// 步进器 +1/-1:夹在 [6,48],回写模型与输入框(同原型 stepSize)。
void _stepSize(_Fd fd, int d) {
final n = (_sizeOf(fd).round() + d).clamp(6, 48);
_setSize(fd, n.toDouble());
_sizeCtrl.text = n.toString();
}
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: _isLocked ? null : _reset),
const SizedBox(width: 10),
DsButton('打印', icon: LucideIcons.printer, onPressed: _print),
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,
),
if (_isLocked) ...[
const SizedBox(height: 10),
_lockNote(t),
],
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: _isLocked ? null : _renameTemplate),
DsButton('设默认',
icon: LucideIcons.check,
small: true,
onPressed: _setDefault),
DsButton('删除',
icon: LucideIcons.x,
small: true,
variant: DsBtnVariant.danger,
onPressed: _isLocked ? null : _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 _lockNote(AppTokens t) => Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: t.warnBg,
borderRadius: BorderRadius.circular(AppDims.rMd),
border: Border.all(color: t.warn),
),
child: Text('系统默认模板不可修改,点「复制」生成可编辑副本',
style: TextStyle(
fontSize: AppDims.fsXs, color: t.warn, height: 1.5)),
);
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 = !_isLocked && _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: _isLocked
? null
: () => 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: _isLocked ? null : (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))),
Expanded(
child: DsInput(
controller: _contentCtrl(fd),
onChanged: (_) => _scheduleRender(),
),
),
],
),
const SizedBox(height: 4),
Text('仅改预览示例,真实打印按商品数据填充',
style: TextStyle(
fontSize: AppDims.fsXs, color: t.muted, height: 1.6)),
const SizedBox(height: 12),
// 字号(品名可切自动)
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());
},
suffix: _sizeStepper(fd, t, auto),
),
),
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]),
),
],
),
],
);
}
/// 字号输入框右侧内嵌的上下箭头步进器(点击 +1/-1,同原型 .lt-steparr)。
Widget _sizeStepper(_Fd fd, AppTokens t, bool auto) {
Widget arrow(IconData ic, int d) => InkWell(
onTap: auto ? null : () => _stepSize(fd, d),
child: SizedBox(
height: 15,
width: 18,
child: Icon(ic, size: 13, color: auto ? t.faint : t.muted),
),
);
return Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
arrow(LucideIcons.chevronUp, 1),
arrow(LucideIcons.chevronDown, -1),
],
);
}
// ── 高级 · 区域位置 ───────────────────────────────────────────────────
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) {
if (_isLocked) return;
_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,
enabled: !_isLocked,
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)),
],
),
],
),
),
);
}
}