532 lines
16 KiB
Dart
532 lines
16 KiB
Dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import '../domain/label_document.dart';
|
|
import '../domain/dynamic_field_definition.dart';
|
|
import '../services/template_data_resolver.dart';
|
|
import '../services/zpl_compiler.dart';
|
|
import '../../inventory/domain/product.dart';
|
|
import '../../inventory/domain/inventory_item.dart';
|
|
|
|
class BarcodeDesignerState {
|
|
final LabelDocument document;
|
|
final String? selectedElementId;
|
|
final Product? sampleProduct;
|
|
final InventoryItem? sampleInventoryItem;
|
|
final bool isPreviewMode;
|
|
final double zoom;
|
|
final bool showGrid;
|
|
final bool snapToGrid;
|
|
final double gridStepMm;
|
|
final int? savedTemplateId;
|
|
final List<LabelDocument> undoStack;
|
|
final List<LabelDocument> redoStack;
|
|
final String liveCommands;
|
|
|
|
const BarcodeDesignerState({
|
|
required this.document,
|
|
this.selectedElementId,
|
|
this.sampleProduct,
|
|
this.sampleInventoryItem,
|
|
this.isPreviewMode = false,
|
|
this.zoom = 2.4, // Good default zoom for 50x25mm labels on desktop/mobile
|
|
this.showGrid = true,
|
|
this.snapToGrid = true,
|
|
this.gridStepMm = 1.0,
|
|
this.savedTemplateId,
|
|
this.undoStack = const [],
|
|
this.redoStack = const [],
|
|
this.liveCommands = '',
|
|
});
|
|
|
|
LabelElement? get selectedElement {
|
|
if (selectedElementId == null) return null;
|
|
return document.elements.where((e) => e.id == selectedElementId).firstOrNull;
|
|
}
|
|
|
|
LabelDocument get resolvedDocument {
|
|
if (sampleProduct == null && sampleInventoryItem == null) {
|
|
return document;
|
|
}
|
|
return TemplateDataResolver.resolveDocument(
|
|
document: document,
|
|
product: sampleProduct,
|
|
inventoryItem: sampleInventoryItem,
|
|
);
|
|
}
|
|
|
|
BarcodeDesignerState copyWith({
|
|
LabelDocument? document,
|
|
String? selectedElementId,
|
|
bool clearSelectedElement = false,
|
|
Product? sampleProduct,
|
|
bool clearSampleProduct = false,
|
|
InventoryItem? sampleInventoryItem,
|
|
bool clearSampleInventoryItem = false,
|
|
bool? isPreviewMode,
|
|
double? zoom,
|
|
bool? showGrid,
|
|
bool? snapToGrid,
|
|
double? gridStepMm,
|
|
int? savedTemplateId,
|
|
List<LabelDocument>? undoStack,
|
|
List<LabelDocument>? redoStack,
|
|
String? liveCommands,
|
|
}) {
|
|
return BarcodeDesignerState(
|
|
document: document ?? this.document,
|
|
selectedElementId: clearSelectedElement
|
|
? null
|
|
: (selectedElementId ?? this.selectedElementId),
|
|
sampleProduct: clearSampleProduct ? null : (sampleProduct ?? this.sampleProduct),
|
|
sampleInventoryItem: clearSampleInventoryItem
|
|
? null
|
|
: (sampleInventoryItem ?? this.sampleInventoryItem),
|
|
isPreviewMode: isPreviewMode ?? this.isPreviewMode,
|
|
zoom: zoom ?? this.zoom,
|
|
showGrid: showGrid ?? this.showGrid,
|
|
snapToGrid: snapToGrid ?? this.snapToGrid,
|
|
gridStepMm: gridStepMm ?? this.gridStepMm,
|
|
savedTemplateId: savedTemplateId ?? this.savedTemplateId,
|
|
undoStack: undoStack ?? this.undoStack,
|
|
redoStack: redoStack ?? this.redoStack,
|
|
liveCommands: liveCommands ?? this.liveCommands,
|
|
);
|
|
}
|
|
}
|
|
|
|
class BarcodeDesignerNotifier extends Notifier<BarcodeDesignerState> {
|
|
@override
|
|
BarcodeDesignerState build() {
|
|
final defaultDoc = LabelDocument.createDefault();
|
|
final cmds = PrintCompiler.compile(document: defaultDoc);
|
|
return BarcodeDesignerState(
|
|
document: defaultDoc,
|
|
liveCommands: cmds,
|
|
);
|
|
}
|
|
|
|
void _pushUndo(LabelDocument oldDoc) {
|
|
final newUndo = List<LabelDocument>.from(state.undoStack);
|
|
if (newUndo.length >= 30) newUndo.removeAt(0);
|
|
newUndo.add(oldDoc);
|
|
state = state.copyWith(undoStack: newUndo, redoStack: []);
|
|
}
|
|
|
|
void undo() {
|
|
if (state.undoStack.isEmpty) return;
|
|
final newUndo = List<LabelDocument>.from(state.undoStack);
|
|
final previousDoc = newUndo.removeLast();
|
|
final newRedo = List<LabelDocument>.from(state.redoStack)..add(state.document);
|
|
|
|
state = state.copyWith(
|
|
document: previousDoc,
|
|
undoStack: newUndo,
|
|
redoStack: newRedo,
|
|
);
|
|
_updateLiveCommands();
|
|
}
|
|
|
|
void redo() {
|
|
if (state.redoStack.isEmpty) return;
|
|
final newRedo = List<LabelDocument>.from(state.redoStack);
|
|
final nextDoc = newRedo.removeLast();
|
|
final newUndo = List<LabelDocument>.from(state.undoStack)..add(state.document);
|
|
|
|
state = state.copyWith(
|
|
document: nextDoc,
|
|
undoStack: newUndo,
|
|
redoStack: newRedo,
|
|
);
|
|
_updateLiveCommands();
|
|
}
|
|
|
|
void loadDocument(LabelDocument doc, [int? templateId]) {
|
|
final cmds = PrintCompiler.compile(document: doc);
|
|
state = BarcodeDesignerState(
|
|
document: doc,
|
|
savedTemplateId: templateId,
|
|
sampleProduct: state.sampleProduct,
|
|
sampleInventoryItem: state.sampleInventoryItem,
|
|
isPreviewMode: state.isPreviewMode,
|
|
zoom: state.zoom,
|
|
showGrid: state.showGrid,
|
|
snapToGrid: state.snapToGrid,
|
|
liveCommands: cmds,
|
|
);
|
|
}
|
|
|
|
void resetToNew([double widthMm = 50.0, double heightMm = 25.0]) {
|
|
final newDoc = LabelDocument(
|
|
id: 'template_${DateTime.now().millisecondsSinceEpoch}',
|
|
name: '${widthMm.round()}x${heightMm.round()} Barcode Template',
|
|
config: LabelConfiguration(
|
|
widthMm: widthMm,
|
|
heightMm: heightMm,
|
|
),
|
|
elements: [],
|
|
);
|
|
loadDocument(newDoc, null);
|
|
}
|
|
|
|
void updateTemplateName(String name) {
|
|
state = state.copyWith(
|
|
document: state.document.copyWith(name: name),
|
|
);
|
|
}
|
|
|
|
void updateTemplateDescription(String desc) {
|
|
state = state.copyWith(
|
|
document: state.document.copyWith(description: desc),
|
|
);
|
|
}
|
|
|
|
void setZoom(double z) {
|
|
state = state.copyWith(zoom: z.clamp(0.8, 6.0));
|
|
}
|
|
|
|
void toggleGrid() {
|
|
state = state.copyWith(showGrid: !state.showGrid);
|
|
}
|
|
|
|
void toggleSnap() {
|
|
state = state.copyWith(snapToGrid: !state.snapToGrid);
|
|
}
|
|
|
|
void togglePreviewMode([bool? force]) {
|
|
final nextMode = force ?? !state.isPreviewMode;
|
|
state = state.copyWith(isPreviewMode: nextMode);
|
|
_updateLiveCommands();
|
|
}
|
|
|
|
void setSampleProduct(Product? product, [InventoryItem? item]) {
|
|
state = state.copyWith(
|
|
sampleProduct: product,
|
|
clearSampleProduct: product == null,
|
|
sampleInventoryItem: item,
|
|
clearSampleInventoryItem: item == null,
|
|
);
|
|
_updateLiveCommands();
|
|
}
|
|
|
|
void selectElement(String? id) {
|
|
state = state.copyWith(
|
|
selectedElementId: id,
|
|
clearSelectedElement: id == null,
|
|
);
|
|
}
|
|
|
|
void updateLabelConfig(LabelConfiguration newConfig) {
|
|
_pushUndo(state.document);
|
|
state = state.copyWith(
|
|
document: state.document.copyWith(config: newConfig),
|
|
);
|
|
_updateLiveCommands();
|
|
}
|
|
|
|
double _snap(double valueMm) {
|
|
if (!state.snapToGrid) return valueMm;
|
|
final step = state.gridStepMm;
|
|
return (valueMm / step).round() * step;
|
|
}
|
|
|
|
void addElement(LabelElement element) {
|
|
_pushUndo(state.document);
|
|
final elements = List<LabelElement>.from(state.document.elements)..add(element);
|
|
state = state.copyWith(
|
|
document: state.document.copyWith(elements: elements),
|
|
selectedElementId: element.id,
|
|
);
|
|
_updateLiveCommands();
|
|
}
|
|
|
|
void updateElement(LabelElement element) {
|
|
_pushUndo(state.document);
|
|
final elements = state.document.elements.map((e) {
|
|
return e.id == element.id ? element : e;
|
|
}).toList();
|
|
|
|
state = state.copyWith(
|
|
document: state.document.copyWith(elements: elements),
|
|
);
|
|
_updateLiveCommands();
|
|
}
|
|
|
|
void recordUndo() {
|
|
_pushUndo(state.document);
|
|
}
|
|
|
|
void setElementPosition(String id, double xMm, double yMm) {
|
|
final elem = state.document.elements.where((e) => e.id == id).firstOrNull;
|
|
if (elem == null || elem.isLocked) return;
|
|
|
|
double newX = xMm;
|
|
double newY = yMm;
|
|
|
|
if (state.snapToGrid) {
|
|
newX = _snap(newX);
|
|
newY = _snap(newY);
|
|
}
|
|
|
|
newX = newX.clamp(0.0, state.document.config.widthMm - 1.0);
|
|
newY = newY.clamp(0.0, state.document.config.heightMm - 1.0);
|
|
|
|
final updated = elem.copyWithPosition(xMm: newX, yMm: newY);
|
|
final elements = state.document.elements.map((e) => e.id == id ? updated : e).toList();
|
|
|
|
state = state.copyWith(
|
|
document: state.document.copyWith(elements: elements),
|
|
);
|
|
_updateLiveCommands();
|
|
}
|
|
|
|
void setElementSize(String id, double widthMm, double heightMm) {
|
|
final elem = state.document.elements.where((e) => e.id == id).firstOrNull;
|
|
if (elem == null || elem.isLocked) return;
|
|
|
|
double w = widthMm;
|
|
double h = heightMm;
|
|
if (state.snapToGrid) {
|
|
w = _snap(w);
|
|
h = _snap(h);
|
|
}
|
|
w = w.clamp(2.0, state.document.config.widthMm);
|
|
h = h.clamp(1.0, state.document.config.heightMm);
|
|
|
|
final updated = elem.copyWithPosition(widthMm: w, heightMm: h);
|
|
final elements = state.document.elements.map((e) => e.id == id ? updated : e).toList();
|
|
|
|
state = state.copyWith(
|
|
document: state.document.copyWith(elements: elements),
|
|
);
|
|
_updateLiveCommands();
|
|
}
|
|
|
|
void moveElement(String id, double deltaXMm, double deltaYMm) {
|
|
final elem = state.document.elements.where((e) => e.id == id).firstOrNull;
|
|
if (elem == null || elem.isLocked) return;
|
|
|
|
double newX = (elem.xMm + deltaXMm);
|
|
double newY = (elem.yMm + deltaYMm);
|
|
|
|
if (state.snapToGrid) {
|
|
newX = _snap(newX);
|
|
newY = _snap(newY);
|
|
}
|
|
|
|
newX = newX.clamp(0.0, state.document.config.widthMm - 1.0);
|
|
newY = newY.clamp(0.0, state.document.config.heightMm - 1.0);
|
|
|
|
final updated = elem.copyWithPosition(xMm: newX, yMm: newY);
|
|
final elements = state.document.elements.map((e) => e.id == id ? updated : e).toList();
|
|
|
|
state = state.copyWith(
|
|
document: state.document.copyWith(elements: elements),
|
|
);
|
|
_updateLiveCommands();
|
|
}
|
|
|
|
void resizeElement(String id, double newWidthMm, double newHeightMm) {
|
|
final elem = state.document.elements.where((e) => e.id == id).firstOrNull;
|
|
if (elem == null || elem.isLocked) return;
|
|
|
|
double w = newWidthMm;
|
|
double h = newHeightMm;
|
|
if (state.snapToGrid) {
|
|
w = _snap(w);
|
|
h = _snap(h);
|
|
}
|
|
w = w.clamp(2.0, state.document.config.widthMm);
|
|
h = h.clamp(1.0, state.document.config.heightMm);
|
|
|
|
final updated = elem.copyWithPosition(widthMm: w, heightMm: h);
|
|
final elements = state.document.elements.map((e) => e.id == id ? updated : e).toList();
|
|
|
|
state = state.copyWith(
|
|
document: state.document.copyWith(elements: elements),
|
|
);
|
|
_updateLiveCommands();
|
|
}
|
|
|
|
void deleteElement(String id) {
|
|
_pushUndo(state.document);
|
|
final elements = state.document.elements.where((e) => e.id != id).toList();
|
|
state = state.copyWith(
|
|
document: state.document.copyWith(elements: elements),
|
|
clearSelectedElement: state.selectedElementId == id,
|
|
);
|
|
_updateLiveCommands();
|
|
}
|
|
|
|
void duplicateElement(String id) {
|
|
final elem = state.document.elements.where((e) => e.id == id).firstOrNull;
|
|
if (elem == null) return;
|
|
|
|
_pushUndo(state.document);
|
|
final newId = 'elem_${DateTime.now().millisecondsSinceEpoch}';
|
|
final duplicated = elem.copyWithPosition(
|
|
xMm: (elem.xMm + 2.0).clamp(0.0, state.document.config.widthMm - 2.0),
|
|
yMm: (elem.yMm + 2.0).clamp(0.0, state.document.config.heightMm - 2.0),
|
|
);
|
|
|
|
final json = duplicated.toJson();
|
|
json['id'] = newId;
|
|
final finalElem = LabelElement.fromJson(json);
|
|
|
|
final elements = List<LabelElement>.from(state.document.elements)..add(finalElem);
|
|
state = state.copyWith(
|
|
document: state.document.copyWith(elements: elements),
|
|
selectedElementId: newId,
|
|
);
|
|
_updateLiveCommands();
|
|
}
|
|
|
|
void bringToFront(String id) {
|
|
final index = state.document.elements.indexWhere((e) => e.id == id);
|
|
if (index == -1 || index == state.document.elements.length - 1) return;
|
|
|
|
_pushUndo(state.document);
|
|
final elements = List<LabelElement>.from(state.document.elements);
|
|
final elem = elements.removeAt(index);
|
|
elements.add(elem);
|
|
state = state.copyWith(
|
|
document: state.document.copyWith(elements: elements),
|
|
);
|
|
_updateLiveCommands();
|
|
}
|
|
|
|
void sendToBack(String id) {
|
|
final index = state.document.elements.indexWhere((e) => e.id == id);
|
|
if (index == -1 || index == 0) return;
|
|
|
|
_pushUndo(state.document);
|
|
final elements = List<LabelElement>.from(state.document.elements);
|
|
final elem = elements.removeAt(index);
|
|
elements.insert(0, elem);
|
|
state = state.copyWith(
|
|
document: state.document.copyWith(elements: elements),
|
|
);
|
|
_updateLiveCommands();
|
|
}
|
|
|
|
void bringForward(String id) {
|
|
final index = state.document.elements.indexWhere((e) => e.id == id);
|
|
if (index == -1 || index >= state.document.elements.length - 1) return;
|
|
|
|
_pushUndo(state.document);
|
|
final elements = List<LabelElement>.from(state.document.elements);
|
|
final elem = elements.removeAt(index);
|
|
elements.insert(index + 1, elem);
|
|
state = state.copyWith(
|
|
document: state.document.copyWith(elements: elements),
|
|
);
|
|
_updateLiveCommands();
|
|
}
|
|
|
|
void sendBackward(String id) {
|
|
final index = state.document.elements.indexWhere((e) => e.id == id);
|
|
if (index <= 0) return;
|
|
|
|
_pushUndo(state.document);
|
|
final elements = List<LabelElement>.from(state.document.elements);
|
|
final elem = elements.removeAt(index);
|
|
elements.insert(index - 1, elem);
|
|
state = state.copyWith(
|
|
document: state.document.copyWith(elements: elements),
|
|
);
|
|
_updateLiveCommands();
|
|
}
|
|
|
|
/// Binds a product field directly to an existing DynamicTextElement or BarcodeElement
|
|
void bindFieldToElement(String elementId, String source, String fieldKey) {
|
|
final elem = state.document.elements.where((e) => e.id == elementId).firstOrNull;
|
|
if (elem == null) return;
|
|
|
|
_pushUndo(state.document);
|
|
final fieldDef = DynamicFieldDefinition.find(source, fieldKey);
|
|
|
|
LabelElement updated;
|
|
if (elem is DynamicTextElement) {
|
|
updated = elem.copyWithPosition(
|
|
source: source,
|
|
fieldKey: fieldKey,
|
|
prefix: fieldDef?.defaultPrefix ?? elem.prefix,
|
|
suffix: fieldDef?.defaultSuffix ?? elem.suffix,
|
|
);
|
|
} else if (elem is BarcodeElement) {
|
|
updated = elem.copyWithPosition(
|
|
valueType: 'dynamic',
|
|
source: source,
|
|
fieldKey: fieldKey,
|
|
);
|
|
} else if (elem is QrCodeElement) {
|
|
updated = elem.copyWithPosition(
|
|
valueType: 'dynamic',
|
|
source: source,
|
|
fieldKey: fieldKey,
|
|
);
|
|
} else {
|
|
return;
|
|
}
|
|
|
|
final elements = state.document.elements.map((e) => e.id == elementId ? updated : e).toList();
|
|
state = state.copyWith(
|
|
document: state.document.copyWith(elements: elements),
|
|
);
|
|
_updateLiveCommands();
|
|
}
|
|
|
|
/// Adds a new element directly bound from a dragged DynamicFieldDefinition
|
|
void addBoundFieldElement({
|
|
required DynamicFieldDefinition fieldDef,
|
|
required double dropXMm,
|
|
required double dropYMm,
|
|
}) {
|
|
_pushUndo(state.document);
|
|
final id = 'elem_${DateTime.now().millisecondsSinceEpoch}';
|
|
|
|
LabelElement newElem;
|
|
if (fieldDef.fieldKey == 'barcode' || (fieldDef.isBarcodeCompatible && fieldDef.fieldKey == 'sku')) {
|
|
newElem = BarcodeElement(
|
|
id: id,
|
|
xMm: dropXMm.clamp(0.0, state.document.config.widthMm - 30.0),
|
|
yMm: dropYMm.clamp(0.0, state.document.config.heightMm - 8.0),
|
|
widthMm: 38.0,
|
|
heightMm: 8.0,
|
|
valueType: 'dynamic',
|
|
source: fieldDef.source,
|
|
fieldKey: fieldDef.fieldKey,
|
|
showText: true,
|
|
);
|
|
} else {
|
|
newElem = DynamicTextElement(
|
|
id: id,
|
|
xMm: dropXMm.clamp(0.0, state.document.config.widthMm - 20.0),
|
|
yMm: dropYMm.clamp(0.0, state.document.config.heightMm - 4.0),
|
|
widthMm: 28.0,
|
|
heightMm: 4.0,
|
|
source: fieldDef.source,
|
|
fieldKey: fieldDef.fieldKey,
|
|
prefix: fieldDef.defaultPrefix,
|
|
suffix: fieldDef.defaultSuffix,
|
|
fontSizePt: 8.0,
|
|
);
|
|
}
|
|
|
|
addElement(newElem);
|
|
}
|
|
|
|
void _updateLiveCommands() {
|
|
try {
|
|
final docToCompile = state.isPreviewMode ? state.resolvedDocument : state.document;
|
|
final cmds = PrintCompiler.compile(document: docToCompile);
|
|
state = state.copyWith(liveCommands: cmds);
|
|
} catch (_) {
|
|
// Ignore intermediate compilation errors while typing
|
|
}
|
|
}
|
|
}
|
|
|
|
final barcodeDesignerProvider =
|
|
NotifierProvider<BarcodeDesignerNotifier, BarcodeDesignerState>(() {
|
|
return BarcodeDesignerNotifier();
|
|
});
|