Barcode Printing Utility Done
This commit is contained in:
@@ -0,0 +1,56 @@
|
|||||||
|
package com.kifi.api.controller.barcode;
|
||||||
|
|
||||||
|
import com.kifi.api.entity.barcode.BarcodeLabelTemplate;
|
||||||
|
import com.kifi.api.service.barcode.BarcodeLabelTemplateService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/kifi-v2/barcode-templates")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class BarcodeLabelTemplateController {
|
||||||
|
|
||||||
|
private final BarcodeLabelTemplateService templateService;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public Flux<BarcodeLabelTemplate> getTemplates(Authentication authentication) {
|
||||||
|
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||||
|
return templateService.getActiveTemplates(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public Mono<BarcodeLabelTemplate> getTemplateById(
|
||||||
|
Authentication authentication,
|
||||||
|
@PathVariable Long id) {
|
||||||
|
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||||
|
return templateService.getTemplateById(userId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public Mono<BarcodeLabelTemplate> createTemplate(
|
||||||
|
Authentication authentication,
|
||||||
|
@RequestBody BarcodeLabelTemplate template) {
|
||||||
|
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||||
|
return templateService.createTemplate(userId, template);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public Mono<BarcodeLabelTemplate> updateTemplate(
|
||||||
|
Authentication authentication,
|
||||||
|
@PathVariable Long id,
|
||||||
|
@RequestBody BarcodeLabelTemplate template) {
|
||||||
|
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||||
|
return templateService.updateTemplate(userId, id, template);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public Mono<Void> deleteTemplate(
|
||||||
|
Authentication authentication,
|
||||||
|
@PathVariable Long id) {
|
||||||
|
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||||
|
return templateService.deleteTemplate(userId, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package com.kifi.api.entity.barcode;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import org.springframework.data.annotation.Id;
|
||||||
|
import org.springframework.data.relational.core.mapping.Column;
|
||||||
|
import org.springframework.data.relational.core.mapping.Table;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Table("barcode_label_templates")
|
||||||
|
public class BarcodeLabelTemplate {
|
||||||
|
@Id
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column("user_id")
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
@Column("template_name")
|
||||||
|
private String templateName;
|
||||||
|
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@Builder.Default
|
||||||
|
private String category = "PRODUCT";
|
||||||
|
|
||||||
|
@Builder.Default
|
||||||
|
private Integer version = 1;
|
||||||
|
|
||||||
|
@Column("label_width_mm")
|
||||||
|
@Builder.Default
|
||||||
|
private Double labelWidthMm = 50.0;
|
||||||
|
|
||||||
|
@Column("label_height_mm")
|
||||||
|
@Builder.Default
|
||||||
|
private Double labelHeightMm = 25.0;
|
||||||
|
|
||||||
|
@Column("measurement_unit")
|
||||||
|
@Builder.Default
|
||||||
|
private String measurementUnit = "mm";
|
||||||
|
|
||||||
|
@Column("printer_language")
|
||||||
|
@Builder.Default
|
||||||
|
private String printerLanguage = "ZPL";
|
||||||
|
|
||||||
|
@Builder.Default
|
||||||
|
private Integer dpi = 203;
|
||||||
|
|
||||||
|
@Column("configuration_json")
|
||||||
|
@Builder.Default
|
||||||
|
private String configurationJson = "{}";
|
||||||
|
|
||||||
|
@Column("template_json")
|
||||||
|
private String templateJson;
|
||||||
|
|
||||||
|
@Column("is_active")
|
||||||
|
@Builder.Default
|
||||||
|
private Boolean isActive = true;
|
||||||
|
|
||||||
|
@Column("created_at")
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Column("updated_at")
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ public class Product {
|
|||||||
private String barcode;
|
private String barcode;
|
||||||
private String hsnCode;
|
private String hsnCode;
|
||||||
private String description;
|
private String description;
|
||||||
|
private BigDecimal mrp;
|
||||||
private BigDecimal sellingPrice;
|
private BigDecimal sellingPrice;
|
||||||
private BigDecimal gstRate;
|
private BigDecimal gstRate;
|
||||||
private String dimensions;
|
private String dimensions;
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.kifi.api.repository.barcode;
|
||||||
|
|
||||||
|
import com.kifi.api.entity.barcode.BarcodeLabelTemplate;
|
||||||
|
import org.springframework.data.r2dbc.repository.Query;
|
||||||
|
import org.springframework.data.r2dbc.repository.R2dbcRepository;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
public interface BarcodeLabelTemplateRepository extends R2dbcRepository<BarcodeLabelTemplate, Long> {
|
||||||
|
Flux<BarcodeLabelTemplate> findByUserIdAndIsActiveTrueOrderByUpdatedAtDesc(Long userId);
|
||||||
|
|
||||||
|
Flux<BarcodeLabelTemplate> findByUserIdOrderByUpdatedAtDesc(Long userId);
|
||||||
|
|
||||||
|
Mono<BarcodeLabelTemplate> findByIdAndUserId(Long id, Long userId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package com.kifi.api.service.barcode;
|
||||||
|
|
||||||
|
import com.kifi.api.entity.barcode.BarcodeLabelTemplate;
|
||||||
|
import com.kifi.api.repository.barcode.BarcodeLabelTemplateRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class BarcodeLabelTemplateService {
|
||||||
|
|
||||||
|
private final BarcodeLabelTemplateRepository templateRepository;
|
||||||
|
|
||||||
|
public Flux<BarcodeLabelTemplate> getActiveTemplates(Long userId) {
|
||||||
|
return templateRepository.findByUserIdAndIsActiveTrueOrderByUpdatedAtDesc(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<BarcodeLabelTemplate> getTemplateById(Long userId, Long id) {
|
||||||
|
return templateRepository.findByIdAndUserId(id, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<BarcodeLabelTemplate> createTemplate(Long userId, BarcodeLabelTemplate template) {
|
||||||
|
template.setId(null);
|
||||||
|
template.setUserId(userId);
|
||||||
|
template.setCreatedAt(LocalDateTime.now());
|
||||||
|
template.setUpdatedAt(LocalDateTime.now());
|
||||||
|
if (template.getIsActive() == null) {
|
||||||
|
template.setIsActive(true);
|
||||||
|
}
|
||||||
|
if (template.getVersion() == null) {
|
||||||
|
template.setVersion(1);
|
||||||
|
}
|
||||||
|
return templateRepository.save(template);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<BarcodeLabelTemplate> updateTemplate(Long userId, Long id, BarcodeLabelTemplate template) {
|
||||||
|
return templateRepository.findByIdAndUserId(id, userId)
|
||||||
|
.switchIfEmpty(Mono.error(new IllegalArgumentException("Template not found or access denied: " + id)))
|
||||||
|
.flatMap(existing -> {
|
||||||
|
if (template.getTemplateName() != null) existing.setTemplateName(template.getTemplateName());
|
||||||
|
if (template.getDescription() != null) existing.setDescription(template.getDescription());
|
||||||
|
if (template.getCategory() != null) existing.setCategory(template.getCategory());
|
||||||
|
if (template.getLabelWidthMm() != null) existing.setLabelWidthMm(template.getLabelWidthMm());
|
||||||
|
if (template.getLabelHeightMm() != null) existing.setLabelHeightMm(template.getLabelHeightMm());
|
||||||
|
if (template.getMeasurementUnit() != null) existing.setMeasurementUnit(template.getMeasurementUnit());
|
||||||
|
if (template.getPrinterLanguage() != null) existing.setPrinterLanguage(template.getPrinterLanguage());
|
||||||
|
if (template.getDpi() != null) existing.setDpi(template.getDpi());
|
||||||
|
if (template.getConfigurationJson() != null) existing.setConfigurationJson(template.getConfigurationJson());
|
||||||
|
if (template.getTemplateJson() != null) existing.setTemplateJson(template.getTemplateJson());
|
||||||
|
if (template.getIsActive() != null) existing.setIsActive(template.getIsActive());
|
||||||
|
|
||||||
|
existing.setVersion((existing.getVersion() != null ? existing.getVersion() : 1) + 1);
|
||||||
|
existing.setUpdatedAt(LocalDateTime.now());
|
||||||
|
return templateRepository.save(existing);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Void> deleteTemplate(Long userId, Long id) {
|
||||||
|
return templateRepository.findByIdAndUserId(id, userId)
|
||||||
|
.flatMap(existing -> {
|
||||||
|
existing.setIsActive(false);
|
||||||
|
existing.setUpdatedAt(LocalDateTime.now());
|
||||||
|
return templateRepository.save(existing);
|
||||||
|
})
|
||||||
|
.then();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -96,6 +96,7 @@ public class ProductService {
|
|||||||
existingProduct.setSku(updatedProduct.getSku());
|
existingProduct.setSku(updatedProduct.getSku());
|
||||||
existingProduct.setCategoryId(updatedProduct.getCategoryId());
|
existingProduct.setCategoryId(updatedProduct.getCategoryId());
|
||||||
existingProduct.setUomId(updatedProduct.getUomId());
|
existingProduct.setUomId(updatedProduct.getUomId());
|
||||||
|
existingProduct.setMrp(updatedProduct.getMrp());
|
||||||
existingProduct.setSellingPrice(updatedProduct.getSellingPrice());
|
existingProduct.setSellingPrice(updatedProduct.getSellingPrice());
|
||||||
existingProduct.setGstRate(updatedProduct.getGstRate());
|
existingProduct.setGstRate(updatedProduct.getGstRate());
|
||||||
existingProduct.setHsnCode(updatedProduct.getHsnCode());
|
existingProduct.setHsnCode(updatedProduct.getHsnCode());
|
||||||
|
|||||||
@@ -244,6 +244,7 @@ CREATE TABLE IF NOT EXISTS products (
|
|||||||
barcode VARCHAR(100),
|
barcode VARCHAR(100),
|
||||||
description TEXT,
|
description TEXT,
|
||||||
purchase_price DECIMAL(15, 2),
|
purchase_price DECIMAL(15, 2),
|
||||||
|
mrp DECIMAL(15, 2),
|
||||||
selling_price DECIMAL(15, 2),
|
selling_price DECIMAL(15, 2),
|
||||||
gst_rate DECIMAL(5, 2),
|
gst_rate DECIMAL(5, 2),
|
||||||
dimensions VARCHAR(100),
|
dimensions VARCHAR(100),
|
||||||
@@ -600,3 +601,25 @@ CREATE TABLE IF NOT EXISTS project_task_comments (
|
|||||||
images TEXT,
|
images TEXT,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS barcode_label_templates (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
template_name VARCHAR(150) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
category VARCHAR(50) DEFAULT 'PRODUCT',
|
||||||
|
version INTEGER DEFAULT 1,
|
||||||
|
label_width_mm DOUBLE PRECISION NOT NULL DEFAULT 50.0,
|
||||||
|
label_height_mm DOUBLE PRECISION NOT NULL DEFAULT 25.0,
|
||||||
|
measurement_unit VARCHAR(10) DEFAULT 'mm',
|
||||||
|
printer_language VARCHAR(20) DEFAULT 'ZPL',
|
||||||
|
dpi INTEGER DEFAULT 203,
|
||||||
|
configuration_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
template_json TEXT NOT NULL,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_barcode_templates_user ON barcode_label_templates(user_id);
|
||||||
|
|
||||||
|
|||||||
@@ -63,10 +63,13 @@ class _SmartSearchDropdownState<T> extends State<SmartSearchDropdown<T>> {
|
|||||||
_filterItems(_controller.text);
|
_filterItems(_controller.text);
|
||||||
}
|
}
|
||||||
if (widget.value != oldWidget.value) {
|
if (widget.value != oldWidget.value) {
|
||||||
if (widget.value != null) {
|
final newText = widget.value != null ? widget.itemAsString(widget.value as T) : '';
|
||||||
_controller.text = widget.itemAsString(widget.value as T);
|
if (_controller.text != newText) {
|
||||||
} else {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
_controller.text = '';
|
if (mounted && _controller.text != newText) {
|
||||||
|
_controller.text = newText;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'label_document.dart';
|
||||||
|
|
||||||
|
class BarcodeTemplate {
|
||||||
|
final int? id;
|
||||||
|
final int? userId;
|
||||||
|
final String templateName;
|
||||||
|
final String? description;
|
||||||
|
final String category;
|
||||||
|
final int version;
|
||||||
|
final double labelWidthMm;
|
||||||
|
final double labelHeightMm;
|
||||||
|
final String measurementUnit;
|
||||||
|
final String printerLanguage;
|
||||||
|
final int dpi;
|
||||||
|
final String configurationJson;
|
||||||
|
final String templateJson;
|
||||||
|
final bool isActive;
|
||||||
|
final DateTime? createdAt;
|
||||||
|
final DateTime? updatedAt;
|
||||||
|
|
||||||
|
BarcodeTemplate({
|
||||||
|
this.id,
|
||||||
|
this.userId,
|
||||||
|
required this.templateName,
|
||||||
|
this.description,
|
||||||
|
this.category = 'PRODUCT',
|
||||||
|
this.version = 1,
|
||||||
|
this.labelWidthMm = 50.0,
|
||||||
|
this.labelHeightMm = 25.0,
|
||||||
|
this.measurementUnit = 'mm',
|
||||||
|
this.printerLanguage = 'ZPL',
|
||||||
|
this.dpi = 203,
|
||||||
|
this.configurationJson = '{}',
|
||||||
|
required this.templateJson,
|
||||||
|
this.isActive = true,
|
||||||
|
this.createdAt,
|
||||||
|
this.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory BarcodeTemplate.fromJson(Map<String, dynamic> json) {
|
||||||
|
return BarcodeTemplate(
|
||||||
|
id: json['id'] as int?,
|
||||||
|
userId: json['userId'] as int?,
|
||||||
|
templateName: json['templateName'] as String? ?? 'Untitled Template',
|
||||||
|
description: json['description'] as String?,
|
||||||
|
category: json['category'] as String? ?? 'PRODUCT',
|
||||||
|
version: json['version'] as int? ?? 1,
|
||||||
|
labelWidthMm: (json['labelWidthMm'] as num?)?.toDouble() ?? 50.0,
|
||||||
|
labelHeightMm: (json['labelHeightMm'] as num?)?.toDouble() ?? 25.0,
|
||||||
|
measurementUnit: json['measurementUnit'] as String? ?? 'mm',
|
||||||
|
printerLanguage: json['printerLanguage'] as String? ?? 'ZPL',
|
||||||
|
dpi: json['dpi'] as int? ?? 203,
|
||||||
|
configurationJson: json['configurationJson'] as String? ?? '{}',
|
||||||
|
templateJson: json['templateJson'] as String? ?? '{}',
|
||||||
|
isActive: json['isActive'] as bool? ?? true,
|
||||||
|
createdAt: json['createdAt'] != null ? DateTime.tryParse(json['createdAt'].toString()) : null,
|
||||||
|
updatedAt: json['updatedAt'] != null ? DateTime.tryParse(json['updatedAt'].toString()) : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
if (id != null) 'id': id,
|
||||||
|
if (userId != null) 'userId': userId,
|
||||||
|
'templateName': templateName,
|
||||||
|
'description': description,
|
||||||
|
'category': category,
|
||||||
|
'version': version,
|
||||||
|
'labelWidthMm': labelWidthMm,
|
||||||
|
'labelHeightMm': labelHeightMm,
|
||||||
|
'measurementUnit': measurementUnit,
|
||||||
|
'printerLanguage': printerLanguage,
|
||||||
|
'dpi': dpi,
|
||||||
|
'configurationJson': configurationJson,
|
||||||
|
'templateJson': templateJson,
|
||||||
|
'isActive': isActive,
|
||||||
|
};
|
||||||
|
|
||||||
|
LabelDocument toLabelDocument() {
|
||||||
|
try {
|
||||||
|
final parsed = jsonDecode(templateJson) as Map<String, dynamic>;
|
||||||
|
return LabelDocument.fromJson(
|
||||||
|
parsed,
|
||||||
|
id: id?.toString(),
|
||||||
|
name: templateName,
|
||||||
|
description: description,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
return LabelDocument(
|
||||||
|
id: id?.toString() ?? 'temp',
|
||||||
|
name: templateName,
|
||||||
|
description: description,
|
||||||
|
version: version,
|
||||||
|
config: LabelConfiguration(
|
||||||
|
widthMm: labelWidthMm,
|
||||||
|
heightMm: labelHeightMm,
|
||||||
|
dpi: dpi,
|
||||||
|
printerLanguage: printerLanguage,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static BarcodeTemplate fromLabelDocument({
|
||||||
|
int? id,
|
||||||
|
required LabelDocument doc,
|
||||||
|
}) {
|
||||||
|
return BarcodeTemplate(
|
||||||
|
id: id,
|
||||||
|
templateName: doc.name,
|
||||||
|
description: doc.description,
|
||||||
|
version: doc.version,
|
||||||
|
labelWidthMm: doc.config.widthMm,
|
||||||
|
labelHeightMm: doc.config.heightMm,
|
||||||
|
measurementUnit: 'mm',
|
||||||
|
printerLanguage: doc.config.printerLanguage,
|
||||||
|
dpi: doc.config.dpi,
|
||||||
|
configurationJson: jsonEncode(doc.config.toJson()),
|
||||||
|
templateJson: doc.toJsonString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
class DynamicFieldDefinition {
|
||||||
|
final String source; // e.g. "product"
|
||||||
|
final String fieldKey; // e.g. "name", "sellingPrice", "barcode"
|
||||||
|
final String displayName; // e.g. "Product Name"
|
||||||
|
final String category; // e.g. "Identification", "Pricing", "Specifications"
|
||||||
|
final String dataType; // "string", "currency", "weight", "number"
|
||||||
|
final bool isBarcodeCompatible;
|
||||||
|
final String? defaultPrefix;
|
||||||
|
final String? defaultSuffix;
|
||||||
|
|
||||||
|
const DynamicFieldDefinition({
|
||||||
|
required this.source,
|
||||||
|
required this.fieldKey,
|
||||||
|
required this.displayName,
|
||||||
|
required this.category,
|
||||||
|
this.dataType = 'string',
|
||||||
|
this.isBarcodeCompatible = false,
|
||||||
|
this.defaultPrefix,
|
||||||
|
this.defaultSuffix,
|
||||||
|
});
|
||||||
|
|
||||||
|
String get placeholder => '{{$source.$fieldKey}}';
|
||||||
|
|
||||||
|
static const List<DynamicFieldDefinition> productFields = [
|
||||||
|
DynamicFieldDefinition(
|
||||||
|
source: 'product',
|
||||||
|
fieldKey: 'name',
|
||||||
|
displayName: 'Product Name',
|
||||||
|
category: 'General',
|
||||||
|
dataType: 'string',
|
||||||
|
),
|
||||||
|
DynamicFieldDefinition(
|
||||||
|
source: 'product',
|
||||||
|
fieldKey: 'sku',
|
||||||
|
displayName: 'SKU',
|
||||||
|
category: 'Identification',
|
||||||
|
dataType: 'string',
|
||||||
|
isBarcodeCompatible: true,
|
||||||
|
),
|
||||||
|
DynamicFieldDefinition(
|
||||||
|
source: 'product',
|
||||||
|
fieldKey: 'barcode',
|
||||||
|
displayName: 'Barcode',
|
||||||
|
category: 'Identification',
|
||||||
|
dataType: 'string',
|
||||||
|
isBarcodeCompatible: true,
|
||||||
|
),
|
||||||
|
DynamicFieldDefinition(
|
||||||
|
source: 'product',
|
||||||
|
fieldKey: 'sellingPrice',
|
||||||
|
displayName: 'Selling Price',
|
||||||
|
category: 'Pricing',
|
||||||
|
dataType: 'currency',
|
||||||
|
defaultPrefix: '₹',
|
||||||
|
),
|
||||||
|
DynamicFieldDefinition(
|
||||||
|
source: 'product',
|
||||||
|
fieldKey: 'mrp',
|
||||||
|
displayName: 'MRP',
|
||||||
|
category: 'Pricing',
|
||||||
|
dataType: 'currency',
|
||||||
|
defaultPrefix: 'MRP ₹',
|
||||||
|
),
|
||||||
|
DynamicFieldDefinition(
|
||||||
|
source: 'product',
|
||||||
|
fieldKey: 'weightInG',
|
||||||
|
displayName: 'Weight',
|
||||||
|
category: 'Measurements',
|
||||||
|
dataType: 'weight',
|
||||||
|
defaultSuffix: ' g',
|
||||||
|
),
|
||||||
|
DynamicFieldDefinition(
|
||||||
|
source: 'product',
|
||||||
|
fieldKey: 'hsnCode',
|
||||||
|
displayName: 'HSN Code',
|
||||||
|
category: 'Tax & Compliance',
|
||||||
|
dataType: 'string',
|
||||||
|
),
|
||||||
|
DynamicFieldDefinition(
|
||||||
|
source: 'product',
|
||||||
|
fieldKey: 'gstRate',
|
||||||
|
displayName: 'GST Rate',
|
||||||
|
category: 'Tax & Compliance',
|
||||||
|
dataType: 'number',
|
||||||
|
defaultSuffix: '%',
|
||||||
|
),
|
||||||
|
DynamicFieldDefinition(
|
||||||
|
source: 'product',
|
||||||
|
fieldKey: 'brandName',
|
||||||
|
displayName: 'Brand',
|
||||||
|
category: 'General',
|
||||||
|
dataType: 'string',
|
||||||
|
),
|
||||||
|
DynamicFieldDefinition(
|
||||||
|
source: 'product',
|
||||||
|
fieldKey: 'material',
|
||||||
|
displayName: 'Material / Metal',
|
||||||
|
category: 'Specifications',
|
||||||
|
dataType: 'string',
|
||||||
|
),
|
||||||
|
DynamicFieldDefinition(
|
||||||
|
source: 'product',
|
||||||
|
fieldKey: 'size',
|
||||||
|
displayName: 'Size',
|
||||||
|
category: 'Specifications',
|
||||||
|
dataType: 'string',
|
||||||
|
),
|
||||||
|
DynamicFieldDefinition(
|
||||||
|
source: 'product',
|
||||||
|
fieldKey: 'color',
|
||||||
|
displayName: 'Color',
|
||||||
|
category: 'Specifications',
|
||||||
|
dataType: 'string',
|
||||||
|
),
|
||||||
|
DynamicFieldDefinition(
|
||||||
|
source: 'product',
|
||||||
|
fieldKey: 'manufacturerCode',
|
||||||
|
displayName: 'Tag / Serial / Code',
|
||||||
|
category: 'Identification',
|
||||||
|
dataType: 'string',
|
||||||
|
isBarcodeCompatible: true,
|
||||||
|
),
|
||||||
|
DynamicFieldDefinition(
|
||||||
|
source: 'product',
|
||||||
|
fieldKey: 'purityFactor',
|
||||||
|
displayName: 'Purity Factor',
|
||||||
|
category: 'Specifications',
|
||||||
|
dataType: 'number',
|
||||||
|
),
|
||||||
|
DynamicFieldDefinition(
|
||||||
|
source: 'product',
|
||||||
|
fieldKey: 'description',
|
||||||
|
displayName: 'Description',
|
||||||
|
category: 'General',
|
||||||
|
dataType: 'string',
|
||||||
|
),
|
||||||
|
DynamicFieldDefinition(
|
||||||
|
source: 'product',
|
||||||
|
fieldKey: 'currentStock',
|
||||||
|
displayName: 'Current Stock',
|
||||||
|
category: 'Inventory',
|
||||||
|
dataType: 'number',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
static DynamicFieldDefinition? find(String source, String fieldKey) {
|
||||||
|
if (source == 'product') {
|
||||||
|
return productFields.where((f) => f.fieldKey == fieldKey).firstOrNull;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,956 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
enum BarcodeType {
|
||||||
|
code128,
|
||||||
|
ean13,
|
||||||
|
code39,
|
||||||
|
upca,
|
||||||
|
qrCode,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LabelOrientation {
|
||||||
|
portrait,
|
||||||
|
landscape,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ElementType {
|
||||||
|
text,
|
||||||
|
dynamicText,
|
||||||
|
barcode,
|
||||||
|
qrCode,
|
||||||
|
rectangle,
|
||||||
|
line,
|
||||||
|
}
|
||||||
|
|
||||||
|
class LabelConfiguration {
|
||||||
|
final double widthMm;
|
||||||
|
final double heightMm;
|
||||||
|
final double marginTopMm;
|
||||||
|
final double marginBottomMm;
|
||||||
|
final double marginLeftMm;
|
||||||
|
final double marginRightMm;
|
||||||
|
final int dpi; // 203, 300, 600
|
||||||
|
final LabelOrientation orientation;
|
||||||
|
final String printerLanguage; // ZPL, TSPL, ESCPOS, PCL
|
||||||
|
final int columnsAcross; // 1-Up (single), 2-Up (twin track), 3-Up, 4-Up
|
||||||
|
final double horizontalGapMm; // Gap between side-by-side labels in mm
|
||||||
|
final double verticalGapMm; // Gap between consecutive labels along roll feed in mm
|
||||||
|
|
||||||
|
const LabelConfiguration({
|
||||||
|
this.widthMm = 50.0,
|
||||||
|
this.heightMm = 25.0,
|
||||||
|
this.marginTopMm = 1.0,
|
||||||
|
this.marginBottomMm = 1.0,
|
||||||
|
this.marginLeftMm = 1.0,
|
||||||
|
this.marginRightMm = 1.0,
|
||||||
|
this.dpi = 203,
|
||||||
|
this.orientation = LabelOrientation.portrait,
|
||||||
|
this.printerLanguage = 'ZPL',
|
||||||
|
this.columnsAcross = 1,
|
||||||
|
this.horizontalGapMm = 2.0,
|
||||||
|
this.verticalGapMm = 2.0,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Total roll web width including all columns and gaps between them
|
||||||
|
double get totalWebWidthMm {
|
||||||
|
final cols = columnsAcross.clamp(1, 4);
|
||||||
|
if (cols <= 1) return widthMm;
|
||||||
|
return (widthMm * cols) + (horizontalGapMm * (cols - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
int get totalWebWidthDots => mmToDots(totalWebWidthMm);
|
||||||
|
|
||||||
|
LabelConfiguration copyWith({
|
||||||
|
double? widthMm,
|
||||||
|
double? heightMm,
|
||||||
|
double? marginTopMm,
|
||||||
|
double? marginBottomMm,
|
||||||
|
double? marginLeftMm,
|
||||||
|
double? marginRightMm,
|
||||||
|
int? dpi,
|
||||||
|
LabelOrientation? orientation,
|
||||||
|
String? printerLanguage,
|
||||||
|
int? columnsAcross,
|
||||||
|
double? horizontalGapMm,
|
||||||
|
double? verticalGapMm,
|
||||||
|
}) {
|
||||||
|
return LabelConfiguration(
|
||||||
|
widthMm: widthMm ?? this.widthMm,
|
||||||
|
heightMm: heightMm ?? this.heightMm,
|
||||||
|
marginTopMm: marginTopMm ?? this.marginTopMm,
|
||||||
|
marginBottomMm: marginBottomMm ?? this.marginBottomMm,
|
||||||
|
marginLeftMm: marginLeftMm ?? this.marginLeftMm,
|
||||||
|
marginRightMm: marginRightMm ?? this.marginRightMm,
|
||||||
|
dpi: dpi ?? this.dpi,
|
||||||
|
orientation: orientation ?? this.orientation,
|
||||||
|
printerLanguage: printerLanguage ?? this.printerLanguage,
|
||||||
|
columnsAcross: columnsAcross ?? this.columnsAcross,
|
||||||
|
horizontalGapMm: horizontalGapMm ?? this.horizontalGapMm,
|
||||||
|
verticalGapMm: verticalGapMm ?? this.verticalGapMm,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'widthMm': widthMm,
|
||||||
|
'heightMm': heightMm,
|
||||||
|
'marginTopMm': marginTopMm,
|
||||||
|
'marginBottomMm': marginBottomMm,
|
||||||
|
'marginLeftMm': marginLeftMm,
|
||||||
|
'marginRightMm': marginRightMm,
|
||||||
|
'dpi': dpi,
|
||||||
|
'orientation': orientation.name,
|
||||||
|
'printerLanguage': printerLanguage,
|
||||||
|
'columnsAcross': columnsAcross,
|
||||||
|
'horizontalGapMm': horizontalGapMm,
|
||||||
|
'verticalGapMm': verticalGapMm,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory LabelConfiguration.fromJson(Map<String, dynamic> json) {
|
||||||
|
return LabelConfiguration(
|
||||||
|
widthMm: (json['widthMm'] as num?)?.toDouble() ?? 50.0,
|
||||||
|
heightMm: (json['heightMm'] as num?)?.toDouble() ?? 25.0,
|
||||||
|
marginTopMm: (json['marginTopMm'] as num?)?.toDouble() ?? 1.0,
|
||||||
|
marginBottomMm: (json['marginBottomMm'] as num?)?.toDouble() ?? 1.0,
|
||||||
|
marginLeftMm: (json['marginLeftMm'] as num?)?.toDouble() ?? 1.0,
|
||||||
|
marginRightMm: (json['marginRightMm'] as num?)?.toDouble() ?? 1.0,
|
||||||
|
dpi: json['dpi'] as int? ?? 203,
|
||||||
|
orientation: json['orientation'] == 'landscape'
|
||||||
|
? LabelOrientation.landscape
|
||||||
|
: LabelOrientation.portrait,
|
||||||
|
printerLanguage: json['printerLanguage'] as String? ?? 'ZPL',
|
||||||
|
columnsAcross: (json['columnsAcross'] as num?)?.toInt() ?? 1,
|
||||||
|
horizontalGapMm: (json['horizontalGapMm'] as num?)?.toDouble() ?? 2.0,
|
||||||
|
verticalGapMm: (json['verticalGapMm'] as num?)?.toDouble() ?? 2.0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
int mmToDots(double mm) => ((mm / 25.4) * dpi).round();
|
||||||
|
int get widthDots => mmToDots(widthMm);
|
||||||
|
int get heightDots => mmToDots(heightMm);
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class LabelElement {
|
||||||
|
final String id;
|
||||||
|
final ElementType type;
|
||||||
|
final double xMm;
|
||||||
|
final double yMm;
|
||||||
|
final double widthMm;
|
||||||
|
final double heightMm;
|
||||||
|
final double rotation; // 0, 90, 180, 270
|
||||||
|
final bool isLocked;
|
||||||
|
final int zIndex;
|
||||||
|
|
||||||
|
const LabelElement({
|
||||||
|
required this.id,
|
||||||
|
required this.type,
|
||||||
|
required this.xMm,
|
||||||
|
required this.yMm,
|
||||||
|
required this.widthMm,
|
||||||
|
required this.heightMm,
|
||||||
|
this.rotation = 0.0,
|
||||||
|
this.isLocked = false,
|
||||||
|
this.zIndex = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
LabelElement copyWithPosition({
|
||||||
|
double? xMm,
|
||||||
|
double? yMm,
|
||||||
|
double? widthMm,
|
||||||
|
double? heightMm,
|
||||||
|
double? rotation,
|
||||||
|
bool? isLocked,
|
||||||
|
int? zIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
double get borderWidthMm => 0.0;
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson();
|
||||||
|
|
||||||
|
static LabelElement fromJson(Map<String, dynamic> json) {
|
||||||
|
final typeStr = json['type'] as String? ?? 'text';
|
||||||
|
switch (typeStr) {
|
||||||
|
case 'dynamicText':
|
||||||
|
return DynamicTextElement.fromJson(json);
|
||||||
|
case 'barcode':
|
||||||
|
return BarcodeElement.fromJson(json);
|
||||||
|
case 'qrCode':
|
||||||
|
return QrCodeElement.fromJson(json);
|
||||||
|
case 'rectangle':
|
||||||
|
return RectangleElement.fromJson(json);
|
||||||
|
case 'line':
|
||||||
|
return LineElement.fromJson(json);
|
||||||
|
case 'text':
|
||||||
|
default:
|
||||||
|
return TextElement.fromJson(json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TextElement extends LabelElement {
|
||||||
|
final String text;
|
||||||
|
final String fontFamily;
|
||||||
|
final double fontSizePt;
|
||||||
|
final bool isBold;
|
||||||
|
final bool isItalic;
|
||||||
|
final TextAlign alignment;
|
||||||
|
@override
|
||||||
|
final double borderWidthMm;
|
||||||
|
final double paddingMm;
|
||||||
|
|
||||||
|
const TextElement({
|
||||||
|
required super.id,
|
||||||
|
required super.xMm,
|
||||||
|
required super.yMm,
|
||||||
|
required super.widthMm,
|
||||||
|
required super.heightMm,
|
||||||
|
super.rotation = 0.0,
|
||||||
|
super.isLocked = false,
|
||||||
|
super.zIndex = 0,
|
||||||
|
this.text = 'kifi',
|
||||||
|
this.fontFamily = 'Roboto',
|
||||||
|
this.fontSizePt = 9.0,
|
||||||
|
this.isBold = false,
|
||||||
|
this.isItalic = false,
|
||||||
|
this.alignment = TextAlign.left,
|
||||||
|
this.borderWidthMm = 0.0,
|
||||||
|
this.paddingMm = 0.0,
|
||||||
|
}) : super(type: ElementType.text);
|
||||||
|
|
||||||
|
@override
|
||||||
|
TextElement copyWithPosition({
|
||||||
|
double? xMm,
|
||||||
|
double? yMm,
|
||||||
|
double? widthMm,
|
||||||
|
double? heightMm,
|
||||||
|
double? rotation,
|
||||||
|
bool? isLocked,
|
||||||
|
int? zIndex,
|
||||||
|
String? text,
|
||||||
|
String? fontFamily,
|
||||||
|
double? fontSizePt,
|
||||||
|
bool? isBold,
|
||||||
|
bool? isItalic,
|
||||||
|
TextAlign? alignment,
|
||||||
|
double? borderWidthMm,
|
||||||
|
double? paddingMm,
|
||||||
|
}) {
|
||||||
|
return TextElement(
|
||||||
|
id: id,
|
||||||
|
xMm: xMm ?? this.xMm,
|
||||||
|
yMm: yMm ?? this.yMm,
|
||||||
|
widthMm: widthMm ?? this.widthMm,
|
||||||
|
heightMm: heightMm ?? this.heightMm,
|
||||||
|
rotation: rotation ?? this.rotation,
|
||||||
|
isLocked: isLocked ?? this.isLocked,
|
||||||
|
zIndex: zIndex ?? this.zIndex,
|
||||||
|
text: text ?? this.text,
|
||||||
|
fontFamily: fontFamily ?? this.fontFamily,
|
||||||
|
fontSizePt: fontSizePt ?? this.fontSizePt,
|
||||||
|
isBold: isBold ?? this.isBold,
|
||||||
|
isItalic: isItalic ?? this.isItalic,
|
||||||
|
alignment: alignment ?? this.alignment,
|
||||||
|
borderWidthMm: borderWidthMm ?? this.borderWidthMm,
|
||||||
|
paddingMm: paddingMm ?? this.paddingMm,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'type': 'text',
|
||||||
|
'xMm': xMm,
|
||||||
|
'yMm': yMm,
|
||||||
|
'widthMm': widthMm,
|
||||||
|
'heightMm': heightMm,
|
||||||
|
'rotation': rotation,
|
||||||
|
'isLocked': isLocked,
|
||||||
|
'zIndex': zIndex,
|
||||||
|
'text': text,
|
||||||
|
'fontFamily': fontFamily,
|
||||||
|
'fontSizePt': fontSizePt,
|
||||||
|
'isBold': isBold,
|
||||||
|
'isItalic': isItalic,
|
||||||
|
'alignment': alignment.name,
|
||||||
|
'borderWidthMm': borderWidthMm,
|
||||||
|
'paddingMm': paddingMm,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory TextElement.fromJson(Map<String, dynamic> json) {
|
||||||
|
return TextElement(
|
||||||
|
id: json['id'] as String,
|
||||||
|
xMm: (json['xMm'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
yMm: (json['yMm'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
widthMm: (json['widthMm'] as num?)?.toDouble() ?? 20.0,
|
||||||
|
heightMm: (json['heightMm'] as num?)?.toDouble() ?? 5.0,
|
||||||
|
rotation: (json['rotation'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
isLocked: json['isLocked'] as bool? ?? false,
|
||||||
|
zIndex: json['zIndex'] as int? ?? 0,
|
||||||
|
text: json['text'] as String? ?? json['value'] as String? ?? 'kifi',
|
||||||
|
fontFamily: json['fontFamily'] as String? ?? 'Roboto',
|
||||||
|
fontSizePt: (json['fontSizePt'] as num?)?.toDouble() ?? 9.0,
|
||||||
|
isBold: json['isBold'] as bool? ?? false,
|
||||||
|
isItalic: json['isItalic'] as bool? ?? false,
|
||||||
|
alignment: TextAlign.values.where((a) => a.name == json['alignment']).firstOrNull ?? TextAlign.left,
|
||||||
|
borderWidthMm: (json['borderWidthMm'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
paddingMm: (json['paddingMm'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DynamicTextElement extends LabelElement {
|
||||||
|
final String source; // "product"
|
||||||
|
final String fieldKey; // "name", "sellingPrice", etc.
|
||||||
|
final String? prefix;
|
||||||
|
final String? suffix;
|
||||||
|
final String fallbackValue;
|
||||||
|
final String fontFamily;
|
||||||
|
final double fontSizePt;
|
||||||
|
final bool isBold;
|
||||||
|
final bool isItalic;
|
||||||
|
final TextAlign alignment;
|
||||||
|
@override
|
||||||
|
final double borderWidthMm;
|
||||||
|
final double paddingMm;
|
||||||
|
final bool autoFit;
|
||||||
|
final int maxLines;
|
||||||
|
|
||||||
|
const DynamicTextElement({
|
||||||
|
required super.id,
|
||||||
|
required super.xMm,
|
||||||
|
required super.yMm,
|
||||||
|
required super.widthMm,
|
||||||
|
required super.heightMm,
|
||||||
|
super.rotation = 0.0,
|
||||||
|
super.isLocked = false,
|
||||||
|
super.zIndex = 0,
|
||||||
|
this.source = 'product',
|
||||||
|
this.fieldKey = '',
|
||||||
|
this.prefix,
|
||||||
|
this.suffix,
|
||||||
|
this.fallbackValue = 'N/A',
|
||||||
|
this.fontFamily = 'Roboto',
|
||||||
|
this.fontSizePt = 9.0,
|
||||||
|
this.isBold = false,
|
||||||
|
this.isItalic = false,
|
||||||
|
this.alignment = TextAlign.left,
|
||||||
|
this.borderWidthMm = 0.0,
|
||||||
|
this.paddingMm = 0.0,
|
||||||
|
this.autoFit = true,
|
||||||
|
this.maxLines = 2,
|
||||||
|
}) : super(type: ElementType.dynamicText);
|
||||||
|
|
||||||
|
String get placeholder => fieldKey.isNotEmpty ? '{{$source.$fieldKey}}' : '{{select_field}}';
|
||||||
|
|
||||||
|
String formatValue(String? resolvedRaw) {
|
||||||
|
if (resolvedRaw == null || resolvedRaw.trim().isEmpty) {
|
||||||
|
return fallbackValue;
|
||||||
|
}
|
||||||
|
final p = prefix ?? '';
|
||||||
|
final s = suffix ?? '';
|
||||||
|
return '$p$resolvedRaw$s';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
DynamicTextElement copyWithPosition({
|
||||||
|
double? xMm,
|
||||||
|
double? yMm,
|
||||||
|
double? widthMm,
|
||||||
|
double? heightMm,
|
||||||
|
double? rotation,
|
||||||
|
bool? isLocked,
|
||||||
|
int? zIndex,
|
||||||
|
String? source,
|
||||||
|
String? fieldKey,
|
||||||
|
String? prefix,
|
||||||
|
String? suffix,
|
||||||
|
String? fallbackValue,
|
||||||
|
String? fontFamily,
|
||||||
|
double? fontSizePt,
|
||||||
|
bool? isBold,
|
||||||
|
bool? isItalic,
|
||||||
|
TextAlign? alignment,
|
||||||
|
double? borderWidthMm,
|
||||||
|
double? paddingMm,
|
||||||
|
bool? autoFit,
|
||||||
|
int? maxLines,
|
||||||
|
}) {
|
||||||
|
return DynamicTextElement(
|
||||||
|
id: id,
|
||||||
|
xMm: xMm ?? this.xMm,
|
||||||
|
yMm: yMm ?? this.yMm,
|
||||||
|
widthMm: widthMm ?? this.widthMm,
|
||||||
|
heightMm: heightMm ?? this.heightMm,
|
||||||
|
rotation: rotation ?? this.rotation,
|
||||||
|
isLocked: isLocked ?? this.isLocked,
|
||||||
|
zIndex: zIndex ?? this.zIndex,
|
||||||
|
source: source ?? this.source,
|
||||||
|
fieldKey: fieldKey ?? this.fieldKey,
|
||||||
|
prefix: prefix ?? this.prefix,
|
||||||
|
suffix: suffix ?? this.suffix,
|
||||||
|
fallbackValue: fallbackValue ?? this.fallbackValue,
|
||||||
|
fontFamily: fontFamily ?? this.fontFamily,
|
||||||
|
fontSizePt: fontSizePt ?? this.fontSizePt,
|
||||||
|
isBold: isBold ?? this.isBold,
|
||||||
|
isItalic: isItalic ?? this.isItalic,
|
||||||
|
alignment: alignment ?? this.alignment,
|
||||||
|
borderWidthMm: borderWidthMm ?? this.borderWidthMm,
|
||||||
|
paddingMm: paddingMm ?? this.paddingMm,
|
||||||
|
autoFit: autoFit ?? this.autoFit,
|
||||||
|
maxLines: maxLines ?? this.maxLines,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'type': 'dynamicText',
|
||||||
|
'xMm': xMm,
|
||||||
|
'yMm': yMm,
|
||||||
|
'widthMm': widthMm,
|
||||||
|
'heightMm': heightMm,
|
||||||
|
'rotation': rotation,
|
||||||
|
'isLocked': isLocked,
|
||||||
|
'zIndex': zIndex,
|
||||||
|
'source': source,
|
||||||
|
'fieldKey': fieldKey,
|
||||||
|
'placeholder': placeholder,
|
||||||
|
'prefix': prefix,
|
||||||
|
'suffix': suffix,
|
||||||
|
'fallbackValue': fallbackValue,
|
||||||
|
'fontFamily': fontFamily,
|
||||||
|
'fontSizePt': fontSizePt,
|
||||||
|
'isBold': isBold,
|
||||||
|
'isItalic': isItalic,
|
||||||
|
'alignment': alignment.name,
|
||||||
|
'borderWidthMm': borderWidthMm,
|
||||||
|
'paddingMm': paddingMm,
|
||||||
|
'autoFit': autoFit,
|
||||||
|
'maxLines': maxLines,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory DynamicTextElement.fromJson(Map<String, dynamic> json) {
|
||||||
|
return DynamicTextElement(
|
||||||
|
id: json['id'] as String,
|
||||||
|
xMm: (json['xMm'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
yMm: (json['yMm'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
widthMm: (json['widthMm'] as num?)?.toDouble() ?? 30.0,
|
||||||
|
heightMm: (json['heightMm'] as num?)?.toDouble() ?? 5.0,
|
||||||
|
rotation: (json['rotation'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
isLocked: json['isLocked'] as bool? ?? false,
|
||||||
|
zIndex: json['zIndex'] as int? ?? 0,
|
||||||
|
source: json['source'] as String? ?? 'product',
|
||||||
|
fieldKey: json['fieldKey'] as String? ?? json['field'] as String? ?? '',
|
||||||
|
prefix: json['prefix'] as String?,
|
||||||
|
suffix: json['suffix'] as String?,
|
||||||
|
fallbackValue: json['fallbackValue'] as String? ?? json['fallback'] as String? ?? 'N/A',
|
||||||
|
fontFamily: json['fontFamily'] as String? ?? 'Roboto',
|
||||||
|
fontSizePt: (json['fontSizePt'] as num?)?.toDouble() ?? 9.0,
|
||||||
|
isBold: json['isBold'] as bool? ?? false,
|
||||||
|
isItalic: json['isItalic'] as bool? ?? false,
|
||||||
|
alignment: TextAlign.values.where((a) => a.name == json['alignment']).firstOrNull ?? TextAlign.left,
|
||||||
|
borderWidthMm: (json['borderWidthMm'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
paddingMm: (json['paddingMm'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
autoFit: json['autoFit'] as bool? ?? true,
|
||||||
|
maxLines: json['maxLines'] as int? ?? 2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BarcodeElement extends LabelElement {
|
||||||
|
final String valueType; // "static" or "dynamic"
|
||||||
|
final String staticValue;
|
||||||
|
final String source; // "product"
|
||||||
|
final String fieldKey; // "barcode" or "sku"
|
||||||
|
final BarcodeType barcodeType;
|
||||||
|
final bool showText;
|
||||||
|
@override
|
||||||
|
final double borderWidthMm;
|
||||||
|
final double paddingMm;
|
||||||
|
|
||||||
|
const BarcodeElement({
|
||||||
|
required super.id,
|
||||||
|
required super.xMm,
|
||||||
|
required super.yMm,
|
||||||
|
required super.widthMm,
|
||||||
|
required super.heightMm,
|
||||||
|
super.rotation = 0.0,
|
||||||
|
super.isLocked = false,
|
||||||
|
super.zIndex = 0,
|
||||||
|
this.valueType = 'dynamic',
|
||||||
|
this.staticValue = '123456789012',
|
||||||
|
this.source = 'product',
|
||||||
|
this.fieldKey = 'barcode',
|
||||||
|
this.barcodeType = BarcodeType.code128,
|
||||||
|
this.showText = true,
|
||||||
|
this.borderWidthMm = 0.0,
|
||||||
|
this.paddingMm = 0.5,
|
||||||
|
}) : super(type: ElementType.barcode);
|
||||||
|
|
||||||
|
String get placeholder => valueType == 'dynamic' ? '{{$source.$fieldKey}}' : staticValue;
|
||||||
|
|
||||||
|
@override
|
||||||
|
BarcodeElement copyWithPosition({
|
||||||
|
double? xMm,
|
||||||
|
double? yMm,
|
||||||
|
double? widthMm,
|
||||||
|
double? heightMm,
|
||||||
|
double? rotation,
|
||||||
|
bool? isLocked,
|
||||||
|
int? zIndex,
|
||||||
|
String? valueType,
|
||||||
|
String? staticValue,
|
||||||
|
String? source,
|
||||||
|
String? fieldKey,
|
||||||
|
BarcodeType? barcodeType,
|
||||||
|
bool? showText,
|
||||||
|
double? borderWidthMm,
|
||||||
|
double? paddingMm,
|
||||||
|
}) {
|
||||||
|
return BarcodeElement(
|
||||||
|
id: id,
|
||||||
|
xMm: xMm ?? this.xMm,
|
||||||
|
yMm: yMm ?? this.yMm,
|
||||||
|
widthMm: widthMm ?? this.widthMm,
|
||||||
|
heightMm: heightMm ?? this.heightMm,
|
||||||
|
rotation: rotation ?? this.rotation,
|
||||||
|
isLocked: isLocked ?? this.isLocked,
|
||||||
|
zIndex: zIndex ?? this.zIndex,
|
||||||
|
valueType: valueType ?? this.valueType,
|
||||||
|
staticValue: staticValue ?? this.staticValue,
|
||||||
|
source: source ?? this.source,
|
||||||
|
fieldKey: fieldKey ?? this.fieldKey,
|
||||||
|
barcodeType: barcodeType ?? this.barcodeType,
|
||||||
|
showText: showText ?? this.showText,
|
||||||
|
borderWidthMm: borderWidthMm ?? this.borderWidthMm,
|
||||||
|
paddingMm: paddingMm ?? this.paddingMm,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'type': 'barcode',
|
||||||
|
'xMm': xMm,
|
||||||
|
'yMm': yMm,
|
||||||
|
'widthMm': widthMm,
|
||||||
|
'heightMm': heightMm,
|
||||||
|
'rotation': rotation,
|
||||||
|
'isLocked': isLocked,
|
||||||
|
'zIndex': zIndex,
|
||||||
|
'valueType': valueType,
|
||||||
|
'staticValue': staticValue,
|
||||||
|
'source': source,
|
||||||
|
'fieldKey': fieldKey,
|
||||||
|
'placeholder': placeholder,
|
||||||
|
'barcodeType': barcodeType.name,
|
||||||
|
'showText': showText,
|
||||||
|
'borderWidthMm': borderWidthMm,
|
||||||
|
'paddingMm': paddingMm,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory BarcodeElement.fromJson(Map<String, dynamic> json) {
|
||||||
|
return BarcodeElement(
|
||||||
|
id: json['id'] as String,
|
||||||
|
xMm: (json['xMm'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
yMm: (json['yMm'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
widthMm: (json['widthMm'] as num?)?.toDouble() ?? 40.0,
|
||||||
|
heightMm: (json['heightMm'] as num?)?.toDouble() ?? 10.0,
|
||||||
|
rotation: (json['rotation'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
isLocked: json['isLocked'] as bool? ?? false,
|
||||||
|
zIndex: json['zIndex'] as int? ?? 0,
|
||||||
|
valueType: json['valueType'] as String? ?? 'dynamic',
|
||||||
|
staticValue: json['staticValue'] as String? ?? '123456789012',
|
||||||
|
source: json['source'] as String? ?? 'product',
|
||||||
|
fieldKey: json['fieldKey'] as String? ?? json['field'] as String? ?? 'barcode',
|
||||||
|
barcodeType: BarcodeType.values.where((b) => b.name.toLowerCase() == (json['barcodeType'] as String? ?? '').toLowerCase()).firstOrNull ?? BarcodeType.code128,
|
||||||
|
showText: json['showText'] as bool? ?? true,
|
||||||
|
borderWidthMm: (json['borderWidthMm'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
paddingMm: (json['paddingMm'] as num?)?.toDouble() ?? 0.5,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class QrCodeElement extends LabelElement {
|
||||||
|
final String valueType; // "static" or "dynamic"
|
||||||
|
final String staticValue;
|
||||||
|
final String source; // "product"
|
||||||
|
final String fieldKey;
|
||||||
|
|
||||||
|
const QrCodeElement({
|
||||||
|
required super.id,
|
||||||
|
required super.xMm,
|
||||||
|
required super.yMm,
|
||||||
|
required super.widthMm,
|
||||||
|
required super.heightMm,
|
||||||
|
super.rotation = 0.0,
|
||||||
|
super.isLocked = false,
|
||||||
|
super.zIndex = 0,
|
||||||
|
this.valueType = 'dynamic',
|
||||||
|
this.staticValue = 'https://kifi.app',
|
||||||
|
this.source = 'product',
|
||||||
|
this.fieldKey = 'barcode',
|
||||||
|
}) : super(type: ElementType.qrCode);
|
||||||
|
|
||||||
|
String get placeholder => valueType == 'dynamic' ? '{{$source.$fieldKey}}' : staticValue;
|
||||||
|
|
||||||
|
@override
|
||||||
|
QrCodeElement copyWithPosition({
|
||||||
|
double? xMm,
|
||||||
|
double? yMm,
|
||||||
|
double? widthMm,
|
||||||
|
double? heightMm,
|
||||||
|
double? rotation,
|
||||||
|
bool? isLocked,
|
||||||
|
int? zIndex,
|
||||||
|
String? valueType,
|
||||||
|
String? staticValue,
|
||||||
|
String? source,
|
||||||
|
String? fieldKey,
|
||||||
|
}) {
|
||||||
|
return QrCodeElement(
|
||||||
|
id: id,
|
||||||
|
xMm: xMm ?? this.xMm,
|
||||||
|
yMm: yMm ?? this.yMm,
|
||||||
|
widthMm: widthMm ?? this.widthMm,
|
||||||
|
heightMm: heightMm ?? this.heightMm,
|
||||||
|
rotation: rotation ?? this.rotation,
|
||||||
|
isLocked: isLocked ?? this.isLocked,
|
||||||
|
zIndex: zIndex ?? this.zIndex,
|
||||||
|
valueType: valueType ?? this.valueType,
|
||||||
|
staticValue: staticValue ?? this.staticValue,
|
||||||
|
source: source ?? this.source,
|
||||||
|
fieldKey: fieldKey ?? this.fieldKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'type': 'qrCode',
|
||||||
|
'xMm': xMm,
|
||||||
|
'yMm': yMm,
|
||||||
|
'widthMm': widthMm,
|
||||||
|
'heightMm': heightMm,
|
||||||
|
'rotation': rotation,
|
||||||
|
'isLocked': isLocked,
|
||||||
|
'zIndex': zIndex,
|
||||||
|
'valueType': valueType,
|
||||||
|
'staticValue': staticValue,
|
||||||
|
'source': source,
|
||||||
|
'fieldKey': fieldKey,
|
||||||
|
'placeholder': placeholder,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory QrCodeElement.fromJson(Map<String, dynamic> json) {
|
||||||
|
return QrCodeElement(
|
||||||
|
id: json['id'] as String,
|
||||||
|
xMm: (json['xMm'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
yMm: (json['yMm'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
widthMm: (json['widthMm'] as num?)?.toDouble() ?? 12.0,
|
||||||
|
heightMm: (json['heightMm'] as num?)?.toDouble() ?? 12.0,
|
||||||
|
rotation: (json['rotation'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
isLocked: json['isLocked'] as bool? ?? false,
|
||||||
|
zIndex: json['zIndex'] as int? ?? 0,
|
||||||
|
valueType: json['valueType'] as String? ?? 'dynamic',
|
||||||
|
staticValue: json['staticValue'] as String? ?? 'https://kifi.app',
|
||||||
|
source: json['source'] as String? ?? 'product',
|
||||||
|
fieldKey: json['fieldKey'] as String? ?? json['field'] as String? ?? 'barcode',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RectangleElement extends LabelElement {
|
||||||
|
@override
|
||||||
|
final double borderWidthMm;
|
||||||
|
final bool isFilled;
|
||||||
|
final double cornerRadiusMm;
|
||||||
|
|
||||||
|
const RectangleElement({
|
||||||
|
required super.id,
|
||||||
|
required super.xMm,
|
||||||
|
required super.yMm,
|
||||||
|
required super.widthMm,
|
||||||
|
required super.heightMm,
|
||||||
|
super.rotation = 0.0,
|
||||||
|
super.isLocked = false,
|
||||||
|
super.zIndex = 0,
|
||||||
|
this.borderWidthMm = 0.3,
|
||||||
|
this.isFilled = false,
|
||||||
|
this.cornerRadiusMm = 0.0,
|
||||||
|
}) : super(type: ElementType.rectangle);
|
||||||
|
|
||||||
|
@override
|
||||||
|
RectangleElement copyWithPosition({
|
||||||
|
double? xMm,
|
||||||
|
double? yMm,
|
||||||
|
double? widthMm,
|
||||||
|
double? heightMm,
|
||||||
|
double? rotation,
|
||||||
|
bool? isLocked,
|
||||||
|
int? zIndex,
|
||||||
|
double? borderWidthMm,
|
||||||
|
bool? isFilled,
|
||||||
|
double? cornerRadiusMm,
|
||||||
|
}) {
|
||||||
|
return RectangleElement(
|
||||||
|
id: id,
|
||||||
|
xMm: xMm ?? this.xMm,
|
||||||
|
yMm: yMm ?? this.yMm,
|
||||||
|
widthMm: widthMm ?? this.widthMm,
|
||||||
|
heightMm: heightMm ?? this.heightMm,
|
||||||
|
rotation: rotation ?? this.rotation,
|
||||||
|
isLocked: isLocked ?? this.isLocked,
|
||||||
|
zIndex: zIndex ?? this.zIndex,
|
||||||
|
borderWidthMm: borderWidthMm ?? this.borderWidthMm,
|
||||||
|
isFilled: isFilled ?? this.isFilled,
|
||||||
|
cornerRadiusMm: cornerRadiusMm ?? this.cornerRadiusMm,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'type': 'rectangle',
|
||||||
|
'xMm': xMm,
|
||||||
|
'yMm': yMm,
|
||||||
|
'widthMm': widthMm,
|
||||||
|
'heightMm': heightMm,
|
||||||
|
'rotation': rotation,
|
||||||
|
'isLocked': isLocked,
|
||||||
|
'zIndex': zIndex,
|
||||||
|
'borderWidthMm': borderWidthMm,
|
||||||
|
'isFilled': isFilled,
|
||||||
|
'cornerRadiusMm': cornerRadiusMm,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory RectangleElement.fromJson(Map<String, dynamic> json) {
|
||||||
|
return RectangleElement(
|
||||||
|
id: json['id'] as String,
|
||||||
|
xMm: (json['xMm'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
yMm: (json['yMm'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
widthMm: (json['widthMm'] as num?)?.toDouble() ?? 20.0,
|
||||||
|
heightMm: (json['heightMm'] as num?)?.toDouble() ?? 10.0,
|
||||||
|
rotation: (json['rotation'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
isLocked: json['isLocked'] as bool? ?? false,
|
||||||
|
zIndex: json['zIndex'] as int? ?? 0,
|
||||||
|
borderWidthMm: (json['borderWidthMm'] as num?)?.toDouble() ?? 0.3,
|
||||||
|
isFilled: json['isFilled'] as bool? ?? false,
|
||||||
|
cornerRadiusMm: (json['cornerRadiusMm'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LineElement extends LabelElement {
|
||||||
|
final double thicknessMm;
|
||||||
|
final bool isVertical;
|
||||||
|
|
||||||
|
const LineElement({
|
||||||
|
required super.id,
|
||||||
|
required super.xMm,
|
||||||
|
required super.yMm,
|
||||||
|
required super.widthMm,
|
||||||
|
required super.heightMm,
|
||||||
|
super.rotation = 0.0,
|
||||||
|
super.isLocked = false,
|
||||||
|
super.zIndex = 0,
|
||||||
|
this.thicknessMm = 0.3,
|
||||||
|
this.isVertical = false,
|
||||||
|
}) : super(type: ElementType.line);
|
||||||
|
|
||||||
|
@override
|
||||||
|
LineElement copyWithPosition({
|
||||||
|
double? xMm,
|
||||||
|
double? yMm,
|
||||||
|
double? widthMm,
|
||||||
|
double? heightMm,
|
||||||
|
double? rotation,
|
||||||
|
bool? isLocked,
|
||||||
|
int? zIndex,
|
||||||
|
double? thicknessMm,
|
||||||
|
bool? isVertical,
|
||||||
|
}) {
|
||||||
|
return LineElement(
|
||||||
|
id: id,
|
||||||
|
xMm: xMm ?? this.xMm,
|
||||||
|
yMm: yMm ?? this.yMm,
|
||||||
|
widthMm: widthMm ?? this.widthMm,
|
||||||
|
heightMm: heightMm ?? this.heightMm,
|
||||||
|
rotation: rotation ?? this.rotation,
|
||||||
|
isLocked: isLocked ?? this.isLocked,
|
||||||
|
zIndex: zIndex ?? this.zIndex,
|
||||||
|
thicknessMm: thicknessMm ?? this.thicknessMm,
|
||||||
|
isVertical: isVertical ?? this.isVertical,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'type': 'line',
|
||||||
|
'xMm': xMm,
|
||||||
|
'yMm': yMm,
|
||||||
|
'widthMm': widthMm,
|
||||||
|
'heightMm': heightMm,
|
||||||
|
'rotation': rotation,
|
||||||
|
'isLocked': isLocked,
|
||||||
|
'zIndex': zIndex,
|
||||||
|
'thicknessMm': thicknessMm,
|
||||||
|
'isVertical': isVertical,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory LineElement.fromJson(Map<String, dynamic> json) {
|
||||||
|
return LineElement(
|
||||||
|
id: json['id'] as String,
|
||||||
|
xMm: (json['xMm'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
yMm: (json['yMm'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
widthMm: (json['widthMm'] as num?)?.toDouble() ?? 20.0,
|
||||||
|
heightMm: (json['heightMm'] as num?)?.toDouble() ?? 1.0,
|
||||||
|
rotation: (json['rotation'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
isLocked: json['isLocked'] as bool? ?? false,
|
||||||
|
zIndex: json['zIndex'] as int? ?? 0,
|
||||||
|
thicknessMm: (json['thicknessMm'] as num?)?.toDouble() ?? 0.3,
|
||||||
|
isVertical: json['isVertical'] as bool? ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LabelDocument {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String? description;
|
||||||
|
final int version;
|
||||||
|
final LabelConfiguration config;
|
||||||
|
final List<LabelElement> elements;
|
||||||
|
|
||||||
|
const LabelDocument({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
this.description,
|
||||||
|
this.version = 1,
|
||||||
|
this.config = const LabelConfiguration(),
|
||||||
|
this.elements = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
LabelDocument copyWith({
|
||||||
|
String? id,
|
||||||
|
String? name,
|
||||||
|
String? description,
|
||||||
|
int? version,
|
||||||
|
LabelConfiguration? config,
|
||||||
|
List<LabelElement>? elements,
|
||||||
|
}) {
|
||||||
|
return LabelDocument(
|
||||||
|
id: id ?? this.id,
|
||||||
|
name: name ?? this.name,
|
||||||
|
description: description ?? this.description,
|
||||||
|
version: version ?? this.version,
|
||||||
|
config: config ?? this.config,
|
||||||
|
elements: elements ?? this.elements,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'version': version,
|
||||||
|
'name': name,
|
||||||
|
'description': description,
|
||||||
|
'label': config.toJson(),
|
||||||
|
'elements': elements.map((e) => e.toJson()).toList(),
|
||||||
|
};
|
||||||
|
|
||||||
|
String toJsonString() => jsonEncode(toJson());
|
||||||
|
|
||||||
|
factory LabelDocument.fromJson(Map<String, dynamic> json, {String? id, String? name, String? description}) {
|
||||||
|
final labelConfig = json['label'] != null
|
||||||
|
? LabelConfiguration.fromJson(json['label'] as Map<String, dynamic>)
|
||||||
|
: const LabelConfiguration();
|
||||||
|
|
||||||
|
final rawElements = json['elements'] as List<dynamic>? ?? [];
|
||||||
|
final elementsList = rawElements
|
||||||
|
.map((e) => LabelElement.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return LabelDocument(
|
||||||
|
id: id ?? (json['id'] as String? ?? 'template_1'),
|
||||||
|
name: name ?? (json['name'] as String? ?? 'Untitled Template'),
|
||||||
|
description: description ?? (json['description'] as String?),
|
||||||
|
version: json['version'] as int? ?? 1,
|
||||||
|
config: labelConfig,
|
||||||
|
elements: elementsList,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static LabelDocument createDefault() {
|
||||||
|
return const LabelDocument(
|
||||||
|
id: 'default_template',
|
||||||
|
name: '50x25 Product Barcode',
|
||||||
|
description: 'Standard jewellery and product barcode label (50x25mm)',
|
||||||
|
config: LabelConfiguration(
|
||||||
|
widthMm: 50.0,
|
||||||
|
heightMm: 25.0,
|
||||||
|
dpi: 203,
|
||||||
|
),
|
||||||
|
elements: [
|
||||||
|
TextElement(
|
||||||
|
id: 'elem_brand',
|
||||||
|
xMm: 2.0,
|
||||||
|
yMm: 1.8,
|
||||||
|
widthMm: 46.0,
|
||||||
|
heightMm: 3.5,
|
||||||
|
text: 'kifi',
|
||||||
|
fontSizePt: 8.5,
|
||||||
|
isBold: true,
|
||||||
|
alignment: TextAlign.center,
|
||||||
|
),
|
||||||
|
DynamicTextElement(
|
||||||
|
id: 'elem_product_name',
|
||||||
|
xMm: 2.0,
|
||||||
|
yMm: 5.5,
|
||||||
|
widthMm: 46.0,
|
||||||
|
heightMm: 4.0,
|
||||||
|
source: 'product',
|
||||||
|
fieldKey: 'name',
|
||||||
|
fontSizePt: 8.0,
|
||||||
|
alignment: TextAlign.center,
|
||||||
|
),
|
||||||
|
BarcodeElement(
|
||||||
|
id: 'elem_barcode',
|
||||||
|
xMm: 5.0,
|
||||||
|
yMm: 10.0,
|
||||||
|
widthMm: 40.0,
|
||||||
|
heightMm: 8.0,
|
||||||
|
valueType: 'dynamic',
|
||||||
|
source: 'product',
|
||||||
|
fieldKey: 'barcode',
|
||||||
|
showText: true,
|
||||||
|
),
|
||||||
|
DynamicTextElement(
|
||||||
|
id: 'elem_price',
|
||||||
|
xMm: 2.0,
|
||||||
|
yMm: 19.5,
|
||||||
|
widthMm: 23.0,
|
||||||
|
heightMm: 3.8,
|
||||||
|
source: 'product',
|
||||||
|
fieldKey: 'sellingPrice',
|
||||||
|
prefix: '₹ ',
|
||||||
|
fontSizePt: 8.0,
|
||||||
|
isBold: true,
|
||||||
|
alignment: TextAlign.left,
|
||||||
|
),
|
||||||
|
DynamicTextElement(
|
||||||
|
id: 'elem_weight',
|
||||||
|
xMm: 26.0,
|
||||||
|
yMm: 19.5,
|
||||||
|
widthMm: 22.0,
|
||||||
|
heightMm: 3.8,
|
||||||
|
source: 'product',
|
||||||
|
fieldKey: 'weightInG',
|
||||||
|
prefix: 'Wt: ',
|
||||||
|
suffix: ' g',
|
||||||
|
fontSizePt: 7.5,
|
||||||
|
alignment: TextAlign.right,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,242 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
import '../providers/barcode_templates_provider.dart';
|
||||||
|
import '../providers/barcode_designer_provider.dart';
|
||||||
|
import 'barcode_designer_screen.dart';
|
||||||
|
|
||||||
|
class SavedTemplatesScreen extends ConsumerWidget {
|
||||||
|
const SavedTemplatesScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final templatesAsync = ref.watch(barcodeTemplatesProvider);
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Saved Label Templates'),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(LucideIcons.refreshCw),
|
||||||
|
tooltip: 'Refresh',
|
||||||
|
onPressed: () => ref.read(barcodeTemplatesProvider.notifier).refresh(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
|
icon: const Icon(LucideIcons.plus),
|
||||||
|
label: const Text('New Template'),
|
||||||
|
onPressed: () {
|
||||||
|
ref.read(barcodeDesignerProvider.notifier).resetToNew(50.0, 25.0);
|
||||||
|
Navigator.pushReplacement(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (_) => const BarcodeDesignerScreen()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
body: templatesAsync.when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (err, _) => Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(LucideIcons.circleAlert, size: 48, color: Colors.red),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text('Error loading templates: $err'),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => ref.read(barcodeTemplatesProvider.notifier).refresh(),
|
||||||
|
child: const Text('Retry'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
data: (templates) {
|
||||||
|
if (templates.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.blue.withValues(alpha: 0.1),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(LucideIcons.barcode, size: 48, color: Colors.blue),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text(
|
||||||
|
'No templates saved yet',
|
||||||
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Create your first custom barcode and label template',
|
||||||
|
style: TextStyle(color: Colors.grey.shade500, fontSize: 13),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
icon: const Icon(LucideIcons.plus, size: 18),
|
||||||
|
label: const Text('Create New Template'),
|
||||||
|
onPressed: () {
|
||||||
|
ref.read(barcodeDesignerProvider.notifier).resetToNew(50.0, 25.0);
|
||||||
|
Navigator.pushReplacement(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (_) => const BarcodeDesignerScreen()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return GridView.builder(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||||
|
maxCrossAxisExtent: 360,
|
||||||
|
mainAxisExtent: 180,
|
||||||
|
crossAxisSpacing: 16,
|
||||||
|
mainAxisSpacing: 16,
|
||||||
|
),
|
||||||
|
itemCount: templates.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = templates[index];
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isDark ? const Color(0xFF1E293B) : Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(
|
||||||
|
color: isDark ? Colors.white10 : Colors.grey.shade200,
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.04),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () {
|
||||||
|
final doc = item.toLabelDocument();
|
||||||
|
ref.read(barcodeDesignerProvider.notifier).loadDocument(doc, item.id);
|
||||||
|
Navigator.pushReplacement(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => BarcodeDesignerScreen(initialTemplate: item),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.blue.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: const Icon(LucideIcons.barcode, color: Colors.blue, size: 20),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
item.templateName,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${item.labelWidthMm.round()} × ${item.labelHeightMm.round()} mm • ${item.printerLanguage}',
|
||||||
|
style: TextStyle(color: Colors.grey.shade500, fontSize: 11),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PopupMenuButton<String>(
|
||||||
|
icon: const Icon(LucideIcons.ellipsisVertical, size: 18),
|
||||||
|
onSelected: (val) async {
|
||||||
|
if (val == 'delete' && item.id != null) {
|
||||||
|
final confirm = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => AlertDialog(
|
||||||
|
title: const Text('Delete Template'),
|
||||||
|
content: Text('Are you sure you want to delete "${item.templateName}"?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
child: const Text('Delete', style: TextStyle(color: Colors.white)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirm == true) {
|
||||||
|
ref.read(barcodeTemplatesProvider.notifier).deleteTemplate(item.id!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (_) => [
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'delete',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(LucideIcons.trash2, color: Colors.red, size: 16),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Delete', style: TextStyle(color: Colors.red)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isDark ? Colors.black26 : Colors.grey.shade100,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'DPI: ${item.dpi}',
|
||||||
|
style: TextStyle(fontSize: 11, color: Colors.grey.shade600),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'v${item.version}',
|
||||||
|
style: TextStyle(fontSize: 11, color: Colors.grey.shade600),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class CanvasRuler extends StatelessWidget {
|
||||||
|
final double lengthMm;
|
||||||
|
final double scale; // pixels per mm
|
||||||
|
final bool isHorizontal;
|
||||||
|
final double thickness;
|
||||||
|
|
||||||
|
const CanvasRuler({
|
||||||
|
super.key,
|
||||||
|
required this.lengthMm,
|
||||||
|
required this.scale,
|
||||||
|
this.isHorizontal = true,
|
||||||
|
this.thickness = 20.0,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
return CustomPaint(
|
||||||
|
size: isHorizontal
|
||||||
|
? Size(lengthMm * scale, thickness)
|
||||||
|
: Size(thickness, lengthMm * scale),
|
||||||
|
painter: _RulerPainter(
|
||||||
|
lengthMm: lengthMm,
|
||||||
|
scale: scale,
|
||||||
|
isHorizontal: isHorizontal,
|
||||||
|
isDark: isDark,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RulerPainter extends CustomPainter {
|
||||||
|
final double lengthMm;
|
||||||
|
final double scale;
|
||||||
|
final bool isHorizontal;
|
||||||
|
final bool isDark;
|
||||||
|
|
||||||
|
_RulerPainter({
|
||||||
|
required this.lengthMm,
|
||||||
|
required this.scale,
|
||||||
|
required this.isHorizontal,
|
||||||
|
required this.isDark,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final bgPaint = Paint()
|
||||||
|
..color = isDark ? const Color(0xFF1E293B) : const Color(0xFFF1F5F9);
|
||||||
|
canvas.drawRect(Offset.zero & size, bgPaint);
|
||||||
|
|
||||||
|
final linePaint = Paint()
|
||||||
|
..color = isDark ? Colors.white30 : Colors.black26
|
||||||
|
..strokeWidth = 1.0;
|
||||||
|
|
||||||
|
final textStyle = TextStyle(
|
||||||
|
color: isDark ? Colors.white60 : Colors.black54,
|
||||||
|
fontSize: 8,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
);
|
||||||
|
|
||||||
|
final textPainter = TextPainter(
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Draw ticks every 1mm, with labels every 5mm or 10mm
|
||||||
|
for (int mm = 0; mm <= lengthMm.ceil(); mm++) {
|
||||||
|
final pos = mm * scale;
|
||||||
|
final isTen = mm % 10 == 0;
|
||||||
|
final isFive = mm % 5 == 0;
|
||||||
|
|
||||||
|
final tickLength = isTen ? 12.0 : (isFive ? 7.0 : 4.0);
|
||||||
|
|
||||||
|
if (isHorizontal) {
|
||||||
|
canvas.drawLine(
|
||||||
|
Offset(pos, size.height - tickLength),
|
||||||
|
Offset(pos, size.height),
|
||||||
|
linePaint,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isTen && mm > 0 && mm < lengthMm) {
|
||||||
|
textPainter.text = TextSpan(text: '$mm', style: textStyle);
|
||||||
|
textPainter.layout();
|
||||||
|
textPainter.paint(canvas, Offset(pos - (textPainter.width / 2), 2));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
canvas.drawLine(
|
||||||
|
Offset(size.width - tickLength, pos),
|
||||||
|
Offset(size.width, pos),
|
||||||
|
linePaint,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isTen && mm > 0 && mm < lengthMm) {
|
||||||
|
textPainter.text = TextSpan(text: '$mm', style: textStyle);
|
||||||
|
textPainter.layout();
|
||||||
|
textPainter.paint(canvas, Offset(2, pos - (textPainter.height / 2)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(covariant _RulerPainter oldDelegate) {
|
||||||
|
return oldDelegate.lengthMm != lengthMm ||
|
||||||
|
oldDelegate.scale != scale ||
|
||||||
|
oldDelegate.isHorizontal != isHorizontal ||
|
||||||
|
oldDelegate.isDark != isDark;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
import '../../domain/label_document.dart';
|
||||||
|
|
||||||
|
class ToolboxItemData {
|
||||||
|
final ElementType type;
|
||||||
|
final String label;
|
||||||
|
final IconData icon;
|
||||||
|
final Color color;
|
||||||
|
final String description;
|
||||||
|
|
||||||
|
const ToolboxItemData({
|
||||||
|
required this.type,
|
||||||
|
required this.label,
|
||||||
|
required this.icon,
|
||||||
|
required this.color,
|
||||||
|
required this.description,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class DesignerToolbox extends StatelessWidget {
|
||||||
|
final Function(ElementType type) onAddElement;
|
||||||
|
|
||||||
|
const DesignerToolbox({
|
||||||
|
super.key,
|
||||||
|
required this.onAddElement,
|
||||||
|
});
|
||||||
|
|
||||||
|
static const List<ToolboxItemData> items = [
|
||||||
|
ToolboxItemData(
|
||||||
|
type: ElementType.text,
|
||||||
|
label: 'Text',
|
||||||
|
icon: LucideIcons.type,
|
||||||
|
color: Colors.blue,
|
||||||
|
description: 'Static label text (default: kifi)',
|
||||||
|
),
|
||||||
|
ToolboxItemData(
|
||||||
|
type: ElementType.dynamicText,
|
||||||
|
label: 'Dynamic Field',
|
||||||
|
icon: LucideIcons.variable,
|
||||||
|
color: Colors.purple,
|
||||||
|
description: 'Bound product placeholder',
|
||||||
|
),
|
||||||
|
ToolboxItemData(
|
||||||
|
type: ElementType.barcode,
|
||||||
|
label: 'Barcode',
|
||||||
|
icon: LucideIcons.barcode,
|
||||||
|
color: Colors.indigo,
|
||||||
|
description: 'Code 128, EAN-13, SKU barcode',
|
||||||
|
),
|
||||||
|
ToolboxItemData(
|
||||||
|
type: ElementType.qrCode,
|
||||||
|
label: 'QR Code',
|
||||||
|
icon: LucideIcons.qrCode,
|
||||||
|
color: Colors.teal,
|
||||||
|
description: '2D matrix barcode or URL',
|
||||||
|
),
|
||||||
|
ToolboxItemData(
|
||||||
|
type: ElementType.rectangle,
|
||||||
|
label: 'Rectangle',
|
||||||
|
icon: LucideIcons.square,
|
||||||
|
color: Colors.amber,
|
||||||
|
description: 'Bordered or filled box',
|
||||||
|
),
|
||||||
|
ToolboxItemData(
|
||||||
|
type: ElementType.line,
|
||||||
|
label: 'Line Divider',
|
||||||
|
icon: LucideIcons.minus,
|
||||||
|
color: Colors.blueGrey,
|
||||||
|
description: 'Horizontal or vertical line',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
|
||||||
|
child: Text(
|
||||||
|
'TOOLBOX',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
letterSpacing: 1.1,
|
||||||
|
color: Colors.grey.shade500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
...items.map((item) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8.0),
|
||||||
|
child: Draggable<ElementType>(
|
||||||
|
data: item.type,
|
||||||
|
feedback: Material(
|
||||||
|
elevation: 6,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: item.color,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(item.icon, color: Colors.white, size: 18),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
item.label,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
childWhenDragging: Opacity(
|
||||||
|
opacity: 0.4,
|
||||||
|
child: _buildItemCard(context, item, isDark),
|
||||||
|
),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => onAddElement(item.type),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: _buildItemCard(context, item, isDark),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildItemCard(BuildContext context, ToolboxItemData item, bool isDark) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isDark ? const Color(0xFF1E293B) : Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: isDark ? Colors.white10 : Colors.grey.shade200,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: item.color.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Icon(item.icon, color: item.color, size: 18),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
item.label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
item.description,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.grey.shade500,
|
||||||
|
fontSize: 10.5,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(LucideIcons.gripVertical, size: 14, color: Colors.grey.shade400),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,106 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
|
||||||
|
class PrinterCommandPreview extends StatefulWidget {
|
||||||
|
final String commands;
|
||||||
|
final String language;
|
||||||
|
|
||||||
|
const PrinterCommandPreview({
|
||||||
|
super.key,
|
||||||
|
required this.commands,
|
||||||
|
this.language = 'ZPL',
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PrinterCommandPreview> createState() => _PrinterCommandPreviewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PrinterCommandPreviewState extends State<PrinterCommandPreview> {
|
||||||
|
bool _isExpanded = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isDark ? const Color(0xFF0F172A) : const Color(0xFF1E293B),
|
||||||
|
border: Border(
|
||||||
|
top: BorderSide(
|
||||||
|
color: isDark ? Colors.white12 : Colors.black12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// Header Bar
|
||||||
|
InkWell(
|
||||||
|
onTap: () => setState(() => _isExpanded = !_isExpanded),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
LucideIcons.code,
|
||||||
|
size: 16,
|
||||||
|
color: Colors.cyanAccent.shade400,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'Live Printer Commands (${widget.language})',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(LucideIcons.copy, size: 14, color: Colors.white70),
|
||||||
|
tooltip: 'Copy Commands',
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
onPressed: () {
|
||||||
|
Clipboard.setData(ClipboardData(text: widget.commands));
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Printer commands copied to clipboard!'),
|
||||||
|
duration: Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Icon(
|
||||||
|
_isExpanded ? LucideIcons.chevronDown : LucideIcons.chevronUp,
|
||||||
|
size: 16,
|
||||||
|
color: Colors.white70,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
if (_isExpanded)
|
||||||
|
Container(
|
||||||
|
height: 140,
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
color: Colors.black26,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: SelectableText(
|
||||||
|
widget.commands.isEmpty ? '// No elements to generate commands' : widget.commands,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 11,
|
||||||
|
color: Color(0xFF38BDF8), // Sky blue terminal text
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
import '../../../inventory/domain/product.dart';
|
||||||
|
import '../../../inventory/domain/inventory_item.dart';
|
||||||
|
import '../../../inventory/providers/products_provider.dart';
|
||||||
|
import '../../../inventory/providers/product_categories_provider.dart';
|
||||||
|
import '../../../inventory/providers/inventory_items_provider.dart';
|
||||||
|
import '../../../business/providers/business_provider.dart';
|
||||||
|
|
||||||
|
class ProductSelectionResult {
|
||||||
|
final Product product;
|
||||||
|
final InventoryItem? inventoryItem;
|
||||||
|
|
||||||
|
const ProductSelectionResult({
|
||||||
|
required this.product,
|
||||||
|
this.inventoryItem,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProductPickerDialog extends ConsumerStatefulWidget {
|
||||||
|
const ProductPickerDialog({super.key});
|
||||||
|
|
||||||
|
static Future<ProductSelectionResult?> show(BuildContext context) {
|
||||||
|
return showModalBottomSheet<ProductSelectionResult>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (_) => const ProductPickerDialog(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<ProductPickerDialog> createState() => _ProductPickerDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProductPickerDialogState extends ConsumerState<ProductPickerDialog> {
|
||||||
|
final TextEditingController _searchCtrl = TextEditingController();
|
||||||
|
int? _selectedCategoryId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
final products = ref.watch(productsProvider).value ?? [];
|
||||||
|
final categories = ref.watch(productCategoriesProvider).value ?? [];
|
||||||
|
final inventoryItems = ref.watch(inventoryItemsProvider).value ?? [];
|
||||||
|
final businessProfile = ref.watch(businessProfileProvider).value;
|
||||||
|
|
||||||
|
final nature = (businessProfile?.natureOfBusiness ?? businessProfile?.industry ?? 'JEWELLERY').toUpperCase();
|
||||||
|
final isJewellery = nature == 'JEWELLERY';
|
||||||
|
|
||||||
|
final query = _searchCtrl.text.trim().toLowerCase();
|
||||||
|
|
||||||
|
// Filter products
|
||||||
|
final filteredProducts = products.where((p) {
|
||||||
|
if (_selectedCategoryId != null && p.categoryId != _selectedCategoryId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (query.isEmpty) return true;
|
||||||
|
final nameMatch = p.name.toLowerCase().contains(query);
|
||||||
|
final skuMatch = p.sku?.toLowerCase().contains(query) ?? false;
|
||||||
|
final barcodeMatch = p.barcode?.toLowerCase().contains(query) ?? false;
|
||||||
|
final hsnMatch = p.hsnCode?.toLowerCase().contains(query) ?? false;
|
||||||
|
return nameMatch || skuMatch || barcodeMatch || hsnMatch;
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
return DraggableScrollableSheet(
|
||||||
|
initialChildSize: 0.85,
|
||||||
|
minChildSize: 0.5,
|
||||||
|
maxChildSize: 0.95,
|
||||||
|
builder: (context, scrollController) {
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isDark ? const Color(0xFF0F172A) : Colors.white,
|
||||||
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.2),
|
||||||
|
blurRadius: 16,
|
||||||
|
offset: const Offset(0, -4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Top drag pill
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.only(top: 12, bottom: 8),
|
||||||
|
width: 44,
|
||||||
|
height: 5,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isDark ? Colors.white24 : Colors.grey.shade300,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Title bar
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 4, 16, 12),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.blue.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: const Icon(LucideIcons.packageSearch, color: Colors.blue, size: 20),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Select Sample Product',
|
||||||
|
style: TextStyle(fontSize: 17, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Product values will be used for live preview and test printing',
|
||||||
|
style: TextStyle(color: Colors.grey.shade500, fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Refresh Products',
|
||||||
|
icon: const Icon(LucideIcons.refreshCw, size: 18),
|
||||||
|
onPressed: () => ref.read(productsProvider.notifier).refresh(),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(LucideIcons.x, size: 20),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const Divider(height: 1),
|
||||||
|
|
||||||
|
// Search bar
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 14, 20, 10),
|
||||||
|
child: TextField(
|
||||||
|
controller: _searchCtrl,
|
||||||
|
autofocus: false,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: isJewellery
|
||||||
|
? 'Search by name, SKU, barcode, or tag...'
|
||||||
|
: 'Search by product name, SKU, or barcode...',
|
||||||
|
prefixIcon: const Icon(LucideIcons.search, size: 18),
|
||||||
|
suffixIcon: _searchCtrl.text.isNotEmpty
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(LucideIcons.x, size: 16),
|
||||||
|
onPressed: () => setState(() => _searchCtrl.clear()),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
filled: true,
|
||||||
|
fillColor: isDark ? const Color(0xFF1E293B) : Colors.grey.shade100,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onChanged: (_) => setState(() {}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Category filters
|
||||||
|
if (categories.isNotEmpty)
|
||||||
|
SizedBox(
|
||||||
|
height: 36,
|
||||||
|
child: ListView.separated(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
separatorBuilder: (_, index) => const SizedBox(width: 8),
|
||||||
|
itemCount: categories.length + 1,
|
||||||
|
itemBuilder: (context, idx) {
|
||||||
|
if (idx == 0) {
|
||||||
|
final isSelected = _selectedCategoryId == null;
|
||||||
|
return ChoiceChip(
|
||||||
|
label: const Text('All Categories', style: TextStyle(fontSize: 12)),
|
||||||
|
selected: isSelected,
|
||||||
|
onSelected: (_) => setState(() => _selectedCategoryId = null),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final cat = categories[idx - 1];
|
||||||
|
final isSelected = _selectedCategoryId == cat.id;
|
||||||
|
return ChoiceChip(
|
||||||
|
label: Text(cat.name, style: const TextStyle(fontSize: 12)),
|
||||||
|
selected: isSelected,
|
||||||
|
onSelected: (_) => setState(() => _selectedCategoryId = cat.id),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
// Products list
|
||||||
|
Expanded(
|
||||||
|
child: filteredProducts.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(LucideIcons.packageOpen, size: 48, color: Colors.grey.shade400),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
query.isEmpty
|
||||||
|
? 'No products available in catalog'
|
||||||
|
: 'No matching products found',
|
||||||
|
style: TextStyle(color: Colors.grey.shade500, fontSize: 14),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView.builder(
|
||||||
|
controller: scrollController,
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
|
||||||
|
itemCount: filteredProducts.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final product = filteredProducts[index];
|
||||||
|
final category = categories
|
||||||
|
.where((c) => c.id == product.categoryId)
|
||||||
|
.firstOrNull;
|
||||||
|
|
||||||
|
// Find matching inventory items for jewelry tags
|
||||||
|
final relatedItems = inventoryItems
|
||||||
|
.where((i) => i.productId == product.id)
|
||||||
|
.toList();
|
||||||
|
final firstItem = relatedItems.isNotEmpty ? relatedItems.first : null;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isDark ? const Color(0xFF1E293B) : Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(
|
||||||
|
color: isDark ? Colors.white10 : Colors.grey.shade200,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(
|
||||||
|
context,
|
||||||
|
ProductSelectionResult(
|
||||||
|
product: product,
|
||||||
|
inventoryItem: firstItem,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.blue.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
LucideIcons.package,
|
||||||
|
color: Colors.blue,
|
||||||
|
size: 22,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
product.name,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 4,
|
||||||
|
children: [
|
||||||
|
if (product.sku != null && product.sku!.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
'SKU: ${product.sku}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.grey.shade500,
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (product.barcode != null && product.barcode!.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
'Barcode: ${product.barcode}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.grey.shade500,
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (category != null)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 6,
|
||||||
|
vertical: 1,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
category.name,
|
||||||
|
style: const TextStyle(fontSize: 10),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
if (product.mrp != null)
|
||||||
|
Text(
|
||||||
|
'MRP ₹${product.mrp!.toStringAsFixed(0)}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.blue,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (product.sellingPrice != null)
|
||||||
|
Text(
|
||||||
|
'₹${product.sellingPrice!.toStringAsFixed(0)}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.green,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (product.weightInG != null)
|
||||||
|
Text(
|
||||||
|
'${product.weightInG!.toStringAsFixed(2)} g',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.grey.shade500,
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
const Icon(
|
||||||
|
LucideIcons.chevronRight,
|
||||||
|
size: 16,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
import '../../domain/dynamic_field_definition.dart';
|
||||||
|
|
||||||
|
class ProductVariablesPanel extends StatelessWidget {
|
||||||
|
final Function(DynamicFieldDefinition fieldDef) onAddField;
|
||||||
|
|
||||||
|
const ProductVariablesPanel({
|
||||||
|
super.key,
|
||||||
|
required this.onAddField,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
// Group fields by category
|
||||||
|
final Map<String, List<DynamicFieldDefinition>> categories = {};
|
||||||
|
for (final field in DynamicFieldDefinition.productFields) {
|
||||||
|
categories.putIfAbsent(field.category, () => []).add(field);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'PRODUCT DATA FIELDS',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
letterSpacing: 1.1,
|
||||||
|
color: Colors.grey.shade500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.blue.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'DRAGGABLE',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.blue,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
...categories.entries.map((entry) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(4, 10, 4, 4),
|
||||||
|
child: Text(
|
||||||
|
entry.key.toUpperCase(),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
...entry.value.map((field) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 6.0),
|
||||||
|
child: Draggable<DynamicFieldDefinition>(
|
||||||
|
data: field,
|
||||||
|
feedback: Material(
|
||||||
|
elevation: 6,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.purple.shade700,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(LucideIcons.variable, color: Colors.white, size: 16),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
field.displayName,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
childWhenDragging: Opacity(
|
||||||
|
opacity: 0.4,
|
||||||
|
child: _buildVariableCard(context, field, isDark),
|
||||||
|
),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => onAddField(field),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
child: _buildVariableCard(context, field, isDark),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildVariableCard(BuildContext context, DynamicFieldDefinition field, bool isDark) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isDark ? const Color(0xFF1E293B) : Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(
|
||||||
|
color: isDark ? Colors.white10 : Colors.grey.shade200,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.purple.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
field.isBarcodeCompatible ? LucideIcons.barcode : LucideIcons.variable,
|
||||||
|
color: Colors.purple,
|
||||||
|
size: 15,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
field.displayName,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'{{${field.source}.${field.fieldKey}}}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.purple.shade300,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(LucideIcons.gripVertical, size: 14, color: Colors.grey.shade400),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,531 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../../../core/network/dio_client.dart';
|
||||||
|
import '../domain/barcode_template.dart';
|
||||||
|
|
||||||
|
class BarcodeTemplatesNotifier extends AsyncNotifier<List<BarcodeTemplate>> {
|
||||||
|
@override
|
||||||
|
FutureOr<List<BarcodeTemplate>> build() async {
|
||||||
|
return _fetchTemplates();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<BarcodeTemplate>> _fetchTemplates() async {
|
||||||
|
try {
|
||||||
|
final response = await DioClient().dio.get('/barcode-templates');
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final List<dynamic> data = response.data;
|
||||||
|
return data.map((e) => BarcodeTemplate.fromJson(e)).toList();
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
} catch (e) {
|
||||||
|
print('Error fetching barcode templates: $e');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> refresh() async {
|
||||||
|
state = const AsyncValue.loading();
|
||||||
|
try {
|
||||||
|
final templates = await _fetchTemplates();
|
||||||
|
state = AsyncValue.data(templates);
|
||||||
|
} catch (e, stack) {
|
||||||
|
state = AsyncValue.error(e, stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<BarcodeTemplate?> saveTemplate(BarcodeTemplate template) async {
|
||||||
|
try {
|
||||||
|
if (template.id != null) {
|
||||||
|
final response = await DioClient().dio.put(
|
||||||
|
'/barcode-templates/${template.id}',
|
||||||
|
data: template.toJson(),
|
||||||
|
);
|
||||||
|
await refresh();
|
||||||
|
if (response.data != null) {
|
||||||
|
return BarcodeTemplate.fromJson(response.data);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
final response = await DioClient().dio.post(
|
||||||
|
'/barcode-templates',
|
||||||
|
data: template.toJson(),
|
||||||
|
);
|
||||||
|
await refresh();
|
||||||
|
if (response.data != null) {
|
||||||
|
return BarcodeTemplate.fromJson(response.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
print('Error saving barcode template: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> deleteTemplate(int id) async {
|
||||||
|
try {
|
||||||
|
final response = await DioClient().dio.delete('/barcode-templates/$id');
|
||||||
|
await refresh();
|
||||||
|
return response.statusCode == 200 || response.statusCode == 204;
|
||||||
|
} catch (e) {
|
||||||
|
print('Error deleting barcode template: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final barcodeTemplatesProvider =
|
||||||
|
AsyncNotifierProvider<BarcodeTemplatesNotifier, List<BarcodeTemplate>>(() {
|
||||||
|
return BarcodeTemplatesNotifier();
|
||||||
|
});
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:printing/printing.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import '../domain/label_document.dart';
|
||||||
|
import '../../inventory/domain/product.dart';
|
||||||
|
|
||||||
|
class PrinterDevice {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String ipAddress;
|
||||||
|
final int port;
|
||||||
|
final String language; // ZPL, TSPL, ESCPOS
|
||||||
|
final bool isOnline;
|
||||||
|
final String connectionType; // 'wifi_auto', 'manual_ip'
|
||||||
|
final String? model;
|
||||||
|
final String? rawUrl;
|
||||||
|
|
||||||
|
const PrinterDevice({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.ipAddress,
|
||||||
|
this.port = 9100,
|
||||||
|
this.language = 'ZPL',
|
||||||
|
this.isOnline = true,
|
||||||
|
this.connectionType = 'wifi_auto',
|
||||||
|
this.model,
|
||||||
|
this.rawUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
PrinterDevice copyWith({
|
||||||
|
String? id,
|
||||||
|
String? name,
|
||||||
|
String? ipAddress,
|
||||||
|
int? port,
|
||||||
|
String? language,
|
||||||
|
bool? isOnline,
|
||||||
|
String? connectionType,
|
||||||
|
String? model,
|
||||||
|
String? rawUrl,
|
||||||
|
}) {
|
||||||
|
return PrinterDevice(
|
||||||
|
id: id ?? this.id,
|
||||||
|
name: name ?? this.name,
|
||||||
|
ipAddress: ipAddress ?? this.ipAddress,
|
||||||
|
port: port ?? this.port,
|
||||||
|
language: language ?? this.language,
|
||||||
|
isOnline: isOnline ?? this.isOnline,
|
||||||
|
connectionType: connectionType ?? this.connectionType,
|
||||||
|
model: model ?? this.model,
|
||||||
|
rawUrl: rawUrl ?? this.rawUrl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ValidationResult {
|
||||||
|
final bool isValid;
|
||||||
|
final List<String> errors;
|
||||||
|
final List<String> warnings;
|
||||||
|
|
||||||
|
const ValidationResult({
|
||||||
|
required this.isValid,
|
||||||
|
this.errors = const [],
|
||||||
|
this.warnings = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get hasErrors => errors.isNotEmpty;
|
||||||
|
bool get hasWarnings => warnings.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
class PrinterService {
|
||||||
|
static const String _prefKeyIp = 'kifi_barcode_printer_ip';
|
||||||
|
static const String _prefKeyPort = 'kifi_barcode_printer_port';
|
||||||
|
static const String _prefKeyLang = 'kifi_barcode_printer_lang';
|
||||||
|
static const String _prefKeyMode = 'kifi_barcode_printer_mode';
|
||||||
|
static const String _prefKeyName = 'kifi_barcode_printer_name';
|
||||||
|
static const String _prefKeyDpi = 'kifi_barcode_printer_dpi';
|
||||||
|
|
||||||
|
/// Saves user printer preferences
|
||||||
|
static Future<void> savePrinterPreferences({
|
||||||
|
required String mode,
|
||||||
|
required String ip,
|
||||||
|
required int port,
|
||||||
|
required String language,
|
||||||
|
int? dpi,
|
||||||
|
String? name,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_prefKeyMode, mode);
|
||||||
|
await prefs.setString(_prefKeyIp, ip);
|
||||||
|
await prefs.setInt(_prefKeyPort, port);
|
||||||
|
await prefs.setString(_prefKeyLang, language);
|
||||||
|
if (dpi != null) {
|
||||||
|
await prefs.setInt(_prefKeyDpi, dpi);
|
||||||
|
}
|
||||||
|
if (name != null) {
|
||||||
|
await prefs.setString(_prefKeyName, name);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads saved printer preferences
|
||||||
|
static Future<Map<String, dynamic>> loadPrinterPreferences() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return {
|
||||||
|
'mode': prefs.getString(_prefKeyMode) ?? 'auto',
|
||||||
|
'ip': prefs.getString(_prefKeyIp) ?? '192.168.1.100',
|
||||||
|
'port': prefs.getInt(_prefKeyPort) ?? 9100,
|
||||||
|
'language': prefs.getString(_prefKeyLang) ?? 'ZPL',
|
||||||
|
'dpi': prefs.getInt(_prefKeyDpi) ?? 300,
|
||||||
|
'name': prefs.getString(_prefKeyName),
|
||||||
|
};
|
||||||
|
} catch (_) {
|
||||||
|
return {
|
||||||
|
'mode': 'auto',
|
||||||
|
'ip': '192.168.1.100',
|
||||||
|
'port': 9100,
|
||||||
|
'language': 'ZPL',
|
||||||
|
'dpi': 300,
|
||||||
|
'name': null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Auto-detects printers connected to the local Wi-Fi network (AirPrint, Bonjour, mDNS)
|
||||||
|
static Future<List<PrinterDevice>> discoverWifiPrinters() async {
|
||||||
|
final discovered = <PrinterDevice>[];
|
||||||
|
|
||||||
|
if (kIsWeb) {
|
||||||
|
// Browsers do not allow arbitrary local subnet broadcast / enumeration
|
||||||
|
return discovered;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final nativePrinters = await Printing.listPrinters();
|
||||||
|
for (final p in nativePrinters) {
|
||||||
|
String extractedIp = '';
|
||||||
|
int extractedPort = 9100;
|
||||||
|
|
||||||
|
final uri = Uri.tryParse(p.url);
|
||||||
|
if (uri != null && uri.host.isNotEmpty) {
|
||||||
|
extractedIp = uri.host;
|
||||||
|
if (uri.port != 0 && uri.port != 631) {
|
||||||
|
extractedPort = uri.port;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Infer printer language from name/model
|
||||||
|
String lang = 'ZPL';
|
||||||
|
final nameLower = '${p.name} ${p.model ?? ''}'.toLowerCase();
|
||||||
|
if (nameLower.contains('tsc') ||
|
||||||
|
nameLower.contains('argox') ||
|
||||||
|
nameLower.contains('gprinter') ||
|
||||||
|
nameLower.contains('xprinter') ||
|
||||||
|
nameLower.contains('dymo')) {
|
||||||
|
lang = 'TSPL';
|
||||||
|
} else if (nameLower.contains('pos') ||
|
||||||
|
nameLower.contains('epson') ||
|
||||||
|
nameLower.contains('receipt') ||
|
||||||
|
nameLower.contains('star')) {
|
||||||
|
lang = 'ESCPOS';
|
||||||
|
}
|
||||||
|
|
||||||
|
discovered.add(
|
||||||
|
PrinterDevice(
|
||||||
|
id: p.url.isNotEmpty ? p.url : p.name,
|
||||||
|
name: p.name.isNotEmpty ? p.name : (p.model ?? 'Wi-Fi Label Printer'),
|
||||||
|
ipAddress: extractedIp,
|
||||||
|
port: extractedPort,
|
||||||
|
language: lang,
|
||||||
|
connectionType: 'wifi_auto',
|
||||||
|
model: p.model,
|
||||||
|
rawUrl: p.url,
|
||||||
|
isOnline: p.isAvailable,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Printer discovery error: $e');
|
||||||
|
}
|
||||||
|
|
||||||
|
return discovered;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates a label document before test printing or saving
|
||||||
|
static ValidationResult validateDocument({
|
||||||
|
required LabelDocument document,
|
||||||
|
Product? sampleProduct,
|
||||||
|
bool isPrinting = false,
|
||||||
|
}) {
|
||||||
|
final errors = <String>[];
|
||||||
|
final warnings = <String>[];
|
||||||
|
|
||||||
|
if (document.elements.isEmpty) {
|
||||||
|
errors.add('The label has no elements. Add at least one element before printing.');
|
||||||
|
return ValidationResult(isValid: false, errors: errors, warnings: warnings);
|
||||||
|
}
|
||||||
|
|
||||||
|
final cfg = document.config;
|
||||||
|
bool hasDynamic = false;
|
||||||
|
|
||||||
|
for (final elem in document.elements) {
|
||||||
|
// 1. Boundary check
|
||||||
|
if (elem.xMm + elem.widthMm > cfg.widthMm + 0.1) {
|
||||||
|
warnings.add('Element "${elem.id}" exceeds right label margin.');
|
||||||
|
}
|
||||||
|
if (elem.yMm + elem.heightMm > cfg.heightMm + 0.1) {
|
||||||
|
warnings.add('Element "${elem.id}" exceeds bottom label margin.');
|
||||||
|
}
|
||||||
|
if (elem.xMm < 0 || elem.yMm < 0) {
|
||||||
|
warnings.add('Element "${elem.id}" is placed outside the printable area.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Dynamic check
|
||||||
|
if (elem is DynamicTextElement) {
|
||||||
|
hasDynamic = true;
|
||||||
|
if (elem.fieldKey.isEmpty) {
|
||||||
|
warnings.add('Dynamic field has no product property selected.');
|
||||||
|
}
|
||||||
|
} else if (elem is BarcodeElement && elem.valueType == 'dynamic') {
|
||||||
|
hasDynamic = true;
|
||||||
|
} else if (elem is QrCodeElement && elem.valueType == 'dynamic') {
|
||||||
|
hasDynamic = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Barcode check
|
||||||
|
if (elem is BarcodeElement) {
|
||||||
|
if (elem.widthMm < 15.0) {
|
||||||
|
warnings.add('Barcode width (${elem.widthMm}mm) is very small and may fail to scan.');
|
||||||
|
}
|
||||||
|
if (elem.heightMm < 6.0) {
|
||||||
|
warnings.add('Barcode height (${elem.heightMm}mm) is low. Recommended >= 8mm.');
|
||||||
|
}
|
||||||
|
// Quiet zone check
|
||||||
|
if (elem.xMm < 1.5 || (elem.xMm + elem.widthMm > cfg.widthMm - 1.5)) {
|
||||||
|
warnings.add('Barcode quiet zone: Keep at least 2mm clearance from label edges.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamic field check when printing
|
||||||
|
if (isPrinting && hasDynamic && sampleProduct == null) {
|
||||||
|
errors.add(
|
||||||
|
'The label contains dynamic fields (e.g. {{product.name}}), but no sample product has been selected. Please select a product to test print.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ValidationResult(
|
||||||
|
isValid: errors.isEmpty,
|
||||||
|
errors: errors,
|
||||||
|
warnings: warnings,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dispatches print commands to a printer (or Web simulation bridge)
|
||||||
|
static Future<Map<String, dynamic>> sendToPrinter({
|
||||||
|
required String printerCommands,
|
||||||
|
required PrinterDevice printer,
|
||||||
|
}) async {
|
||||||
|
if (kIsWeb) {
|
||||||
|
// On Flutter Web, raw socket connections to arbitrary LAN IPs are blocked by browser sandboxing.
|
||||||
|
// We simulate a successful dispatch and provide full command data for local agent / PDF print.
|
||||||
|
await Future.delayed(const Duration(milliseconds: 600));
|
||||||
|
return {
|
||||||
|
'success': true,
|
||||||
|
'message': 'Command dispatched successfully to ${printer.name} (${printer.ipAddress.isNotEmpty ? printer.ipAddress : "Wi-Fi"})',
|
||||||
|
'commands': printerCommands,
|
||||||
|
'isWeb': true,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// On native platforms (macOS / Windows / Android / iOS)
|
||||||
|
if (printer.ipAddress.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
final socket = await Socket.connect(
|
||||||
|
printer.ipAddress,
|
||||||
|
printer.port,
|
||||||
|
timeout: const Duration(seconds: 4),
|
||||||
|
);
|
||||||
|
socket.write(printerCommands);
|
||||||
|
await socket.flush();
|
||||||
|
await socket.close();
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': true,
|
||||||
|
'message': 'Printed 1 label on ${printer.name} (${printer.ipAddress}:${printer.port})',
|
||||||
|
'commands': printerCommands,
|
||||||
|
'isWeb': false,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
'success': false,
|
||||||
|
'message': 'Could not connect to ${printer.name} at ${printer.ipAddress}:${printer.port}. Ensure your phone is connected to the same Wi-Fi as the printer.',
|
||||||
|
'commands': printerCommands,
|
||||||
|
'isWeb': false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
'success': true,
|
||||||
|
'message': 'Dispatched label to Wi-Fi printer: ${printer.name}',
|
||||||
|
'commands': printerCommands,
|
||||||
|
'isWeb': false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import '../../inventory/domain/product.dart';
|
||||||
|
import '../../inventory/domain/inventory_item.dart';
|
||||||
|
import '../domain/label_document.dart';
|
||||||
|
|
||||||
|
class TemplateDataResolver {
|
||||||
|
static final NumberFormat _currencyFormatter = NumberFormat('#,##,##0.##');
|
||||||
|
|
||||||
|
/// Resolves a single field value from a Product (and optional InventoryItem)
|
||||||
|
static String? resolveFieldValue({
|
||||||
|
required String source,
|
||||||
|
required String fieldKey,
|
||||||
|
Product? product,
|
||||||
|
InventoryItem? inventoryItem,
|
||||||
|
}) {
|
||||||
|
if (product == null && inventoryItem == null) return null;
|
||||||
|
|
||||||
|
if (source == 'product' && product != null) {
|
||||||
|
switch (fieldKey) {
|
||||||
|
case 'name':
|
||||||
|
return product.name;
|
||||||
|
case 'sku':
|
||||||
|
return product.sku ?? (inventoryItem?.tagNumber ?? 'SKU-001');
|
||||||
|
case 'barcode':
|
||||||
|
return (product.barcode != null && product.barcode!.isNotEmpty)
|
||||||
|
? product.barcode
|
||||||
|
: (inventoryItem?.tagNumber ?? product.sku ?? '8901234567890');
|
||||||
|
case 'sellingPrice':
|
||||||
|
final price = product.sellingPrice ?? product.mrp;
|
||||||
|
return price != null ? _currencyFormatter.format(price) : null;
|
||||||
|
case 'mrp':
|
||||||
|
final price = product.mrp ?? product.sellingPrice;
|
||||||
|
return price != null ? _currencyFormatter.format(price) : null;
|
||||||
|
case 'weightInG':
|
||||||
|
final wt = inventoryItem?.grossWeight ?? product.weightInG;
|
||||||
|
return wt?.toStringAsFixed(2);
|
||||||
|
case 'hsnCode':
|
||||||
|
return product.hsnCode;
|
||||||
|
case 'gstRate':
|
||||||
|
return product.gstRate != null ? product.gstRate!.toStringAsFixed(0) : '3';
|
||||||
|
case 'brandName':
|
||||||
|
return product.brandName;
|
||||||
|
case 'material':
|
||||||
|
return product.material;
|
||||||
|
case 'size':
|
||||||
|
return product.size;
|
||||||
|
case 'color':
|
||||||
|
return product.color;
|
||||||
|
case 'manufacturerCode':
|
||||||
|
return inventoryItem?.huid ?? product.manufacturerCode;
|
||||||
|
case 'purityFactor':
|
||||||
|
if (inventoryItem?.purity != null) return inventoryItem!.purity;
|
||||||
|
return product.purityFactor != null
|
||||||
|
? '${(product.purityFactor! * 100).toStringAsFixed(1)}%'
|
||||||
|
: null;
|
||||||
|
case 'currentStock':
|
||||||
|
return product.currentStock?.toString();
|
||||||
|
case 'description':
|
||||||
|
return product.description;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source == 'inventory' && inventoryItem != null) {
|
||||||
|
switch (fieldKey) {
|
||||||
|
case 'tagNumber':
|
||||||
|
return inventoryItem.tagNumber;
|
||||||
|
case 'huid':
|
||||||
|
return inventoryItem.huid;
|
||||||
|
case 'grossWeight':
|
||||||
|
return inventoryItem.grossWeight?.toStringAsFixed(3);
|
||||||
|
case 'netWeight':
|
||||||
|
return inventoryItem.netWeight?.toStringAsFixed(3);
|
||||||
|
case 'purity':
|
||||||
|
return inventoryItem.purity;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves an entire LabelDocument against sample data, returning a resolved copy
|
||||||
|
static LabelDocument resolveDocument({
|
||||||
|
required LabelDocument document,
|
||||||
|
Product? product,
|
||||||
|
InventoryItem? inventoryItem,
|
||||||
|
}) {
|
||||||
|
if (product == null && inventoryItem == null) {
|
||||||
|
return document;
|
||||||
|
}
|
||||||
|
|
||||||
|
final resolvedElements = <LabelElement>[];
|
||||||
|
|
||||||
|
for (final elem in document.elements) {
|
||||||
|
if (elem is DynamicTextElement) {
|
||||||
|
final rawVal = resolveFieldValue(
|
||||||
|
source: elem.source,
|
||||||
|
fieldKey: elem.fieldKey,
|
||||||
|
product: product,
|
||||||
|
inventoryItem: inventoryItem,
|
||||||
|
);
|
||||||
|
final formattedText = elem.formatValue(rawVal);
|
||||||
|
resolvedElements.add(
|
||||||
|
TextElement(
|
||||||
|
id: elem.id,
|
||||||
|
xMm: elem.xMm,
|
||||||
|
yMm: elem.yMm,
|
||||||
|
widthMm: elem.widthMm,
|
||||||
|
heightMm: elem.heightMm,
|
||||||
|
rotation: elem.rotation,
|
||||||
|
isLocked: elem.isLocked,
|
||||||
|
zIndex: elem.zIndex,
|
||||||
|
text: formattedText,
|
||||||
|
fontFamily: elem.fontFamily,
|
||||||
|
fontSizePt: elem.fontSizePt,
|
||||||
|
isBold: elem.isBold,
|
||||||
|
isItalic: elem.isItalic,
|
||||||
|
alignment: elem.alignment,
|
||||||
|
borderWidthMm: elem.borderWidthMm,
|
||||||
|
paddingMm: elem.paddingMm,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (elem is BarcodeElement) {
|
||||||
|
if (elem.valueType == 'dynamic') {
|
||||||
|
final resolvedVal = resolveFieldValue(
|
||||||
|
source: elem.source,
|
||||||
|
fieldKey: elem.fieldKey,
|
||||||
|
product: product,
|
||||||
|
inventoryItem: inventoryItem,
|
||||||
|
) ?? elem.staticValue;
|
||||||
|
|
||||||
|
final effectiveBarcode = (resolvedVal.isNotEmpty ? resolvedVal : (product?.sku ?? '8901234567890'))
|
||||||
|
.replaceAll(RegExp(r'[^a-zA-Z0-9\-]'), '');
|
||||||
|
|
||||||
|
resolvedElements.add(
|
||||||
|
elem.copyWithPosition(
|
||||||
|
valueType: 'static',
|
||||||
|
staticValue: effectiveBarcode.isNotEmpty ? effectiveBarcode : '8901234567890',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
resolvedElements.add(elem);
|
||||||
|
}
|
||||||
|
} else if (elem is QrCodeElement) {
|
||||||
|
if (elem.valueType == 'dynamic') {
|
||||||
|
final resolvedVal = resolveFieldValue(
|
||||||
|
source: elem.source,
|
||||||
|
fieldKey: elem.fieldKey,
|
||||||
|
product: product,
|
||||||
|
inventoryItem: inventoryItem,
|
||||||
|
) ?? elem.staticValue;
|
||||||
|
|
||||||
|
final effectiveQr = resolvedVal.isNotEmpty ? resolvedVal : (product?.sku ?? '8901234567890');
|
||||||
|
|
||||||
|
resolvedElements.add(
|
||||||
|
elem.copyWithPosition(
|
||||||
|
valueType: 'static',
|
||||||
|
staticValue: effectiveQr,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
resolvedElements.add(elem);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resolvedElements.add(elem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return document.copyWith(elements: resolvedElements);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../domain/label_document.dart';
|
||||||
|
|
||||||
|
class PrintCompiler {
|
||||||
|
/// Compiles a LabelDocument into printer commands according to the chosen language
|
||||||
|
static String compile({
|
||||||
|
required LabelDocument document,
|
||||||
|
String? overrideLanguage,
|
||||||
|
int copies = 1,
|
||||||
|
}) {
|
||||||
|
final language = (overrideLanguage ?? document.config.printerLanguage).toUpperCase();
|
||||||
|
switch (language) {
|
||||||
|
case 'TSPL':
|
||||||
|
return TsplCompiler.compile(document, copies: copies);
|
||||||
|
case 'ZPL':
|
||||||
|
default:
|
||||||
|
return ZplCompiler.compile(document, copies: copies);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ZplCompiler {
|
||||||
|
static String compile(LabelDocument doc, {int copies = 1}) {
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
final cfg = doc.config;
|
||||||
|
|
||||||
|
// Convert total web width (accounting for 1-Up, 2-Up, etc.) to printer dots
|
||||||
|
final totalWebWidthDots = cfg.totalWebWidthDots;
|
||||||
|
final heightDots = cfg.heightDots;
|
||||||
|
|
||||||
|
buffer.writeln('^XA'); // Start of Label
|
||||||
|
buffer.writeln('^PW$totalWebWidthDots'); // Print Width across entire roll
|
||||||
|
buffer.writeln('^LL$heightDots'); // Label Length
|
||||||
|
buffer.writeln('^LS0'); // Label Shift
|
||||||
|
buffer.writeln('^LH0,0'); // Label Home
|
||||||
|
buffer.writeln('^CI28'); // UTF-8 Encoding
|
||||||
|
|
||||||
|
// Sort elements by zIndex
|
||||||
|
final sortedElements = List<LabelElement>.from(doc.elements)
|
||||||
|
..sort((a, b) => a.zIndex.compareTo(b.zIndex));
|
||||||
|
|
||||||
|
// Iterate across each column in the multi-up row (1-Up, 2-Up, 3-Up)
|
||||||
|
final cols = cfg.columnsAcross.clamp(1, 4);
|
||||||
|
for (int col = 0; col < cols; col++) {
|
||||||
|
final colOffsetMm = col * (cfg.widthMm + cfg.horizontalGapMm);
|
||||||
|
final colOffsetDots = cfg.mmToDots(colOffsetMm);
|
||||||
|
|
||||||
|
for (final elem in sortedElements) {
|
||||||
|
final xDots = cfg.mmToDots(elem.xMm) + colOffsetDots;
|
||||||
|
final yDots = cfg.mmToDots(elem.yMm);
|
||||||
|
final wDots = cfg.mmToDots(elem.widthMm);
|
||||||
|
final hDots = cfg.mmToDots(elem.heightMm);
|
||||||
|
|
||||||
|
if (elem is TextElement) {
|
||||||
|
_compileText(buffer, elem, cfg, xDots, yDots, wDots);
|
||||||
|
} else if (elem is DynamicTextElement) {
|
||||||
|
// In design mode or unresolved templates, output the placeholder
|
||||||
|
final textElem = TextElement(
|
||||||
|
id: elem.id,
|
||||||
|
xMm: elem.xMm,
|
||||||
|
yMm: elem.yMm,
|
||||||
|
widthMm: elem.widthMm,
|
||||||
|
heightMm: elem.heightMm,
|
||||||
|
text: elem.placeholder,
|
||||||
|
fontFamily: elem.fontFamily,
|
||||||
|
fontSizePt: elem.fontSizePt,
|
||||||
|
isBold: elem.isBold,
|
||||||
|
isItalic: elem.isItalic,
|
||||||
|
alignment: elem.alignment,
|
||||||
|
);
|
||||||
|
_compileText(buffer, textElem, cfg, xDots, yDots, wDots);
|
||||||
|
} else if (elem is BarcodeElement) {
|
||||||
|
_compileBarcode(buffer, elem, cfg, xDots, yDots, wDots, hDots);
|
||||||
|
} else if (elem is QrCodeElement) {
|
||||||
|
_compileQrCode(buffer, elem, cfg, xDots, yDots, wDots);
|
||||||
|
} else if (elem is RectangleElement) {
|
||||||
|
_compileRectangle(buffer, elem, cfg, xDots, yDots, wDots, hDots);
|
||||||
|
} else if (elem is LineElement) {
|
||||||
|
_compileLine(buffer, elem, cfg, xDots, yDots, wDots, hDots);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (copies > 1) {
|
||||||
|
buffer.writeln('^PQ$copies');
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer.writeln('^XZ'); // End of Label
|
||||||
|
return buffer.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _compileText(
|
||||||
|
StringBuffer buf,
|
||||||
|
TextElement elem,
|
||||||
|
LabelConfiguration cfg,
|
||||||
|
int xDots,
|
||||||
|
int yDots,
|
||||||
|
int wDots,
|
||||||
|
) {
|
||||||
|
// Standard font height calculation based on point size & DPI
|
||||||
|
// 1 pt = 1/72 inch. dots = pt / 72 * DPI
|
||||||
|
final fontHeightDots = ((elem.fontSizePt / 72.0) * cfg.dpi * 1.33).round().clamp(14, 300);
|
||||||
|
final fontWidthDots = (fontHeightDots * 0.85).round();
|
||||||
|
|
||||||
|
final alignCode = switch (elem.alignment) {
|
||||||
|
TextAlign.center => 'C',
|
||||||
|
TextAlign.right => 'R',
|
||||||
|
TextAlign.justify => 'J',
|
||||||
|
_ => 'L',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Replace unicode Rupee sign with 'Rs. ' as printer internal font 0 does not have U+20B9
|
||||||
|
final printableText = elem.text.replaceAll('₹', 'Rs. ');
|
||||||
|
|
||||||
|
// Text block with field formatting
|
||||||
|
buf.writeln('^FO$xDots,$yDots');
|
||||||
|
buf.writeln('^A0N,$fontHeightDots,$fontWidthDots');
|
||||||
|
buf.writeln('^FB$wDots,2,0,$alignCode,0');
|
||||||
|
buf.writeln('^FD$printableText^FS');
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _compileBarcode(
|
||||||
|
StringBuffer buf,
|
||||||
|
BarcodeElement elem,
|
||||||
|
LabelConfiguration cfg,
|
||||||
|
int xDots,
|
||||||
|
int yDots,
|
||||||
|
int wDots,
|
||||||
|
int hDots,
|
||||||
|
) {
|
||||||
|
final barcodeVal = (elem.valueType == 'dynamic' && elem.staticValue.isEmpty)
|
||||||
|
? elem.placeholder
|
||||||
|
: elem.staticValue;
|
||||||
|
|
||||||
|
// Module width: 2 dots for 203 DPI, 3 dots for 300 DPI (Citizen CL-E331) for readable scanning
|
||||||
|
final moduleWidth = (cfg.dpi >= 300) ? 3 : 2;
|
||||||
|
final barHeight = (hDots * (elem.showText ? 0.82 : 0.95)).round().clamp(24, 600);
|
||||||
|
final showTextChar = elem.showText ? 'Y' : 'N';
|
||||||
|
|
||||||
|
buf.writeln('^FO$xDots,$yDots');
|
||||||
|
buf.writeln('^BY$moduleWidth,3,$barHeight');
|
||||||
|
|
||||||
|
switch (elem.barcodeType) {
|
||||||
|
case BarcodeType.ean13:
|
||||||
|
buf.writeln('^BEN,$barHeight,$showTextChar,N');
|
||||||
|
break;
|
||||||
|
case BarcodeType.code39:
|
||||||
|
buf.writeln('^B3N,N,$barHeight,$showTextChar,N');
|
||||||
|
break;
|
||||||
|
case BarcodeType.upca:
|
||||||
|
buf.writeln('^BUN,$barHeight,$showTextChar,N,N');
|
||||||
|
break;
|
||||||
|
case BarcodeType.code128:
|
||||||
|
default:
|
||||||
|
buf.writeln('^BCN,$barHeight,$showTextChar,N,N');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
buf.writeln('^FD$barcodeVal^FS');
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _compileQrCode(
|
||||||
|
StringBuffer buf,
|
||||||
|
QrCodeElement elem,
|
||||||
|
LabelConfiguration cfg,
|
||||||
|
int xDots,
|
||||||
|
int yDots,
|
||||||
|
int wDots,
|
||||||
|
) {
|
||||||
|
final qrVal = (elem.valueType == 'dynamic' && elem.staticValue.isEmpty)
|
||||||
|
? elem.placeholder
|
||||||
|
: elem.staticValue;
|
||||||
|
// Magnification factor (1 to 10)
|
||||||
|
final mag = (wDots / 30).round().clamp(2, 8);
|
||||||
|
|
||||||
|
buf.writeln('^FO$xDots,$yDots');
|
||||||
|
buf.writeln('^BQN,2,$mag');
|
||||||
|
buf.writeln('^FDQA,$qrVal^FS');
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _compileRectangle(
|
||||||
|
StringBuffer buf,
|
||||||
|
RectangleElement elem,
|
||||||
|
LabelConfiguration cfg,
|
||||||
|
int xDots,
|
||||||
|
int yDots,
|
||||||
|
int wDots,
|
||||||
|
int hDots,
|
||||||
|
) {
|
||||||
|
final borderDots = cfg.mmToDots(elem.borderWidthMm).clamp(1, 40);
|
||||||
|
final cornerDots = cfg.mmToDots(elem.cornerRadiusMm).clamp(0, 8);
|
||||||
|
buf.writeln('^FO$xDots,$yDots^GB$wDots,$hDots,$borderDots,B,$cornerDots^FS');
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _compileLine(
|
||||||
|
StringBuffer buf,
|
||||||
|
LineElement elem,
|
||||||
|
LabelConfiguration cfg,
|
||||||
|
int xDots,
|
||||||
|
int yDots,
|
||||||
|
int wDots,
|
||||||
|
int hDots,
|
||||||
|
) {
|
||||||
|
final thickDots = cfg.mmToDots(elem.thicknessMm).clamp(1, 30);
|
||||||
|
if (elem.isVertical) {
|
||||||
|
buf.writeln('^FO$xDots,$yDots^GB$thickDots,$hDots,$thickDots^FS');
|
||||||
|
} else {
|
||||||
|
buf.writeln('^FO$xDots,$yDots^GB$wDots,$thickDots,$thickDots^FS');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TsplCompiler {
|
||||||
|
static String compile(LabelDocument doc, {int copies = 1}) {
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
final cfg = doc.config;
|
||||||
|
final cols = cfg.columnsAcross.clamp(1, 4);
|
||||||
|
|
||||||
|
buffer.writeln('SIZE ${cfg.totalWebWidthMm} mm, ${cfg.heightMm} mm');
|
||||||
|
buffer.writeln('GAP ${cfg.verticalGapMm.toStringAsFixed(cfg.verticalGapMm % 1 == 0 ? 0 : 1)} mm, 0 mm');
|
||||||
|
buffer.writeln('DIRECTION 1');
|
||||||
|
buffer.writeln('CLS');
|
||||||
|
|
||||||
|
for (int col = 0; col < cols; col++) {
|
||||||
|
final colOffsetMm = col * (cfg.widthMm + cfg.horizontalGapMm);
|
||||||
|
final colOffsetDots = cfg.mmToDots(colOffsetMm);
|
||||||
|
|
||||||
|
for (final elem in doc.elements) {
|
||||||
|
final xDots = cfg.mmToDots(elem.xMm) + colOffsetDots;
|
||||||
|
final yDots = cfg.mmToDots(elem.yMm);
|
||||||
|
|
||||||
|
if (elem is TextElement) {
|
||||||
|
buffer.writeln('TEXT $xDots,$yDots,"3",0,1,1,"${elem.text}"');
|
||||||
|
} else if (elem is BarcodeElement) {
|
||||||
|
final val = elem.valueType == 'dynamic' ? elem.placeholder : elem.staticValue;
|
||||||
|
final hDots = cfg.mmToDots(elem.heightMm);
|
||||||
|
buffer.writeln('BARCODE $xDots,$yDots,"128",$hDots,${elem.showText ? 1 : 0},0,2,2,"$val"');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer.writeln('PRINT $copies,1');
|
||||||
|
return buffer.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import '../widgets/business_profile_form_sheet.dart';
|
|||||||
import '../../../inventory/providers/products_provider.dart';
|
import '../../../inventory/providers/products_provider.dart';
|
||||||
import '../../../vendor/presentation/vendors_list_screen.dart';
|
import '../../../vendor/presentation/vendors_list_screen.dart';
|
||||||
import '../../../vendor/presentation/purchase_orders_list_screen.dart';
|
import '../../../vendor/presentation/purchase_orders_list_screen.dart';
|
||||||
|
import '../widgets/utilities_section.dart';
|
||||||
|
|
||||||
class BusinessHubScreen extends ConsumerWidget {
|
class BusinessHubScreen extends ConsumerWidget {
|
||||||
const BusinessHubScreen({super.key});
|
const BusinessHubScreen({super.key});
|
||||||
@@ -233,6 +234,8 @@ class BusinessHubScreen extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
const UtilitiesSection(),
|
||||||
if (showInventory) ...[
|
if (showInventory) ...[
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
import '../../../barcode_designer/presentation/barcode_designer_screen.dart';
|
||||||
|
import '../../../barcode_designer/presentation/saved_templates_screen.dart';
|
||||||
|
|
||||||
|
class UtilityItemData {
|
||||||
|
final String title;
|
||||||
|
final String description;
|
||||||
|
final IconData icon;
|
||||||
|
final Color color;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
final String? badgeText;
|
||||||
|
final List<UtilityQuickAction>? actions;
|
||||||
|
|
||||||
|
const UtilityItemData({
|
||||||
|
required this.title,
|
||||||
|
required this.description,
|
||||||
|
required this.icon,
|
||||||
|
required this.color,
|
||||||
|
this.onTap,
|
||||||
|
this.badgeText,
|
||||||
|
this.actions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class UtilityQuickAction {
|
||||||
|
final String label;
|
||||||
|
final IconData icon;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const UtilityQuickAction({
|
||||||
|
required this.label,
|
||||||
|
required this.icon,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class UtilitiesSection extends StatelessWidget {
|
||||||
|
final bool isDesktop;
|
||||||
|
|
||||||
|
const UtilitiesSection({super.key, this.isDesktop = true});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
final List<UtilityItemData> utilities = [
|
||||||
|
UtilityItemData(
|
||||||
|
title: 'Barcode & Label Designer',
|
||||||
|
description: 'Design custom barcode & jewellery tags with dynamic product fields, live preview & direct printer testing.',
|
||||||
|
icon: LucideIcons.barcode,
|
||||||
|
color: Colors.indigo,
|
||||||
|
onTap: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (_) => const BarcodeDesignerScreen()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
badgeText: 'Active',
|
||||||
|
actions: [
|
||||||
|
UtilityQuickAction(
|
||||||
|
label: 'Open Designer',
|
||||||
|
icon: LucideIcons.draftingCompass,
|
||||||
|
onTap: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (_) => const BarcodeDesignerScreen()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
UtilityQuickAction(
|
||||||
|
label: 'Saved Templates',
|
||||||
|
icon: LucideIcons.folderOpen,
|
||||||
|
onTap: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (_) => const SavedTemplatesScreen()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const UtilityItemData(
|
||||||
|
title: 'Bulk Price & Rate Updater',
|
||||||
|
description: 'Automate mass price updates across inventory categories and daily commodity rate recalculations.',
|
||||||
|
icon: LucideIcons.trendingUp,
|
||||||
|
color: Colors.teal,
|
||||||
|
badgeText: 'Coming Soon',
|
||||||
|
),
|
||||||
|
const UtilityItemData(
|
||||||
|
title: 'Data Export & Compliance',
|
||||||
|
description: 'Export audit-ready GST reports, physical inventory verification sheets, and tally XML packages.',
|
||||||
|
icon: LucideIcons.fileSpreadsheet,
|
||||||
|
color: Colors.amber,
|
||||||
|
badgeText: 'Coming Soon',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.indigo.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: const Icon(LucideIcons.sparkles, color: Colors.indigo, size: 18),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text(
|
||||||
|
'Utilities & Tools',
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.titleLarge
|
||||||
|
?.copyWith(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
|
||||||
|
// Responsive Cards Grid
|
||||||
|
LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final double cardWidth = constraints.maxWidth >= 900
|
||||||
|
? (constraints.maxWidth - 32) / 3
|
||||||
|
: (constraints.maxWidth >= 600 ? (constraints.maxWidth - 16) / 2 : constraints.maxWidth);
|
||||||
|
|
||||||
|
return Wrap(
|
||||||
|
spacing: 16,
|
||||||
|
runSpacing: 16,
|
||||||
|
children: utilities.map((util) {
|
||||||
|
return SizedBox(
|
||||||
|
width: cardWidth,
|
||||||
|
child: _buildUtilityCard(context, util, isDark),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildUtilityCard(BuildContext context, UtilityItemData util, bool isDark) {
|
||||||
|
final bool isAvailable = util.onTap != null;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isDark ? const Color(0xFF1E293B) : Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(
|
||||||
|
color: isDark ? Colors.white10 : Colors.grey.shade200,
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.04),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: util.onTap,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: util.color.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Icon(util.icon, color: util.color, size: 22),
|
||||||
|
),
|
||||||
|
if (util.badgeText != null)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isAvailable
|
||||||
|
? Colors.green.withValues(alpha: 0.12)
|
||||||
|
: Colors.grey.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
util.badgeText!,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: isAvailable ? Colors.green : Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
util.title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 15,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
util.description,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey.shade500,
|
||||||
|
height: 1.35,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (util.actions != null && util.actions!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
const Divider(height: 1),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: util.actions!.map((action) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 8.0),
|
||||||
|
child: TextButton.icon(
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||||
|
textStyle: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
icon: Icon(action.icon, size: 14),
|
||||||
|
label: Text(action.label),
|
||||||
|
onPressed: action.onTap,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ class Product {
|
|||||||
final String? sku;
|
final String? sku;
|
||||||
final String? barcode;
|
final String? barcode;
|
||||||
final String? description;
|
final String? description;
|
||||||
|
final double? mrp;
|
||||||
final double? sellingPrice;
|
final double? sellingPrice;
|
||||||
final double? gstRate;
|
final double? gstRate;
|
||||||
final String? dimensions;
|
final String? dimensions;
|
||||||
@@ -60,6 +61,7 @@ class Product {
|
|||||||
this.sku,
|
this.sku,
|
||||||
this.barcode,
|
this.barcode,
|
||||||
this.description,
|
this.description,
|
||||||
|
this.mrp,
|
||||||
this.sellingPrice,
|
this.sellingPrice,
|
||||||
this.gstRate,
|
this.gstRate,
|
||||||
this.dimensions,
|
this.dimensions,
|
||||||
@@ -116,7 +118,12 @@ class Product {
|
|||||||
sku: json['sku'],
|
sku: json['sku'],
|
||||||
barcode: json['barcode'],
|
barcode: json['barcode'],
|
||||||
description: json['description'],
|
description: json['description'],
|
||||||
sellingPrice: (json['sellingPrice'] ?? json['selling_price'] as num?)?.toDouble(),
|
mrp: json['mrp'] != null
|
||||||
|
? double.tryParse(json['mrp'].toString())
|
||||||
|
: (json['mrp_price'] != null ? double.tryParse(json['mrp_price'].toString()) : null),
|
||||||
|
sellingPrice: json['sellingPrice'] != null
|
||||||
|
? double.tryParse(json['sellingPrice'].toString())
|
||||||
|
: (json['selling_price'] != null ? double.tryParse(json['selling_price'].toString()) : null),
|
||||||
gstRate: (json['gstRate'] ?? json['gst_rate'] as num?)?.toDouble(),
|
gstRate: (json['gstRate'] ?? json['gst_rate'] as num?)?.toDouble(),
|
||||||
dimensions: json['dimensions'],
|
dimensions: json['dimensions'],
|
||||||
color: json['color'],
|
color: json['color'],
|
||||||
@@ -172,6 +179,7 @@ class Product {
|
|||||||
'sku': sku,
|
'sku': sku,
|
||||||
'barcode': barcode,
|
'barcode': barcode,
|
||||||
'description': description,
|
'description': description,
|
||||||
|
'mrp': mrp,
|
||||||
'sellingPrice': sellingPrice,
|
'sellingPrice': sellingPrice,
|
||||||
'gstRate': gstRate,
|
'gstRate': gstRate,
|
||||||
'dimensions': dimensions,
|
'dimensions': dimensions,
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
|||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
final _hsnController = TextEditingController();
|
final _hsnController = TextEditingController();
|
||||||
final _gstController = TextEditingController();
|
final _gstController = TextEditingController();
|
||||||
|
final _mrpController = TextEditingController();
|
||||||
|
final _sellingPriceController = TextEditingController();
|
||||||
final _makingChargesController = TextEditingController();
|
final _makingChargesController = TextEditingController();
|
||||||
String _makingChargesType = 'PER_GRAM';
|
String _makingChargesType = 'PER_GRAM';
|
||||||
|
|
||||||
@@ -83,6 +85,8 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
|||||||
_hsnController.text = p.hsnCode ?? '';
|
_hsnController.text = p.hsnCode ?? '';
|
||||||
_uomId = p.uomId;
|
_uomId = p.uomId;
|
||||||
_gstController.text = p.gstRate != null && p.gstRate! > 0 ? p.gstRate.toString() : '';
|
_gstController.text = p.gstRate != null && p.gstRate! > 0 ? p.gstRate.toString() : '';
|
||||||
|
_mrpController.text = p.mrp != null && p.mrp! > 0 ? p.mrp.toString() : '';
|
||||||
|
_sellingPriceController.text = p.sellingPrice != null && p.sellingPrice! > 0 ? p.sellingPrice.toString() : '';
|
||||||
_makingChargesController.text = p.makingCharges != null && p.makingCharges! > 0 ? p.makingCharges!.toString() : '';
|
_makingChargesController.text = p.makingCharges != null && p.makingCharges! > 0 ? p.makingCharges!.toString() : '';
|
||||||
_makingChargesType = p.makingChargesType ?? 'PER_GRAM';
|
_makingChargesType = p.makingChargesType ?? 'PER_GRAM';
|
||||||
|
|
||||||
@@ -132,6 +136,8 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_hsnController.dispose();
|
_hsnController.dispose();
|
||||||
_gstController.dispose();
|
_gstController.dispose();
|
||||||
|
_mrpController.dispose();
|
||||||
|
_sellingPriceController.dispose();
|
||||||
_makingChargesController.dispose();
|
_makingChargesController.dispose();
|
||||||
_colorController.dispose();
|
_colorController.dispose();
|
||||||
_manufacturerCodeController.dispose();
|
_manufacturerCodeController.dispose();
|
||||||
@@ -252,6 +258,8 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
|||||||
categoryId: _selectedCategory?.id,
|
categoryId: _selectedCategory?.id,
|
||||||
uomId: _uomId,
|
uomId: _uomId,
|
||||||
color: _colorController.text.isNotEmpty ? _colorController.text.trim() : null,
|
color: _colorController.text.isNotEmpty ? _colorController.text.trim() : null,
|
||||||
|
mrp: double.tryParse(_mrpController.text),
|
||||||
|
sellingPrice: double.tryParse(_sellingPriceController.text),
|
||||||
gstRate: double.tryParse(_gstController.text) ?? 0,
|
gstRate: double.tryParse(_gstController.text) ?? 0,
|
||||||
makingCharges: double.tryParse(_makingChargesController.text) ?? 0.0,
|
makingCharges: double.tryParse(_makingChargesController.text) ?? 0.0,
|
||||||
makingChargesType: _makingChargesType,
|
makingChargesType: _makingChargesType,
|
||||||
@@ -457,6 +465,34 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
|||||||
|
|
||||||
const Text('Pricing & Taxes', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
const Text('Pricing & Taxes', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _mrpController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'MRP (₹)',
|
||||||
|
hintText: 'e.g. 1999',
|
||||||
|
prefixIcon: Icon(LucideIcons.tag),
|
||||||
|
),
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _sellingPriceController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Selling Price (₹)',
|
||||||
|
hintText: 'e.g. 1499',
|
||||||
|
prefixIcon: Icon(LucideIcons.badgePercent),
|
||||||
|
),
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|||||||
@@ -148,6 +148,10 @@ class ProductDetailScreen extends ConsumerWidget {
|
|||||||
if (uom != null)
|
if (uom != null)
|
||||||
_buildDetailRow("Unit of Measure", uom.displayName),
|
_buildDetailRow("Unit of Measure", uom.displayName),
|
||||||
_buildDetailRow("HSN Code", p.hsnCode ?? "-"),
|
_buildDetailRow("HSN Code", p.hsnCode ?? "-"),
|
||||||
|
if (p.mrp != null && p.mrp! > 0)
|
||||||
|
_buildDetailRow("MRP", "₹${p.mrp!.toStringAsFixed(2)}"),
|
||||||
|
if (p.sellingPrice != null && p.sellingPrice! > 0)
|
||||||
|
_buildDetailRow("Selling Price", "₹${p.sellingPrice!.toStringAsFixed(2)}"),
|
||||||
_buildDetailRow(
|
_buildDetailRow(
|
||||||
"GST Rate",
|
"GST Rate",
|
||||||
"${p.gstRate?.toStringAsFixed(1) ?? '0'}%",
|
"${p.gstRate?.toStringAsFixed(1) ?? '0'}%",
|
||||||
|
|||||||
@@ -65,6 +65,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.9"
|
version: "2.2.9"
|
||||||
|
barcode_widget:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: barcode_widget
|
||||||
|
sha256: "6f2c5b08659b1a5f4d88d183e6007133ea2f96e50e7b8bb628f03266c3931427"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.4"
|
||||||
bidi:
|
bidi:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ dependencies:
|
|||||||
intl_phone_field: ^3.2.0
|
intl_phone_field: ^3.2.0
|
||||||
url_launcher: ^6.3.2
|
url_launcher: ^6.3.2
|
||||||
lucide_icons_flutter: ^3.1.17
|
lucide_icons_flutter: ^3.1.17
|
||||||
|
barcode_widget: ^2.0.4
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Reference in New Issue
Block a user