diff --git a/.DS_Store b/.DS_Store index 1d8585b..e97cfcc 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/kifi-api/src/main/java/com/kifi/api/controller/barcode/BarcodeLabelTemplateController.java b/kifi-api/src/main/java/com/kifi/api/controller/barcode/BarcodeLabelTemplateController.java new file mode 100644 index 0000000..8f08a73 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/controller/barcode/BarcodeLabelTemplateController.java @@ -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 getTemplates(Authentication authentication) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return templateService.getActiveTemplates(userId); + } + + @GetMapping("/{id}") + public Mono getTemplateById( + Authentication authentication, + @PathVariable Long id) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return templateService.getTemplateById(userId, id); + } + + @PostMapping + public Mono createTemplate( + Authentication authentication, + @RequestBody BarcodeLabelTemplate template) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return templateService.createTemplate(userId, template); + } + + @PutMapping("/{id}") + public Mono 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 deleteTemplate( + Authentication authentication, + @PathVariable Long id) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return templateService.deleteTemplate(userId, id); + } +} diff --git a/kifi-api/src/main/java/com/kifi/api/entity/barcode/BarcodeLabelTemplate.java b/kifi-api/src/main/java/com/kifi/api/entity/barcode/BarcodeLabelTemplate.java new file mode 100644 index 0000000..98df375 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/entity/barcode/BarcodeLabelTemplate.java @@ -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; +} diff --git a/kifi-api/src/main/java/com/kifi/api/entity/inventory/Product.java b/kifi-api/src/main/java/com/kifi/api/entity/inventory/Product.java index fdc5e59..265de2e 100644 --- a/kifi-api/src/main/java/com/kifi/api/entity/inventory/Product.java +++ b/kifi-api/src/main/java/com/kifi/api/entity/inventory/Product.java @@ -29,6 +29,7 @@ public class Product { private String barcode; private String hsnCode; private String description; + private BigDecimal mrp; private BigDecimal sellingPrice; private BigDecimal gstRate; private String dimensions; diff --git a/kifi-api/src/main/java/com/kifi/api/repository/barcode/BarcodeLabelTemplateRepository.java b/kifi-api/src/main/java/com/kifi/api/repository/barcode/BarcodeLabelTemplateRepository.java new file mode 100644 index 0000000..e81c609 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/repository/barcode/BarcodeLabelTemplateRepository.java @@ -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 { + Flux findByUserIdAndIsActiveTrueOrderByUpdatedAtDesc(Long userId); + + Flux findByUserIdOrderByUpdatedAtDesc(Long userId); + + Mono findByIdAndUserId(Long id, Long userId); +} diff --git a/kifi-api/src/main/java/com/kifi/api/service/barcode/BarcodeLabelTemplateService.java b/kifi-api/src/main/java/com/kifi/api/service/barcode/BarcodeLabelTemplateService.java new file mode 100644 index 0000000..86c5688 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/service/barcode/BarcodeLabelTemplateService.java @@ -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 getActiveTemplates(Long userId) { + return templateRepository.findByUserIdAndIsActiveTrueOrderByUpdatedAtDesc(userId); + } + + public Mono getTemplateById(Long userId, Long id) { + return templateRepository.findByIdAndUserId(id, userId); + } + + public Mono 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 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 deleteTemplate(Long userId, Long id) { + return templateRepository.findByIdAndUserId(id, userId) + .flatMap(existing -> { + existing.setIsActive(false); + existing.setUpdatedAt(LocalDateTime.now()); + return templateRepository.save(existing); + }) + .then(); + } +} diff --git a/kifi-api/src/main/java/com/kifi/api/service/inventory/ProductService.java b/kifi-api/src/main/java/com/kifi/api/service/inventory/ProductService.java index 24919d8..f870e92 100644 --- a/kifi-api/src/main/java/com/kifi/api/service/inventory/ProductService.java +++ b/kifi-api/src/main/java/com/kifi/api/service/inventory/ProductService.java @@ -96,6 +96,7 @@ public class ProductService { existingProduct.setSku(updatedProduct.getSku()); existingProduct.setCategoryId(updatedProduct.getCategoryId()); existingProduct.setUomId(updatedProduct.getUomId()); + existingProduct.setMrp(updatedProduct.getMrp()); existingProduct.setSellingPrice(updatedProduct.getSellingPrice()); existingProduct.setGstRate(updatedProduct.getGstRate()); existingProduct.setHsnCode(updatedProduct.getHsnCode()); diff --git a/kifi-api/src/main/resources/schema.sql b/kifi-api/src/main/resources/schema.sql index 985bd09..535b02b 100644 --- a/kifi-api/src/main/resources/schema.sql +++ b/kifi-api/src/main/resources/schema.sql @@ -244,6 +244,7 @@ CREATE TABLE IF NOT EXISTS products ( barcode VARCHAR(100), description TEXT, purchase_price DECIMAL(15, 2), + mrp DECIMAL(15, 2), selling_price DECIMAL(15, 2), gst_rate DECIMAL(5, 2), dimensions VARCHAR(100), @@ -600,3 +601,25 @@ CREATE TABLE IF NOT EXISTS project_task_comments ( images TEXT, 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); + diff --git a/kifi-app/lib/core/widgets/smart_search_dropdown.dart b/kifi-app/lib/core/widgets/smart_search_dropdown.dart index 58c7821..9c61f4c 100644 --- a/kifi-app/lib/core/widgets/smart_search_dropdown.dart +++ b/kifi-app/lib/core/widgets/smart_search_dropdown.dart @@ -63,10 +63,13 @@ class _SmartSearchDropdownState extends State> { _filterItems(_controller.text); } if (widget.value != oldWidget.value) { - if (widget.value != null) { - _controller.text = widget.itemAsString(widget.value as T); - } else { - _controller.text = ''; + final newText = widget.value != null ? widget.itemAsString(widget.value as T) : ''; + if (_controller.text != newText) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _controller.text != newText) { + _controller.text = newText; + } + }); } } } diff --git a/kifi-app/lib/features/barcode_designer/domain/barcode_template.dart b/kifi-app/lib/features/barcode_designer/domain/barcode_template.dart new file mode 100644 index 0000000..ca94bce --- /dev/null +++ b/kifi-app/lib/features/barcode_designer/domain/barcode_template.dart @@ -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 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 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; + 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(), + ); + } +} diff --git a/kifi-app/lib/features/barcode_designer/domain/dynamic_field_definition.dart b/kifi-app/lib/features/barcode_designer/domain/dynamic_field_definition.dart new file mode 100644 index 0000000..167e56d --- /dev/null +++ b/kifi-app/lib/features/barcode_designer/domain/dynamic_field_definition.dart @@ -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 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; + } +} diff --git a/kifi-app/lib/features/barcode_designer/domain/label_document.dart b/kifi-app/lib/features/barcode_designer/domain/label_document.dart new file mode 100644 index 0000000..3696c36 --- /dev/null +++ b/kifi-app/lib/features/barcode_designer/domain/label_document.dart @@ -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 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 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 toJson(); + + static LabelElement fromJson(Map 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 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 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 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 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 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 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 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 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 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 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 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 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 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? 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 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 json, {String? id, String? name, String? description}) { + final labelConfig = json['label'] != null + ? LabelConfiguration.fromJson(json['label'] as Map) + : const LabelConfiguration(); + + final rawElements = json['elements'] as List? ?? []; + final elementsList = rawElements + .map((e) => LabelElement.fromJson(e as Map)) + .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, + ), + ], + ); + } +} diff --git a/kifi-app/lib/features/barcode_designer/presentation/barcode_designer_screen.dart b/kifi-app/lib/features/barcode_designer/presentation/barcode_designer_screen.dart new file mode 100644 index 0000000..6a1558b --- /dev/null +++ b/kifi-app/lib/features/barcode_designer/presentation/barcode_designer_screen.dart @@ -0,0 +1,1969 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../domain/label_document.dart'; +import '../domain/dynamic_field_definition.dart'; +import '../domain/barcode_template.dart'; +import '../providers/barcode_designer_provider.dart'; +import '../providers/barcode_templates_provider.dart'; +import 'widgets/canvas_ruler.dart'; +import 'widgets/designer_toolbox.dart'; +import 'widgets/product_variables_panel.dart'; +import 'widgets/element_properties_panel.dart'; +import 'widgets/printer_command_preview.dart'; +import 'widgets/test_print_modal.dart'; +import 'widgets/product_picker_dialog.dart'; +import '../../inventory/domain/product.dart'; +import '../../inventory/domain/inventory_item.dart'; +import '../../inventory/providers/products_provider.dart'; +import '../services/template_data_resolver.dart'; +import 'package:barcode_widget/barcode_widget.dart' as bc; +import 'saved_templates_screen.dart'; + +class BarcodeDesignerScreen extends ConsumerStatefulWidget { + final BarcodeTemplate? initialTemplate; + + const BarcodeDesignerScreen({super.key, this.initialTemplate}); + + @override + ConsumerState createState() => _BarcodeDesignerScreenState(); +} + +class _BarcodeDesignerScreenState extends ConsumerState { + int _leftTabIndex = 0; // 0: Toolbox, 1: Product Fields + bool _isSaving = false; + bool _isMobileBottomPanelExpanded = true; + late final FocusNode _canvasFocusNode; + + @override + void initState() { + super.initState(); + _canvasFocusNode = FocusNode(debugLabel: 'BarcodeDesignerCanvas'); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (widget.initialTemplate != null) { + final doc = widget.initialTemplate!.toLabelDocument(); + ref.read(barcodeDesignerProvider.notifier).loadDocument(doc, widget.initialTemplate!.id); + } + }); + } + + @override + void dispose() { + _canvasFocusNode.dispose(); + super.dispose(); + } + + /// Returns true if the user currently has focus inside any text input field + /// so that typing, deleting text, or backspacing is NOT intercepted by canvas shortcuts. + bool _isUserTyping() { + final primaryFocus = FocusManager.instance.primaryFocus; + if (primaryFocus == null) return false; + if (primaryFocus == _canvasFocusNode) return false; + + final context = primaryFocus.context; + if (context != null && context.mounted) { + if (context.widget is EditableText) return true; + if (context.findAncestorWidgetOfExactType() != null) return true; + if (context.findAncestorStateOfType() != null) return true; + if (context.findAncestorWidgetOfExactType() != null) return true; + if (context.findAncestorWidgetOfExactType() != null) return true; + final renderObject = context.findRenderObject(); + if (renderObject != null && renderObject.runtimeType.toString().contains('RenderEditable')) { + return true; + } + } + + final debugLabel = primaryFocus.debugLabel?.toLowerCase() ?? ''; + if (debugLabel.contains('editabletext') || debugLabel.contains('textfield')) { + return true; + } + + return false; + } + + // Pixels per millimeter based on zoom level + double _getScale(double zoom) => 3.2 * zoom; + + @override + Widget build(BuildContext context) { + final state = ref.watch(barcodeDesignerProvider); + final notifier = ref.read(barcodeDesignerProvider.notifier); + final isDark = Theme.of(context).brightness == Brightness.dark; + + // Keep sampleProduct in sync with the live products list in case it was updated + final products = ref.watch(productsProvider).value ?? []; + final currentSampleProduct = state.sampleProduct != null + ? (products.where((p) => p.id == state.sampleProduct!.id).firstOrNull ?? state.sampleProduct) + : null; + + ref.listen(productsProvider, (prev, next) { + final list = next.value; + if (list != null && state.sampleProduct != null) { + final updatedProd = list.where((p) => p.id == state.sampleProduct!.id).firstOrNull; + if (updatedProd != null && + (updatedProd.mrp != state.sampleProduct!.mrp || + updatedProd.sellingPrice != state.sampleProduct!.sellingPrice || + updatedProd.name != state.sampleProduct!.name || + updatedProd.barcode != state.sampleProduct!.barcode)) { + notifier.setSampleProduct(updatedProd, state.sampleInventoryItem); + } + } + }); + + final doc = state.document; + final cfg = doc.config; + final scale = _getScale(state.zoom); + + // Active elements to render (resolved in preview mode using live product data) + final renderDoc = state.isPreviewMode + ? (currentSampleProduct != null + ? TemplateDataResolver.resolveDocument( + document: state.document, + product: currentSampleProduct, + inventoryItem: state.sampleInventoryItem, + ) + : state.resolvedDocument) + : state.document; + + return Scaffold( + backgroundColor: isDark ? const Color(0xFF090D16) : const Color(0xFFF8FAFC), + appBar: _buildTopAppBar(context, state, notifier, isDark), + body: Focus( + focusNode: _canvasFocusNode, + autofocus: true, + onKeyEvent: (node, event) { + if (event is KeyDownEvent || event is KeyRepeatEvent) { + // When user is typing inside any text input (e.g. properties panel), + // ignore canvas shortcuts so Backspace/Delete/Arrows edit text normally. + if (_isUserTyping()) { + return KeyEventResult.ignored; + } + + final isDeleteKey = event.logicalKey == LogicalKeyboardKey.delete || + event.logicalKey == LogicalKeyboardKey.backspace || + event.physicalKey == PhysicalKeyboardKey.delete || + event.physicalKey == PhysicalKeyboardKey.backspace; + + if (isDeleteKey) { + if (event is KeyDownEvent && state.selectedElementId != null) { + if (state.selectedElement?.isLocked == true) { + return KeyEventResult.handled; + } + notifier.deleteElement(state.selectedElementId!); + return KeyEventResult.handled; + } + } + + if (state.selectedElement != null) { + final elem = state.selectedElement!; + final step = HardwareKeyboard.instance.isShiftPressed ? 2.0 : 0.5; + + if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + notifier.setElementPosition(elem.id, elem.xMm - step, elem.yMm); + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) { + notifier.setElementPosition(elem.id, elem.xMm + step, elem.yMm); + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) { + notifier.setElementPosition(elem.id, elem.xMm, elem.yMm - step); + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + notifier.setElementPosition(elem.id, elem.xMm, elem.yMm + step); + return KeyEventResult.handled; + } + } + + if (event.logicalKey == LogicalKeyboardKey.escape) { + notifier.selectElement(null); + return KeyEventResult.handled; + } + + if ((HardwareKeyboard.instance.isControlPressed || HardwareKeyboard.instance.isMetaPressed) && + event.logicalKey == LogicalKeyboardKey.keyD) { + if (event is KeyDownEvent && state.selectedElementId != null) { + notifier.duplicateElement(state.selectedElementId!); + return KeyEventResult.handled; + } + } + + if ((HardwareKeyboard.instance.isControlPressed || HardwareKeyboard.instance.isMetaPressed) && + event.logicalKey == LogicalKeyboardKey.keyZ) { + if (event is KeyDownEvent) { + if (HardwareKeyboard.instance.isShiftPressed) { + notifier.redo(); + } else { + notifier.undo(); + } + return KeyEventResult.handled; + } + } + + // Bring to Front (]) and Send to Back ([) shortcuts + if (event is KeyDownEvent && state.selectedElementId != null) { + if (event.logicalKey == LogicalKeyboardKey.bracketRight) { + notifier.bringToFront(state.selectedElementId!); + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.bracketLeft) { + notifier.sendToBack(state.selectedElementId!); + return KeyEventResult.handled; + } + } + } + return KeyEventResult.ignored; + }, + child: LayoutBuilder( + builder: (context, constraints) { + final isDesktop = constraints.maxWidth >= 960; + + if (!isDesktop) { + return _buildMobileLayout(context, state, notifier, renderDoc, scale, isDark, currentSampleProduct); + } + + return Column( + children: [ + Expanded( + child: Row( + children: [ + // Left Panel (Toolbox & Variables) + SizedBox( + width: 270, + child: Container( + decoration: BoxDecoration( + color: isDark ? const Color(0xFF111827) : Colors.white, + border: Border( + right: BorderSide( + color: isDark ? Colors.white10 : Colors.grey.shade200, + ), + ), + ), + child: Column( + children: [ + // Tab selector + Padding( + padding: const EdgeInsets.fromLTRB(12, 10, 12, 4), + child: SegmentedButton( + segments: const [ + ButtonSegment( + value: 0, + label: Text('Tools', style: TextStyle(fontSize: 12)), + icon: Icon(LucideIcons.wrench, size: 14), + ), + ButtonSegment( + value: 1, + label: Text('Variables', style: TextStyle(fontSize: 12)), + icon: Icon(LucideIcons.variable, size: 14), + ), + ], + selected: {_leftTabIndex}, + onSelectionChanged: (set) => setState(() => _leftTabIndex = set.first), + ), + ), + const Divider(height: 12), + Expanded( + child: _leftTabIndex == 0 + ? DesignerToolbox( + onAddElement: (type) => _addNewElement(type, notifier, cfg), + ) + : ProductVariablesPanel( + onAddField: (field) { + notifier.addBoundFieldElement( + fieldDef: field, + dropXMm: 4.0, + dropYMm: 6.0, + ); + }, + ), + ), + ], + ), + ), + ), + + // Center Interactive Canvas + Expanded( + child: _buildCenterCanvasArea( + context, + state, + notifier, + renderDoc, + scale, + isDark, + currentSampleProduct, + ), + ), + + // Right Properties Panel + SizedBox( + width: 290, + child: ElementPropertiesPanel( + document: state.document, + selectedElement: state.selectedElement, + onUpdateElement: notifier.updateElement, + onDeleteElement: notifier.deleteElement, + onDuplicateElement: notifier.duplicateElement, + onBringToFront: notifier.bringToFront, + onSendToBack: notifier.sendToBack, + onUpdateConfig: notifier.updateLabelConfig, + ), + ), + ], + ), + ), + + // Bottom Live Printer Command Preview + PrinterCommandPreview( + commands: state.liveCommands, + language: cfg.printerLanguage, + ), + ], + ); + }, + ), + ), + ); +} + + PreferredSizeWidget _buildTopAppBar( + BuildContext context, + BarcodeDesignerState state, + BarcodeDesignerNotifier notifier, + bool isDark, + ) { + final screenWidth = MediaQuery.of(context).size.width; + final isMobile = screenWidth < 960; + + return AppBar( + elevation: 1, + backgroundColor: isDark ? const Color(0xFF111827) : Colors.white, + leading: IconButton( + icon: const Icon(LucideIcons.arrowLeft), + onPressed: () => Navigator.pop(context), + ), + titleSpacing: 0, + title: Row( + mainAxisSize: isMobile ? MainAxisSize.min : MainAxisSize.max, + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(LucideIcons.barcode, color: Colors.blue, size: 20), + ), + const SizedBox(width: 10), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: () => _showRenameDialog(context, state.document.name, notifier), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + state.document.name, + style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 6), + Icon(LucideIcons.pencil, size: 12, color: Colors.grey.shade400), + ], + ), + ), + Text( + '${state.document.config.widthMm.round()} × ${state.document.config.heightMm.round()} mm • ${state.document.config.dpi} DPI', + style: TextStyle(fontSize: 11, color: Colors.grey.shade500), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + + if (!isMobile) ...[ + const SizedBox(width: 16), + // Design vs Preview Mode Switch + Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + decoration: BoxDecoration( + color: isDark ? Colors.white10 : Colors.grey.shade200, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildModeButton( + label: 'Design', + icon: LucideIcons.draftingCompass, + isSelected: !state.isPreviewMode, + onTap: () => notifier.togglePreviewMode(false), + isDark: isDark, + ), + _buildModeButton( + label: 'Preview', + icon: LucideIcons.eye, + isSelected: state.isPreviewMode, + onTap: () => notifier.togglePreviewMode(true), + isDark: isDark, + ), + ], + ), + ), + const SizedBox(width: 12), + + // Sample Product Selector + if (state.sampleProduct != null) + InputChip( + avatar: const Icon(LucideIcons.package, size: 14, color: Colors.blue), + label: Text( + state.sampleProduct!.name, + style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold), + overflow: TextOverflow.ellipsis, + ), + onDeleted: () => notifier.setSampleProduct(null), + onPressed: () => _pickSampleProduct(context, notifier), + ) + else + ActionChip( + avatar: const Icon(LucideIcons.packagePlus, size: 14, color: Colors.blue), + label: const Text('Sample Product', style: TextStyle(fontSize: 11)), + onPressed: () => _pickSampleProduct(context, notifier), + ), + ], + ], + ), + actions: isMobile + ? [ + IconButton( + icon: const Icon(LucideIcons.printer, color: Colors.green, size: 20), + tooltip: 'Test Print', + onPressed: () { + TestPrintModal.show( + context, + document: state.document, + sampleProduct: state.sampleProduct, + sampleInventoryItem: state.sampleInventoryItem, + onSelectProduct: (p, item) => notifier.setSampleProduct(p, item), + onUpdateConfig: (cfg) => notifier.updateLabelConfig(cfg), + ); + }, + ), + IconButton( + icon: _isSaving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(LucideIcons.save, size: 20), + tooltip: 'Save Template', + onPressed: _isSaving ? null : () => _saveTemplateToBackend(context, state, notifier), + ), + PopupMenuButton( + icon: const Icon(LucideIcons.ellipsisVertical, size: 18), + onSelected: (val) { + switch (val) { + case 'undo': + if (state.undoStack.isNotEmpty) notifier.undo(); + break; + case 'redo': + if (state.redoStack.isNotEmpty) notifier.redo(); + break; + case 'zoom_in': + notifier.setZoom(state.zoom + 0.3); + break; + case 'zoom_out': + notifier.setZoom(state.zoom - 0.3); + break; + case 'zoom_reset': + notifier.setZoom(1.0); + break; + case 'templates': + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const SavedTemplatesScreen()), + ); + break; + } + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: 'undo', + enabled: state.undoStack.isNotEmpty, + child: const Row( + children: [ + Icon(LucideIcons.undo, size: 16), + SizedBox(width: 8), + Text('Undo'), + ], + ), + ), + PopupMenuItem( + value: 'redo', + enabled: state.redoStack.isNotEmpty, + child: const Row( + children: [ + Icon(LucideIcons.redo, size: 16), + SizedBox(width: 8), + Text('Redo'), + ], + ), + ), + const PopupMenuDivider(), + PopupMenuItem( + value: 'zoom_in', + child: Row( + children: [ + const Icon(LucideIcons.zoomIn, size: 16), + const SizedBox(width: 8), + Text('Zoom In (${(state.zoom * 100).round()}%)'), + ], + ), + ), + const PopupMenuItem( + value: 'zoom_out', + child: Row( + children: [ + Icon(LucideIcons.zoomOut, size: 16), + SizedBox(width: 8), + Text('Zoom Out'), + ], + ), + ), + const PopupMenuItem( + value: 'zoom_reset', + child: Row( + children: [ + Icon(LucideIcons.maximize2, size: 16), + SizedBox(width: 8), + Text('Reset Zoom (100%)'), + ], + ), + ), + const PopupMenuDivider(), + const PopupMenuItem( + value: 'templates', + child: Row( + children: [ + Icon(LucideIcons.folderOpen, size: 16), + SizedBox(width: 8), + Text('Saved Templates'), + ], + ), + ), + ], + ), + const SizedBox(width: 4), + ] + : [ + // Desktop actions + IconButton( + icon: const Icon(LucideIcons.undo, size: 18), + tooltip: 'Undo', + onPressed: state.undoStack.isNotEmpty ? notifier.undo : null, + ), + IconButton( + icon: const Icon(LucideIcons.redo, size: 18), + tooltip: 'Redo', + onPressed: state.redoStack.isNotEmpty ? notifier.redo : null, + ), + const VerticalDivider(width: 20, indent: 12, endIndent: 12), + + // Zoom Controls + IconButton( + icon: const Icon(LucideIcons.zoomOut, size: 18), + tooltip: 'Zoom Out', + onPressed: () => notifier.setZoom(state.zoom - 0.3), + ), + Text( + '${(state.zoom * 100).round()}%', + style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600), + ), + IconButton( + icon: const Icon(LucideIcons.zoomIn, size: 18), + tooltip: 'Zoom In', + onPressed: () => notifier.setZoom(state.zoom + 0.3), + ), + const VerticalDivider(width: 20, indent: 12, endIndent: 12), + + // Test Print Button + OutlinedButton.icon( + style: OutlinedButton.styleFrom( + foregroundColor: Colors.green, + side: const BorderSide(color: Colors.green), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + icon: const Icon(LucideIcons.printer, size: 16), + label: const Text('Test Print', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12)), + onPressed: () { + TestPrintModal.show( + context, + document: state.document, + sampleProduct: state.sampleProduct, + sampleInventoryItem: state.sampleInventoryItem, + onSelectProduct: (p, item) => notifier.setSampleProduct(p, item), + onUpdateConfig: (cfg) => notifier.updateLabelConfig(cfg), + ); + }, + ), + const SizedBox(width: 10), + + // Save Template Button + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + icon: _isSaving + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2), + ) + : const Icon(LucideIcons.save, size: 16), + label: const Text('Save Template', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12)), + onPressed: _isSaving ? null : () => _saveTemplateToBackend(context, state, notifier), + ), + const SizedBox(width: 8), + + // Saved Templates Screen + IconButton( + icon: const Icon(LucideIcons.folderOpen, size: 18), + tooltip: 'Saved Templates', + onPressed: () { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const SavedTemplatesScreen()), + ); + }, + ), + const SizedBox(width: 8), + ], + ); + } + + Widget _buildModeButton({ + required String label, + required IconData icon, + required bool isSelected, + required VoidCallback onTap, + required bool isDark, + }) { + final isPreview = label == 'Preview'; + Color? activeBg; + Color? activeFg; + if (isSelected) { + if (isPreview) { + activeBg = isDark ? const Color(0xFF065F46) : const Color(0xFF10B981); + activeFg = Colors.white; + } else { + activeBg = isDark ? const Color(0xFF2563EB) : Colors.white; + activeFg = isDark ? Colors.white : const Color(0xFF2563EB); + } + } + + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: isSelected ? activeBg : Colors.transparent, + borderRadius: BorderRadius.circular(16), + boxShadow: isSelected && !isDark + ? [const BoxShadow(color: Colors.black12, blurRadius: 4, offset: Offset(0, 1))] + : null, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 13, + color: isSelected ? activeFg : (isDark ? Colors.white60 : Colors.grey.shade600), + ), + const SizedBox(width: 5), + Text( + label, + style: TextStyle( + fontSize: 11, + fontWeight: isSelected ? FontWeight.bold : FontWeight.w500, + color: isSelected ? activeFg : (isDark ? Colors.white60 : Colors.grey.shade600), + ), + ), + ], + ), + ), + ); + } + + Widget _buildCenterCanvasArea( + BuildContext context, + BarcodeDesignerState state, + BarcodeDesignerNotifier notifier, + LabelDocument renderDoc, + double scale, + bool isDark, + Product? currentSampleProduct, + ) { + final cfg = renderDoc.config; + final canvasWidthPx = cfg.widthMm * scale; + final canvasHeightPx = cfg.heightMm * scale; + + return Container( + color: isDark ? const Color(0xFF090D16) : const Color(0xFFF1F5F9), + child: Column( + children: [ + // Canvas Toolbar Controls + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF111827) : Colors.white, + border: Border( + bottom: BorderSide( + color: isDark ? Colors.white10 : Colors.grey.shade200, + ), + ), + ), + child: Row( + children: [ + FilterChip( + label: const Text('Grid', style: TextStyle(fontSize: 11)), + selected: state.showGrid, + onSelected: (_) => notifier.toggleGrid(), + avatar: const Icon(LucideIcons.grid, size: 14), + ), + const SizedBox(width: 8), + FilterChip( + label: const Text('Snap to Grid', style: TextStyle(fontSize: 11)), + selected: state.snapToGrid, + onSelected: (_) => notifier.toggleSnap(), + avatar: const Icon(LucideIcons.magnet, size: 14), + ), + if (MediaQuery.of(context).size.width >= 700) ...[ + const Spacer(), + Text( + 'Canvas: ${cfg.widthMm.round()} × ${cfg.heightMm.round()} mm' + '${cfg.columnsAcross > 1 ? " • ${cfg.columnsAcross}-Across" : ""}', + style: TextStyle(fontSize: 11, color: Colors.grey.shade500), + ), + ], + ], + ), + ), + + // Scrollable Canvas Area with Physical Rulers + Expanded( + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => _canvasFocusNode.requestFocus(), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SingleChildScrollView( + scrollDirection: Axis.vertical, + child: Padding( + padding: const EdgeInsets.all(40.0), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Top Horizontal Ruler (only in Design Mode) + if (!state.isPreviewMode) + Padding( + padding: const EdgeInsets.only(left: 20.0), + child: CanvasRuler( + lengthMm: cfg.widthMm, + scale: scale, + isHorizontal: true, + thickness: 18, + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Left Vertical Ruler (only in Design Mode) + if (!state.isPreviewMode) + CanvasRuler( + lengthMm: cfg.heightMm, + scale: scale, + isHorizontal: false, + thickness: 20, + ), + + // Multi-Across Preview Mode or Interactive Single Canvas Box + if (state.isPreviewMode && cfg.columnsAcross > 1) + Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(cfg.columnsAcross, (colIndex) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: canvasWidthPx, + height: canvasHeightPx, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(4), + boxShadow: const [ + BoxShadow( + color: Colors.black26, + blurRadius: 10, + offset: Offset(0, 3), + ), + ], + ), + child: Stack( + clipBehavior: Clip.none, + children: renderDoc.elements.map((elem) { + return _CanvasElementWidget( + key: ValueKey('${elem.id}_col_$colIndex'), + elem: elem, + scale: scale, + isSelected: false, + isPreviewMode: true, + sampleProduct: currentSampleProduct ?? state.sampleProduct, + sampleInventoryItem: state.sampleInventoryItem, + notifier: notifier, + onSelect: null, + onBringToFront: null, + onSendToBack: null, + onBringForward: null, + onSendBackward: null, + onDuplicate: null, + onDelete: null, + renderContent: _renderElementContent, + ); + }).toList(), + ), + ), + if (colIndex < cfg.columnsAcross - 1) + Container( + width: cfg.horizontalGapMm * scale, + height: canvasHeightPx, + alignment: Alignment.center, + child: Container( + width: 1.5, + height: canvasHeightPx * 0.8, + color: Colors.grey.shade400, + ), + ), + ], + ); + }), + ) + else + // The Physical Label Canvas Box + DragTarget( + onAcceptWithDetails: (details) { + final box = context.findRenderObject() as RenderBox?; + final localOffset = box != null ? box.globalToLocal(details.offset) : Offset.zero; + final dropXMm = (localOffset.dx / scale).clamp(0.0, cfg.widthMm - 10.0); + final dropYMm = (localOffset.dy / scale).clamp(0.0, cfg.heightMm - 5.0); + + final data = details.data; + if (data is ElementType) { + _addNewElementAt(data, dropXMm, dropYMm, notifier, cfg); + } else if (data is DynamicFieldDefinition) { + notifier.addBoundFieldElement( + fieldDef: data, + dropXMm: dropXMm, + dropYMm: dropYMm, + ); + _canvasFocusNode.requestFocus(); + } + }, + builder: (context, candidateData, rejectedData) { + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () { + notifier.selectElement(null); + _canvasFocusNode.requestFocus(); + }, + child: Container( + width: canvasWidthPx, + height: canvasHeightPx, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: candidateData.isNotEmpty + ? Colors.blue + : Colors.grey.shade400, + width: candidateData.isNotEmpty ? 2.0 : 1.0, + ), + boxShadow: const [ + BoxShadow( + color: Colors.black26, + blurRadius: 12, + offset: Offset(0, 4), + ), + ], + ), + child: Stack( + clipBehavior: Clip.none, + children: [ + // Grid background + if (state.showGrid && !state.isPreviewMode) + _buildGridOverlay(cfg.widthMm, cfg.heightMm, scale), + + // Render elements + ...renderDoc.elements.map((elem) { + final isSelected = elem.id == state.selectedElementId; + return _CanvasElementWidget( + key: ValueKey(elem.id), + elem: elem, + scale: scale, + isSelected: isSelected, + isPreviewMode: state.isPreviewMode, + sampleProduct: currentSampleProduct ?? state.sampleProduct, + sampleInventoryItem: state.sampleInventoryItem, + notifier: notifier, + onSelect: () => _canvasFocusNode.requestFocus(), + onBringToFront: () => notifier.bringToFront(elem.id), + onSendToBack: () => notifier.sendToBack(elem.id), + onBringForward: () => notifier.bringForward(elem.id), + onSendBackward: () => notifier.sendBackward(elem.id), + onDuplicate: () => notifier.duplicateElement(elem.id), + onDelete: () => notifier.deleteElement(elem.id), + renderContent: _renderElementContent, + ); + }), + ], + ), + ), + ); + }, + ), + ], + ), + ], + ), + ), + ), + ), + ), + ), + ), + ], + ), + ); +} + + Widget _buildGridOverlay(double widthMm, double heightMm, double scale) { + return CustomPaint( + size: Size(widthMm * scale, heightMm * scale), + painter: _GridPainter(scale: scale), + ); + } + + Alignment _toAlignment(TextAlign align) { + switch (align) { + case TextAlign.center: + return Alignment.center; + case TextAlign.right: + case TextAlign.end: + return Alignment.centerRight; + case TextAlign.left: + case TextAlign.start: + case TextAlign.justify: + return Alignment.centerLeft; + } + } + + bc.Barcode _getBarcodeSymbology(BarcodeType type) { + switch (type) { + case BarcodeType.ean13: + return bc.Barcode.ean13(); + case BarcodeType.code39: + return bc.Barcode.code39(); + case BarcodeType.upca: + return bc.Barcode.upcA(); + case BarcodeType.qrCode: + return bc.Barcode.qrCode(); + case BarcodeType.code128: + return bc.Barcode.code128(); + } + } + + String _sanitizeBarcodeData(BarcodeType type, String rawData) { + var data = rawData.trim(); + if (data.isEmpty) data = '12345678'; + switch (type) { + case BarcodeType.ean13: + final digits = data.replaceAll(RegExp(r'[^0-9]'), ''); + if (digits.length >= 12) return digits.substring(0, 12); + return digits.padLeft(12, '0'); + case BarcodeType.upca: + final digits = data.replaceAll(RegExp(r'[^0-9]'), ''); + if (digits.length >= 11) return digits.substring(0, 11); + return digits.padLeft(11, '0'); + case BarcodeType.code39: + final cleaned = data.toUpperCase().replaceAll(RegExp(r'[^0-9A-Z \-\.\$\/\+\%]'), ''); + return cleaned.isEmpty ? 'CODE39' : cleaned; + case BarcodeType.code128: + case BarcodeType.qrCode: + return data; + } + } + + Widget _renderElementContent( + LabelElement elem, + bool isPreviewMode, + double scale, + Product? sampleProduct, + InventoryItem? sampleInventoryItem, + ) { + if (elem is TextElement) { + return Container( + alignment: _toAlignment(elem.alignment), + padding: EdgeInsets.all(elem.paddingMm * scale * 0.3), + child: Text( + elem.text, + textAlign: elem.alignment, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: (elem.fontSizePt * scale * 0.32).clamp(7.0, 48.0), + fontWeight: elem.isBold ? FontWeight.bold : FontWeight.normal, + fontStyle: elem.isItalic ? FontStyle.italic : FontStyle.normal, + color: Colors.black, + fontFamily: elem.fontFamily, + height: 1.1, + ), + ), + ); + } else if (elem is DynamicTextElement) { + final rawVal = TemplateDataResolver.resolveFieldValue( + source: elem.source, + fieldKey: elem.fieldKey, + product: sampleProduct, + inventoryItem: sampleInventoryItem, + ); + + final isShowingRealData = rawVal != null; + final displayText = isShowingRealData + ? elem.formatValue(rawVal) + : (elem.fieldKey.isNotEmpty + ? '${elem.prefix ?? ''}{{${elem.source}.${elem.fieldKey}}}${elem.suffix ?? ''}' + : '{{select_field}}'); + + return Container( + alignment: _toAlignment(elem.alignment), + padding: EdgeInsets.all(elem.paddingMm * scale * 0.3), + child: Text( + displayText, + textAlign: elem.alignment, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: (elem.fontSizePt * scale * 0.32).clamp(7.0, 48.0), + fontWeight: elem.isBold ? FontWeight.bold : FontWeight.normal, + fontStyle: elem.isItalic ? FontStyle.italic : FontStyle.normal, + color: isPreviewMode + ? Colors.black + : (isShowingRealData ? Colors.purple.shade900 : Colors.purple.shade700), + fontFamily: isShowingRealData ? elem.fontFamily : 'monospace', + height: 1.1, + ), + ), + ); + } else if (elem is BarcodeElement) { + String barcodeValue = elem.staticValue; + if (elem.valueType == 'dynamic') { + final raw = TemplateDataResolver.resolveFieldValue( + source: elem.source, + fieldKey: elem.fieldKey, + product: sampleProduct, + inventoryItem: sampleInventoryItem, + ); + if (raw != null && raw.isNotEmpty) { + barcodeValue = raw; + } else { + barcodeValue = elem.fieldKey.isNotEmpty ? '12345678' : elem.staticValue; + } + } + + final sanitized = _sanitizeBarcodeData(elem.barcodeType, barcodeValue); + final barcodeSym = _getBarcodeSymbology(elem.barcodeType); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 2.0, vertical: 1.0), + child: bc.BarcodeWidget( + barcode: barcodeSym, + data: sanitized, + drawText: elem.showText, + style: TextStyle( + fontSize: (scale * 1.8).clamp(6.0, 11.0), + fontFamily: 'monospace', + fontWeight: FontWeight.bold, + color: Colors.black, + ), + color: Colors.black, + errorBuilder: (context, error) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(LucideIcons.triangleAlert, size: 12, color: Colors.amber), + Text( + 'Invalid for ${elem.barcodeType.name.toUpperCase()}', + style: const TextStyle(fontSize: 8, color: Colors.red), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ), + ); + } else if (elem is QrCodeElement) { + String qrText = elem.staticValue; + if (elem.valueType == 'dynamic') { + final raw = TemplateDataResolver.resolveFieldValue( + source: elem.source, + fieldKey: elem.fieldKey, + product: sampleProduct, + inventoryItem: sampleInventoryItem, + ); + if (raw != null && raw.isNotEmpty) { + qrText = raw; + } else { + qrText = elem.fieldKey.isNotEmpty ? 'https://kifi.app' : elem.staticValue; + } + } + + return Padding( + padding: const EdgeInsets.all(2.0), + child: bc.BarcodeWidget( + barcode: bc.Barcode.qrCode(), + data: qrText.isEmpty ? 'https://kifi.app' : qrText, + drawText: false, + color: Colors.black, + errorBuilder: (context, error) => const Center( + child: Icon(LucideIcons.qrCode, color: Colors.black), + ), + ), + ); + } else if (elem is RectangleElement) { + return Container( + decoration: BoxDecoration( + color: elem.isFilled ? Colors.black : Colors.transparent, + border: Border.all(color: Colors.black, width: 1.5), + borderRadius: BorderRadius.circular(elem.cornerRadiusMm * scale), + ), + ); + } else if (elem is LineElement) { + return Center( + child: Container( + color: Colors.black, + width: elem.isVertical ? 2.0 : double.infinity, + height: elem.isVertical ? double.infinity : 2.0, + ), + ); + } + return const SizedBox(); + } + + Widget _buildMobileLayout( + BuildContext context, + BarcodeDesignerState state, + BarcodeDesignerNotifier notifier, + LabelDocument renderDoc, + double scale, + bool isDark, + Product? currentSampleProduct, + ) { + return Column( + children: [ + // Mobile Sub-bar: Mode Switch & Sample Product Picker + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF111827) : Colors.white, + border: Border( + bottom: BorderSide( + color: isDark ? Colors.white10 : Colors.grey.shade200, + ), + ), + ), + child: Row( + children: [ + // Design vs Preview Mode Switch + Container( + padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 2), + decoration: BoxDecoration( + color: isDark ? Colors.white10 : Colors.grey.shade200, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildModeButton( + label: 'Design', + icon: LucideIcons.draftingCompass, + isSelected: !state.isPreviewMode, + onTap: () => notifier.togglePreviewMode(false), + isDark: isDark, + ), + _buildModeButton( + label: 'Preview', + icon: LucideIcons.eye, + isSelected: state.isPreviewMode, + onTap: () => notifier.togglePreviewMode(true), + isDark: isDark, + ), + ], + ), + ), + const SizedBox(width: 8), + + // Sample Product Selector + Expanded( + child: (currentSampleProduct ?? state.sampleProduct) != null + ? InputChip( + avatar: const Icon(LucideIcons.package, size: 14, color: Colors.blue), + label: Text( + (currentSampleProduct ?? state.sampleProduct)!.name, + style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold), + overflow: TextOverflow.ellipsis, + ), + onDeleted: () => notifier.setSampleProduct(null), + onPressed: () => _pickSampleProduct(context, notifier), + ) + : ActionChip( + avatar: const Icon(LucideIcons.packagePlus, size: 14, color: Colors.blue), + label: const Text('Sample Product', style: TextStyle(fontSize: 11), overflow: TextOverflow.ellipsis), + onPressed: () => _pickSampleProduct(context, notifier), + ), + ), + ], + ), + ), + + Expanded( + child: _buildCenterCanvasArea(context, state, notifier, renderDoc, scale, isDark, currentSampleProduct), + ), + + // Bottom Tool/Properties Panel (Collapsible on Mobile) + AnimatedContainer( + duration: const Duration(milliseconds: 200), + height: _isMobileBottomPanelExpanded ? 260 : 44, + decoration: BoxDecoration( + color: isDark ? const Color(0xFF111827) : Colors.white, + border: Border( + top: BorderSide( + color: isDark ? Colors.white10 : Colors.grey.shade200, + ), + ), + ), + child: DefaultTabController( + length: 3, + initialIndex: state.selectedElement != null ? 2 : 0, + child: Column( + children: [ + Row( + children: [ + Expanded( + child: TabBar( + labelColor: Theme.of(context).colorScheme.primary, + unselectedLabelColor: Colors.grey, + indicatorColor: Theme.of(context).colorScheme.primary, + labelPadding: const EdgeInsets.symmetric(horizontal: 4), + tabs: [ + const Tab( + icon: Icon(LucideIcons.wrench, size: 15), + text: 'Tools', + ), + const Tab( + icon: Icon(LucideIcons.variable, size: 15), + text: 'Variables', + ), + Tab( + icon: Badge( + isLabelVisible: state.selectedElement != null, + smallSize: 8, + child: const Icon(LucideIcons.slidersHorizontal, size: 15), + ), + text: state.selectedElement != null ? 'Properties ●' : 'Properties', + ), + ], + ), + ), + IconButton( + icon: Icon( + _isMobileBottomPanelExpanded ? LucideIcons.chevronDown : LucideIcons.chevronUp, + size: 18, + color: Colors.grey.shade500, + ), + tooltip: _isMobileBottomPanelExpanded ? 'Collapse panel' : 'Expand panel', + onPressed: () { + setState(() => _isMobileBottomPanelExpanded = !_isMobileBottomPanelExpanded); + }, + ), + ], + ), + if (_isMobileBottomPanelExpanded) + Expanded( + child: TabBarView( + children: [ + DesignerToolbox(onAddElement: (type) => _addNewElement(type, notifier, renderDoc.config)), + ProductVariablesPanel(onAddField: (f) => notifier.addBoundFieldElement(fieldDef: f, dropXMm: 4, dropYMm: 6)), + ElementPropertiesPanel( + document: state.document, + selectedElement: state.selectedElement, + onUpdateElement: notifier.updateElement, + onDeleteElement: notifier.deleteElement, + onDuplicateElement: notifier.duplicateElement, + onBringToFront: notifier.bringToFront, + onSendToBack: notifier.sendToBack, + onUpdateConfig: notifier.updateLabelConfig, + ), + ], + ), + ), + ], + ), + ), + ), + ], + ); + } + + void _addNewElement(ElementType type, BarcodeDesignerNotifier notifier, LabelConfiguration cfg) { + _addNewElementAt(type, 4.0, 6.0, notifier, cfg); + } + + void _addNewElementAt( + ElementType type, + double xMm, + double yMm, + BarcodeDesignerNotifier notifier, + LabelConfiguration cfg, + ) { + _canvasFocusNode.requestFocus(); + final id = 'elem_${DateTime.now().millisecondsSinceEpoch}'; + + switch (type) { + case ElementType.text: + notifier.addElement( + TextElement( + id: id, + xMm: xMm, + yMm: yMm, + widthMm: 24.0, + heightMm: 4.5, + text: 'kifi', + fontSizePt: 9.0, + isBold: true, + ), + ); + break; + case ElementType.dynamicText: + notifier.addElement( + DynamicTextElement( + id: id, + xMm: xMm, + yMm: yMm, + widthMm: 32.0, + heightMm: 4.5, + source: 'product', + fieldKey: 'name', + fontSizePt: 8.5, + ), + ); + break; + case ElementType.barcode: + notifier.addElement( + BarcodeElement( + id: id, + xMm: xMm, + yMm: yMm, + widthMm: 38.0, + heightMm: 8.5, + valueType: 'dynamic', + source: 'product', + fieldKey: 'barcode', + showText: true, + ), + ); + break; + case ElementType.qrCode: + notifier.addElement( + QrCodeElement( + id: id, + xMm: xMm, + yMm: yMm, + widthMm: 12.0, + heightMm: 12.0, + valueType: 'dynamic', + source: 'product', + fieldKey: 'barcode', + ), + ); + break; + case ElementType.rectangle: + notifier.addElement( + RectangleElement( + id: id, + xMm: xMm, + yMm: yMm, + widthMm: 24.0, + heightMm: 12.0, + ), + ); + break; + case ElementType.line: + notifier.addElement( + LineElement( + id: id, + xMm: xMm, + yMm: yMm, + widthMm: cfg.widthMm - 4.0, + heightMm: 1.0, + ), + ); + break; + } + } + + void _pickSampleProduct(BuildContext context, BarcodeDesignerNotifier notifier) async { + ref.read(productsProvider.notifier).refresh(); + final result = await ProductPickerDialog.show(context); + if (result != null) { + notifier.setSampleProduct(result.product, result.inventoryItem); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Selected sample: ${result.product.name}'), + duration: const Duration(seconds: 2), + ), + ); + } + } + + void _showRenameDialog(BuildContext context, String currentName, BarcodeDesignerNotifier notifier) { + final ctrl = TextEditingController(text: currentName); + showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Template Name'), + content: TextField( + controller: ctrl, + autofocus: true, + decoration: const InputDecoration(labelText: 'Name'), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), + ElevatedButton( + onPressed: () { + if (ctrl.text.trim().isNotEmpty) { + notifier.updateTemplateName(ctrl.text.trim()); + } + Navigator.pop(context); + }, + child: const Text('Save'), + ), + ], + ), + ); + } + + Future _saveTemplateToBackend( + BuildContext context, + BarcodeDesignerState state, + BarcodeDesignerNotifier notifier, + ) async { + setState(() => _isSaving = true); + try { + final template = BarcodeTemplate.fromLabelDocument( + id: state.savedTemplateId, + doc: state.document, + ); + + final saved = await ref.read(barcodeTemplatesProvider.notifier).saveTemplate(template); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Template "${state.document.name}" saved successfully!'), + backgroundColor: Colors.green, + ), + ); + if (saved != null && saved.id != null) { + notifier.loadDocument(state.document, saved.id); + } + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error saving template: $e'), + backgroundColor: Colors.red, + ), + ); + } + } finally { + if (mounted) setState(() => _isSaving = false); + } + } +} + +class _GridPainter extends CustomPainter { + final double scale; + _GridPainter({required this.scale}); + + @override + void paint(Canvas canvas, Size size) { + final paint1mm = Paint() + ..color = Colors.black.withValues(alpha: 0.04) + ..strokeWidth = 0.5; + + final paint5mm = Paint() + ..color = Colors.black.withValues(alpha: 0.1) + ..strokeWidth = 1.0; + + for (double x = 0; x <= size.width; x += scale) { + final is5 = (x / scale).round() % 5 == 0; + canvas.drawLine(Offset(x, 0), Offset(x, size.height), is5 ? paint5mm : paint1mm); + } + for (double y = 0; y <= size.height; y += scale) { + final is5 = (y / scale).round() % 5 == 0; + canvas.drawLine(Offset(0, y), Offset(size.width, y), is5 ? paint5mm : paint1mm); + } + } + + @override + bool shouldRepaint(covariant _GridPainter oldDelegate) => oldDelegate.scale != scale; +} + +class _CanvasElementWidget extends StatefulWidget { + final LabelElement elem; + final double scale; + final bool isSelected; + final bool isPreviewMode; + final Product? sampleProduct; + final InventoryItem? sampleInventoryItem; + final BarcodeDesignerNotifier notifier; + final VoidCallback? onSelect; + final VoidCallback? onBringToFront; + final VoidCallback? onSendToBack; + final VoidCallback? onBringForward; + final VoidCallback? onSendBackward; + final VoidCallback? onDuplicate; + final VoidCallback? onDelete; + final Widget Function( + LabelElement elem, + bool isPreviewMode, + double scale, + Product? sampleProduct, + InventoryItem? sampleInventoryItem, + ) renderContent; + + const _CanvasElementWidget({ + super.key, + required this.elem, + required this.scale, + required this.isSelected, + required this.isPreviewMode, + this.sampleProduct, + this.sampleInventoryItem, + required this.notifier, + this.onSelect, + this.onBringToFront, + this.onSendToBack, + this.onBringForward, + this.onSendBackward, + this.onDuplicate, + this.onDelete, + required this.renderContent, + }); + + @override + State<_CanvasElementWidget> createState() => _CanvasElementWidgetState(); +} + +class _CanvasElementWidgetState extends State<_CanvasElementWidget> { + double _dragStartX = 0.0; + double _dragStartY = 0.0; + double _accumDx = 0.0; + double _accumDy = 0.0; + + double _resizeStartW = 0.0; + double _resizeStartH = 0.0; + double _accumResizeW = 0.0; + double _accumResizeH = 0.0; + + void _showContextMenu(BuildContext context, Offset globalPos) { + final isDark = Theme.of(context).brightness == Brightness.dark; + showMenu( + context: context, + position: RelativeRect.fromLTRB( + globalPos.dx, + globalPos.dy, + globalPos.dx + 1, + globalPos.dy + 1, + ), + color: isDark ? const Color(0xFF1E293B) : Colors.white, + elevation: 8, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: BorderSide(color: isDark ? Colors.white12 : Colors.grey.shade200), + ), + items: [ + PopupMenuItem( + value: 'front', + height: 34, + child: Row( + children: [ + Icon(LucideIcons.arrowUpToLine, size: 15, color: isDark ? Colors.white70 : Colors.black87), + const SizedBox(width: 8), + const Expanded(child: Text('Bring to Front', style: TextStyle(fontSize: 12))), + Text(']', style: TextStyle(fontSize: 11, color: isDark ? Colors.white38 : Colors.black38)), + ], + ), + ), + PopupMenuItem( + value: 'forward', + height: 34, + child: Row( + children: [ + Icon(LucideIcons.arrowUp, size: 15, color: isDark ? Colors.white70 : Colors.black87), + const SizedBox(width: 8), + const Expanded(child: Text('Bring Forward', style: TextStyle(fontSize: 12))), + ], + ), + ), + PopupMenuItem( + value: 'backward', + height: 34, + child: Row( + children: [ + Icon(LucideIcons.arrowDown, size: 15, color: isDark ? Colors.white70 : Colors.black87), + const SizedBox(width: 8), + const Expanded(child: Text('Send Backward', style: TextStyle(fontSize: 12))), + ], + ), + ), + PopupMenuItem( + value: 'back', + height: 34, + child: Row( + children: [ + Icon(LucideIcons.arrowDownToLine, size: 15, color: isDark ? Colors.white70 : Colors.black87), + const SizedBox(width: 8), + const Expanded(child: Text('Send to Back', style: TextStyle(fontSize: 12))), + Text('[', style: TextStyle(fontSize: 11, color: isDark ? Colors.white38 : Colors.black38)), + ], + ), + ), + const PopupMenuDivider(height: 8), + PopupMenuItem( + value: 'duplicate', + height: 34, + child: Row( + children: [ + Icon(LucideIcons.copy, size: 15, color: isDark ? Colors.white70 : Colors.black87), + const SizedBox(width: 8), + const Expanded(child: Text('Duplicate', style: TextStyle(fontSize: 12))), + Text('Ctrl+D', style: TextStyle(fontSize: 11, color: isDark ? Colors.white38 : Colors.black38)), + ], + ), + ), + PopupMenuItem( + value: 'delete', + height: 34, + child: const Row( + children: [ + Icon(LucideIcons.trash2, size: 15, color: Colors.redAccent), + SizedBox(width: 8), + Expanded(child: Text('Delete', style: TextStyle(fontSize: 12, color: Colors.redAccent))), + Text('Del', style: TextStyle(fontSize: 11, color: Colors.redAccent)), + ], + ), + ), + ], + ).then((value) { + if (value == null) return; + widget.onSelect?.call(); + switch (value) { + case 'front': + widget.onBringToFront?.call(); + break; + case 'forward': + widget.onBringForward?.call(); + break; + case 'backward': + widget.onSendBackward?.call(); + break; + case 'back': + widget.onSendToBack?.call(); + break; + case 'duplicate': + widget.onDuplicate?.call(); + break; + case 'delete': + widget.onDelete?.call(); + break; + } + }); + } + + Widget _buildQuickActionBtn({ + required IconData icon, + required String tooltip, + required VoidCallback? onTap, + required bool isDark, + Color? color, + }) { + return Tooltip( + message: tooltip, + waitDuration: const Duration(milliseconds: 300), + child: InkWell( + borderRadius: BorderRadius.circular(4), + onTap: () { + widget.onSelect?.call(); + onTap?.call(); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 4), + child: Icon( + icon, + size: 13, + color: color ?? (isDark ? Colors.white70 : Colors.black87), + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final elem = widget.elem; + final scale = widget.scale; + final isSelected = widget.isSelected && !widget.isPreviewMode; + final notifier = widget.notifier; + final isDark = Theme.of(context).brightness == Brightness.dark; + + final left = elem.xMm * scale; + final top = elem.yMm * scale; + final width = elem.widthMm * scale; + final height = elem.heightMm * scale; + + return Positioned( + left: left, + top: top, + width: width, + height: height, + child: DragTarget( + onAcceptWithDetails: widget.isPreviewMode + ? null + : (details) { + notifier.bindFieldToElement(elem.id, details.data.source, details.data.fieldKey); + }, + builder: (context, candidateFields, rejectedData) { + return MouseRegion( + cursor: widget.isPreviewMode ? SystemMouseCursors.basic : SystemMouseCursors.click, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.isPreviewMode + ? null + : () { + notifier.selectElement(elem.id); + widget.onSelect?.call(); + }, + onSecondaryTapDown: widget.isPreviewMode + ? null + : (details) { + notifier.selectElement(elem.id); + widget.onSelect?.call(); + _showContextMenu(context, details.globalPosition); + }, + onPanStart: widget.isPreviewMode + ? null + : (details) { + notifier.selectElement(elem.id); + widget.onSelect?.call(); + notifier.recordUndo(); + _dragStartX = elem.xMm; + _dragStartY = elem.yMm; + _accumDx = 0.0; + _accumDy = 0.0; + }, + onPanUpdate: widget.isPreviewMode + ? null + : (details) { + _accumDx += details.delta.dx / scale; + _accumDy += details.delta.dy / scale; + notifier.setElementPosition( + elem.id, + _dragStartX + _accumDx, + _dragStartY + _accumDy, + ); + }, + child: Stack( + clipBehavior: Clip.none, + children: [ + // Visual Content Container + Container( + width: width, + height: height, + decoration: BoxDecoration( + color: (candidateFields.isNotEmpty && !widget.isPreviewMode) + ? Colors.purple.withValues(alpha: 0.15) + : (isSelected + ? Colors.blue.withValues(alpha: 0.04) + : Colors.transparent), + border: widget.isPreviewMode + ? (elem.borderWidthMm > 0 + ? Border.all( + color: Colors.black, + width: (elem.borderWidthMm * scale * 0.4).clamp(1.0, 4.0), + ) + : null) + : Border.all( + color: isSelected + ? Colors.blue + : (candidateFields.isNotEmpty + ? Colors.purple + : (elem.borderWidthMm > 0 + ? Colors.black + : Colors.blue.withValues(alpha: 0.25))), + width: isSelected + ? 1.5 + : (elem.borderWidthMm > 0 + ? (elem.borderWidthMm * scale * 0.4).clamp(1.0, 4.0) + : 0.8), + ), + ), + child: widget.renderContent( + elem, + widget.isPreviewMode, + scale, + widget.sampleProduct, + widget.sampleInventoryItem, + ), + ), + + // Floating Quick Action Toolbar in Preview + if (isSelected) + Positioned( + top: (top > 34) ? -32 : height + 4, + left: 0, + child: Material( + elevation: 6, + borderRadius: BorderRadius.circular(6), + color: isDark ? const Color(0xFF1E293B) : Colors.white, + shadowColor: Colors.black38, + child: Container( + height: 26, + padding: const EdgeInsets.symmetric(horizontal: 4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: isDark ? Colors.white12 : Colors.grey.shade300, + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildQuickActionBtn( + icon: LucideIcons.arrowUpToLine, + tooltip: 'Bring to Front (])', + onTap: widget.onBringToFront, + isDark: isDark, + ), + _buildQuickActionBtn( + icon: LucideIcons.arrowDownToLine, + tooltip: 'Send to Back ([)', + onTap: widget.onSendToBack, + isDark: isDark, + ), + Container( + width: 1, + height: 12, + margin: const EdgeInsets.symmetric(horizontal: 2), + color: isDark ? Colors.white24 : Colors.grey.shade300, + ), + _buildQuickActionBtn( + icon: LucideIcons.copy, + tooltip: 'Duplicate (Ctrl+D)', + onTap: widget.onDuplicate, + isDark: isDark, + ), + _buildQuickActionBtn( + icon: LucideIcons.trash2, + tooltip: 'Delete (Del)', + color: Colors.redAccent, + onTap: widget.onDelete, + isDark: isDark, + ), + ], + ), + ), + ), + ), + + // Selection Box and Corner Resize Handle + if (isSelected) ...[ + Positioned( + right: -5, + bottom: -5, + child: MouseRegion( + cursor: SystemMouseCursors.resizeDownRight, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onPanStart: (details) { + widget.onSelect?.call(); + notifier.recordUndo(); + _resizeStartW = elem.widthMm; + _resizeStartH = elem.heightMm; + _accumResizeW = 0.0; + _accumResizeH = 0.0; + }, + onPanUpdate: (details) { + _accumResizeW += details.delta.dx / scale; + _accumResizeH += details.delta.dy / scale; + notifier.setElementSize( + elem.id, + _resizeStartW + _accumResizeW, + _resizeStartH + _accumResizeH, + ); + }, + child: Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: Colors.blue, + border: Border.all(color: Colors.white, width: 2), + borderRadius: BorderRadius.circular(3), + boxShadow: const [ + BoxShadow( + color: Colors.black26, + blurRadius: 3, + offset: Offset(0, 1), + ), + ], + ), + ), + ), + ), + ), + ], + ], + ), + ), + ); + }, + ), + ); + } +} diff --git a/kifi-app/lib/features/barcode_designer/presentation/saved_templates_screen.dart b/kifi-app/lib/features/barcode_designer/presentation/saved_templates_screen.dart new file mode 100644 index 0000000..3bb441e --- /dev/null +++ b/kifi-app/lib/features/barcode_designer/presentation/saved_templates_screen.dart @@ -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( + icon: const Icon(LucideIcons.ellipsisVertical, size: 18), + onSelected: (val) async { + if (val == 'delete' && item.id != null) { + final confirm = await showDialog( + 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), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + }, + ); + }, + ), + ); + } +} diff --git a/kifi-app/lib/features/barcode_designer/presentation/widgets/canvas_ruler.dart b/kifi-app/lib/features/barcode_designer/presentation/widgets/canvas_ruler.dart new file mode 100644 index 0000000..aed36bd --- /dev/null +++ b/kifi-app/lib/features/barcode_designer/presentation/widgets/canvas_ruler.dart @@ -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; + } +} diff --git a/kifi-app/lib/features/barcode_designer/presentation/widgets/designer_toolbox.dart b/kifi-app/lib/features/barcode_designer/presentation/widgets/designer_toolbox.dart new file mode 100644 index 0000000..bca85be --- /dev/null +++ b/kifi-app/lib/features/barcode_designer/presentation/widgets/designer_toolbox.dart @@ -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 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( + 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), + ], + ), + ); + } +} diff --git a/kifi-app/lib/features/barcode_designer/presentation/widgets/element_properties_panel.dart b/kifi-app/lib/features/barcode_designer/presentation/widgets/element_properties_panel.dart new file mode 100644 index 0000000..173f7fa --- /dev/null +++ b/kifi-app/lib/features/barcode_designer/presentation/widgets/element_properties_panel.dart @@ -0,0 +1,1002 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../domain/label_document.dart'; +import '../../domain/dynamic_field_definition.dart'; + +class ElementPropertiesPanel extends StatelessWidget { + final LabelDocument document; + final LabelElement? selectedElement; + final Function(LabelElement updated) onUpdateElement; + final Function(String id) onDeleteElement; + final Function(String id) onDuplicateElement; + final Function(String id)? onBringToFront; + final Function(String id)? onSendToBack; + final Function(LabelConfiguration config) onUpdateConfig; + + const ElementPropertiesPanel({ + super.key, + required this.document, + required this.selectedElement, + required this.onUpdateElement, + required this.onDeleteElement, + required this.onDuplicateElement, + this.onBringToFront, + this.onSendToBack, + required this.onUpdateConfig, + }); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Container( + decoration: BoxDecoration( + color: isDark ? const Color(0xFF131D2F) : Colors.white, + border: Border( + left: BorderSide( + color: isDark ? Colors.white10 : Colors.grey.shade200, + ), + ), + ), + child: selectedElement != null + ? _buildElementInspector(context, selectedElement!, isDark) + : _CanvasSettingsView( + document: document, + onUpdateConfig: onUpdateConfig, + isDark: isDark, + ), + ); + } + + Widget _buildElementInspector(BuildContext context, LabelElement elem, bool isDark) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Row( + children: [ + Icon( + elem.type == ElementType.barcode + ? LucideIcons.barcode + : (elem.type == ElementType.dynamicText + ? LucideIcons.variable + : LucideIcons.type), + size: 15, + color: Colors.blue, + ), + const SizedBox(width: 6), + Flexible( + child: Text( + elem.type.name.toUpperCase(), + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12), + ), + ), + ], + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (onBringToFront != null) + IconButton( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.all(4), + constraints: const BoxConstraints(minWidth: 28, minHeight: 28), + icon: const Icon(LucideIcons.arrowUpToLine, size: 15), + tooltip: 'Bring to Front', + onPressed: () => onBringToFront!(elem.id), + ), + if (onSendToBack != null) + IconButton( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.all(4), + constraints: const BoxConstraints(minWidth: 28, minHeight: 28), + icon: const Icon(LucideIcons.arrowDownToLine, size: 15), + tooltip: 'Send to Back', + onPressed: () => onSendToBack!(elem.id), + ), + IconButton( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.all(4), + constraints: const BoxConstraints(minWidth: 28, minHeight: 28), + icon: const Icon(LucideIcons.copy, size: 15), + tooltip: 'Duplicate', + onPressed: () => onDuplicateElement(elem.id), + ), + IconButton( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.all(4), + constraints: const BoxConstraints(minWidth: 28, minHeight: 28), + icon: const Icon(LucideIcons.trash2, size: 15, color: Colors.red), + tooltip: 'Delete', + onPressed: () => onDeleteElement(elem.id), + ), + ], + ), + ], + ), + const Divider(height: 16), + + // Millimeter Positioning + _buildSectionHeader('POSITION & SIZE (MM)'), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: _buildNumberInput( + label: 'X (mm)', + value: elem.xMm, + onChanged: (val) => onUpdateElement(elem.copyWithPosition(xMm: val)), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _buildNumberInput( + label: 'Y (mm)', + value: elem.yMm, + onChanged: (val) => onUpdateElement(elem.copyWithPosition(yMm: val)), + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: _buildNumberInput( + label: 'Width (mm)', + value: elem.widthMm, + onChanged: (val) => onUpdateElement(elem.copyWithPosition(widthMm: val)), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _buildNumberInput( + label: 'Height (mm)', + value: elem.heightMm, + onChanged: (val) => onUpdateElement(elem.copyWithPosition(heightMm: val)), + ), + ), + ], + ), + + const SizedBox(height: 16), + + // Type-specific properties + if (elem is TextElement) ...[ + _buildSectionHeader('TEXT CONTENT'), + const SizedBox(height: 8), + TextFormField( + initialValue: elem.text, + key: ValueKey('text_${elem.id}'), + decoration: _inputDecoration('Text'), + onChanged: (val) => onUpdateElement(elem.copyWithPosition(text: val)), + ), + const SizedBox(height: 12), + _buildTypographySection(elem), + ] else if (elem is DynamicTextElement) ...[ + _buildSectionHeader('DYNAMIC BINDING'), + const SizedBox(height: 8), + DropdownButtonFormField( + key: ValueKey('field_sel_${elem.id}_${elem.fieldKey}'), + isExpanded: true, + initialValue: DynamicFieldDefinition.productFields.any((f) => f.fieldKey == elem.fieldKey) + ? elem.fieldKey + : null, + decoration: _inputDecoration('Product Field'), + hint: const Text('Select Field', style: TextStyle(fontSize: 12)), + selectedItemBuilder: (context) { + return DynamicFieldDefinition.productFields.map((f) { + return Align( + alignment: Alignment.centerLeft, + child: Text( + f.displayName, + style: const TextStyle(fontSize: 12), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ); + }).toList(); + }, + items: DynamicFieldDefinition.productFields.map((f) { + return DropdownMenuItem( + value: f.fieldKey, + child: SizedBox( + width: 210, + child: Text( + '${f.displayName} ({{product.${f.fieldKey}}})', + style: const TextStyle(fontSize: 12), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ); + }).toList(), + onChanged: (val) { + if (val != null) { + final def = DynamicFieldDefinition.find('product', val); + onUpdateElement(elem.copyWithPosition( + fieldKey: val, + prefix: def?.defaultPrefix ?? elem.prefix, + suffix: def?.defaultSuffix ?? elem.suffix, + )); + } + }, + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextFormField( + initialValue: elem.prefix ?? '', + key: ValueKey('prefix_${elem.id}'), + decoration: _inputDecoration('Prefix (e.g. ₹)'), + onChanged: (val) => onUpdateElement(elem.copyWithPosition(prefix: val.isEmpty ? null : val)), + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextFormField( + initialValue: elem.suffix ?? '', + key: ValueKey('suffix_${elem.id}'), + decoration: _inputDecoration('Suffix (e.g. g)'), + onChanged: (val) => onUpdateElement(elem.copyWithPosition(suffix: val.isEmpty ? null : val)), + ), + ), + ], + ), + const SizedBox(height: 8), + TextFormField( + initialValue: elem.fallbackValue, + key: ValueKey('fallback_${elem.id}'), + decoration: _inputDecoration('Fallback if missing (e.g. N/A)'), + onChanged: (val) => onUpdateElement(elem.copyWithPosition(fallbackValue: val)), + ), + const SizedBox(height: 12), + _buildTypographySection(elem), + ] else if (elem is BarcodeElement) ...[ + _buildSectionHeader('BARCODE CONFIGURATION'), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: ChoiceChip( + label: const Text('Dynamic Field', style: TextStyle(fontSize: 11)), + selected: elem.valueType == 'dynamic', + onSelected: (sel) { + if (sel) onUpdateElement(elem.copyWithPosition(valueType: 'dynamic')); + }, + ), + ), + const SizedBox(width: 8), + Expanded( + child: ChoiceChip( + label: const Text('Static Value', style: TextStyle(fontSize: 11)), + selected: elem.valueType == 'static', + onSelected: (sel) { + if (sel) onUpdateElement(elem.copyWithPosition(valueType: 'static')); + }, + ), + ), + ], + ), + const SizedBox(height: 10), + if (elem.valueType == 'dynamic') ...[ + DropdownButtonFormField( + key: ValueKey('barcode_field_${elem.id}_${elem.fieldKey}'), + isExpanded: true, + initialValue: DynamicFieldDefinition.productFields.any((f) => f.fieldKey == elem.fieldKey) + ? elem.fieldKey + : 'barcode', + decoration: _inputDecoration('Barcode Field Source'), + hint: const Text('Select Field', style: TextStyle(fontSize: 12)), + selectedItemBuilder: (context) { + return DynamicFieldDefinition.productFields.map((f) { + return Align( + alignment: Alignment.centerLeft, + child: Text( + f.displayName, + style: const TextStyle(fontSize: 12), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ); + }).toList(); + }, + items: DynamicFieldDefinition.productFields.map((f) { + return DropdownMenuItem( + value: f.fieldKey, + child: SizedBox( + width: 210, + child: Text( + '${f.displayName} ({{product.${f.fieldKey}}})', + style: const TextStyle(fontSize: 12), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ); + }).toList(), + onChanged: (val) { + if (val != null) onUpdateElement(elem.copyWithPosition(fieldKey: val)); + }, + ), + ] else ...[ + TextFormField( + initialValue: elem.staticValue, + key: ValueKey('static_barcode_${elem.id}'), + decoration: _inputDecoration('Static Barcode Value'), + onChanged: (val) => onUpdateElement(elem.copyWithPosition(staticValue: val)), + ), + ], + const SizedBox(height: 12), + DropdownButtonFormField( + key: ValueKey('symbology_${elem.id}_${elem.barcodeType}'), + isExpanded: true, + initialValue: elem.barcodeType, + decoration: _inputDecoration('Barcode Symbology'), + items: const [ + DropdownMenuItem(value: BarcodeType.code128, child: Text('Code 128 (Universal)', style: TextStyle(fontSize: 12), overflow: TextOverflow.ellipsis)), + DropdownMenuItem(value: BarcodeType.ean13, child: Text('EAN-13 (Standard Retail)', style: TextStyle(fontSize: 12), overflow: TextOverflow.ellipsis)), + DropdownMenuItem(value: BarcodeType.code39, child: Text('Code 39', style: TextStyle(fontSize: 12), overflow: TextOverflow.ellipsis)), + DropdownMenuItem(value: BarcodeType.upca, child: Text('UPC-A', style: TextStyle(fontSize: 12), overflow: TextOverflow.ellipsis)), + ], + onChanged: (val) { + if (val != null) onUpdateElement(elem.copyWithPosition(barcodeType: val)); + }, + ), + const SizedBox(height: 8), + SwitchListTile( + dense: true, + contentPadding: EdgeInsets.zero, + title: const Text('Show text below barcode', style: TextStyle(fontSize: 12)), + value: elem.showText, + onChanged: (val) => onUpdateElement(elem.copyWithPosition(showText: val)), + ), + ] else if (elem is QrCodeElement) ...[ + _buildSectionHeader('QR CODE CONFIGURATION'), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: ChoiceChip( + label: const Text('Dynamic Field', style: TextStyle(fontSize: 11)), + selected: elem.valueType == 'dynamic', + onSelected: (sel) { + if (sel) onUpdateElement(elem.copyWithPosition(valueType: 'dynamic')); + }, + ), + ), + const SizedBox(width: 8), + Expanded( + child: ChoiceChip( + label: const Text('Static Value', style: TextStyle(fontSize: 11)), + selected: elem.valueType == 'static', + onSelected: (sel) { + if (sel) onUpdateElement(elem.copyWithPosition(valueType: 'static')); + }, + ), + ), + ], + ), + const SizedBox(height: 10), + if (elem.valueType == 'dynamic') ...[ + DropdownButtonFormField( + key: ValueKey('qr_field_${elem.id}_${elem.fieldKey}'), + isExpanded: true, + initialValue: DynamicFieldDefinition.productFields.any((f) => f.fieldKey == elem.fieldKey) + ? elem.fieldKey + : 'sku', + decoration: _inputDecoration('QR Data Field Source'), + hint: const Text('Select Field', style: TextStyle(fontSize: 12)), + selectedItemBuilder: (context) { + return DynamicFieldDefinition.productFields.map((f) { + return Align( + alignment: Alignment.centerLeft, + child: Text( + f.displayName, + style: const TextStyle(fontSize: 12), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ); + }).toList(); + }, + items: DynamicFieldDefinition.productFields.map((f) { + return DropdownMenuItem( + value: f.fieldKey, + child: SizedBox( + width: 210, + child: Text( + '${f.displayName} ({{product.${f.fieldKey}}})', + style: const TextStyle(fontSize: 12), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ); + }).toList(), + onChanged: (val) { + if (val != null) onUpdateElement(elem.copyWithPosition(fieldKey: val)); + }, + ), + ] else ...[ + TextFormField( + initialValue: elem.staticValue, + key: ValueKey('static_qr_${elem.id}'), + decoration: _inputDecoration('Static QR URL or Text'), + onChanged: (val) => onUpdateElement(elem.copyWithPosition(staticValue: val)), + ), + ], + ] else if (elem is RectangleElement) ...[ + _buildSectionHeader('RECTANGLE STYLING'), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: _buildNumberInput( + label: 'Border (mm)', + value: elem.borderWidthMm, + onChanged: (val) => onUpdateElement(elem.copyWithPosition(borderWidthMm: val)), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _buildNumberInput( + label: 'Corner Radius (mm)', + value: elem.cornerRadiusMm, + onChanged: (val) => onUpdateElement(elem.copyWithPosition(cornerRadiusMm: val)), + ), + ), + ], + ), + ], + ], + ); + } + + Widget _buildTypographySection(dynamic elem) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionHeader('TYPOGRAPHY & ALIGNMENT'), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: _buildNumberInput( + label: 'Font Size (pt)', + value: elem.fontSizePt, + onChanged: (val) => onUpdateElement(elem.copyWithPosition(fontSizePt: val)), + ), + ), + const SizedBox(width: 8), + Row( + children: [ + IconButton.filledTonal( + icon: const Icon(LucideIcons.bold, size: 14), + isSelected: elem.isBold, + onPressed: () => onUpdateElement(elem.copyWithPosition(isBold: !elem.isBold)), + ), + const SizedBox(width: 4), + IconButton.filledTonal( + icon: const Icon(LucideIcons.italic, size: 14), + isSelected: elem.isItalic, + onPressed: () => onUpdateElement(elem.copyWithPosition(isItalic: !elem.isItalic)), + ), + ], + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: SegmentedButton( + segments: const [ + ButtonSegment(value: TextAlign.left, icon: Icon(LucideIcons.alignLeft, size: 14)), + ButtonSegment(value: TextAlign.center, icon: Icon(LucideIcons.alignCenter, size: 14)), + ButtonSegment(value: TextAlign.right, icon: Icon(LucideIcons.alignRight, size: 14)), + ], + selected: {elem.alignment}, + onSelectionChanged: (set) { + onUpdateElement(elem.copyWithPosition(alignment: set.first)); + }, + ), + ), + ], + ), + ], + ); + } + + Widget _buildNumberInput({ + required String label, + required double value, + required Function(double val) onChanged, + double min = 0.0, + double max = 500.0, + }) { + return _NumberInputField( + label: label, + value: value, + min: min, + max: max, + onChanged: onChanged, + ); + } + + Widget _buildSectionHeader(String title) { + return Text( + title, + style: TextStyle( + fontSize: 10.5, + fontWeight: FontWeight.bold, + letterSpacing: 1.0, + color: Colors.grey.shade500, + ), + ); + } + + InputDecoration _inputDecoration(String label) { + return InputDecoration( + labelText: label, + labelStyle: const TextStyle(fontSize: 12), + isDense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + ); + } +} + +class _Preset { + final String label; + final double widthMm; + final double heightMm; + final int columnsAcross; + final double horizontalGapMm; + + const _Preset({ + required this.label, + required this.widthMm, + required this.heightMm, + this.columnsAcross = 1, + this.horizontalGapMm = 2.0, + }); + + bool matches(LabelConfiguration cfg) { + return (cfg.widthMm - widthMm).abs() < 0.1 && + (cfg.heightMm - heightMm).abs() < 0.1 && + cfg.columnsAcross == columnsAcross && + (columnsAcross == 1 || (cfg.horizontalGapMm - horizontalGapMm).abs() < 0.1); + } +} + +const List<_Preset> _standardPresets = [ + _Preset( + label: '50 × 25 mm (2-Up Twin)', + widthMm: 50.0, + heightMm: 25.0, + columnsAcross: 2, + horizontalGapMm: 2.0, + ), + _Preset( + label: '50 × 25 mm (1-Up)', + widthMm: 50.0, + heightMm: 25.0, + columnsAcross: 1, + ), + _Preset( + label: '32 × 19 mm (3-Up)', + widthMm: 32.0, + heightMm: 19.0, + columnsAcross: 3, + horizontalGapMm: 2.0, + ), + _Preset( + label: '40 × 20 mm', + widthMm: 40.0, + heightMm: 20.0, + columnsAcross: 1, + ), + _Preset( + label: '50 × 50 mm', + widthMm: 50.0, + heightMm: 50.0, + columnsAcross: 1, + ), + _Preset( + label: '100 × 50 mm', + widthMm: 100.0, + heightMm: 50.0, + columnsAcross: 1, + ), + _Preset( + label: '100 × 150 mm (4x6")', + widthMm: 100.0, + heightMm: 150.0, + columnsAcross: 1, + ), +]; + +class _CanvasSettingsView extends StatefulWidget { + final LabelDocument document; + final Function(LabelConfiguration config) onUpdateConfig; + final bool isDark; + + const _CanvasSettingsView({ + required this.document, + required this.onUpdateConfig, + required this.isDark, + }); + + @override + State<_CanvasSettingsView> createState() => _CanvasSettingsViewState(); +} + +class _CanvasSettingsViewState extends State<_CanvasSettingsView> { + bool _isCustomSelected = false; + + @override + Widget build(BuildContext context) { + final cfg = widget.document.config; + final matchedPreset = _standardPresets.where((p) => p.matches(cfg)).firstOrNull; + final isCustom = _isCustomSelected || matchedPreset == null; + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + const Icon(LucideIcons.slidersHorizontal, size: 16, color: Colors.blue), + const SizedBox(width: 8), + const Text( + 'LABEL & PRINTER SETUP', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13), + ), + ], + ), + const Divider(height: 16), + + _buildSectionHeader('STANDARD PRESETS'), + const SizedBox(height: 8), + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + ..._standardPresets.map((preset) { + final isSelected = !isCustom && preset.matches(cfg); + return ChoiceChip( + label: Text(preset.label, style: const TextStyle(fontSize: 11)), + selected: isSelected, + onSelected: (_) { + setState(() => _isCustomSelected = false); + widget.onUpdateConfig(cfg.copyWith( + widthMm: preset.widthMm, + heightMm: preset.heightMm, + columnsAcross: preset.columnsAcross, + horizontalGapMm: preset.horizontalGapMm, + )); + }, + ); + }), + ChoiceChip( + avatar: Icon( + LucideIcons.slidersHorizontal, + size: 13, + color: isCustom ? Colors.white : Theme.of(context).colorScheme.primary, + ), + label: const Text('Custom', style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold)), + selected: isCustom, + onSelected: (_) { + setState(() => _isCustomSelected = true); + }, + ), + ], + ), + + // Show manual label size and roll layout inputs ONLY when Custom is selected + if (isCustom) ...[ + const SizedBox(height: 16), + _buildSectionHeader('LABEL DIMENSIONS (SINGLE TAG)'), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: _NumberInputField( + label: 'Width (mm)', + value: cfg.widthMm, + min: 10.0, + max: 200.0, + onChanged: (val) => widget.onUpdateConfig(cfg.copyWith(widthMm: val)), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _NumberInputField( + label: 'Height (mm)', + value: cfg.heightMm, + min: 10.0, + max: 300.0, + onChanged: (val) => widget.onUpdateConfig(cfg.copyWith(heightMm: val)), + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: _NumberInputField( + label: 'Inter-Label Feed Gap (mm)', + value: cfg.verticalGapMm, + min: 0.0, + max: 30.0, + onChanged: (val) => widget.onUpdateConfig(cfg.copyWith(verticalGapMm: val)), + ), + ), + ], + ), + + const SizedBox(height: 16), + _buildSectionHeader('ROLL LAYOUT (MULTI-ACROSS)'), + const SizedBox(height: 8), + DropdownButtonFormField( + isExpanded: true, + initialValue: cfg.columnsAcross, + decoration: _inputDecoration('Labels Across (Columns)'), + items: const [ + DropdownMenuItem( + value: 1, + child: Text('1-Up (Single Label)', style: TextStyle(fontSize: 12)), + ), + DropdownMenuItem( + value: 2, + child: Text('2-Up (Twin Track / 2 Across)', style: TextStyle(fontSize: 12)), + ), + DropdownMenuItem( + value: 3, + child: Text('3-Up (Three Across)', style: TextStyle(fontSize: 12)), + ), + DropdownMenuItem( + value: 4, + child: Text('4-Up (Four Across)', style: TextStyle(fontSize: 12)), + ), + ], + onChanged: (val) { + if (val != null) widget.onUpdateConfig(cfg.copyWith(columnsAcross: val)); + }, + ), + ], + + // For ANY configuration (preset or custom) with multiple columns, ALWAYS expose Track Gap & Roll Width + if (cfg.columnsAcross > 1) ...[ + const SizedBox(height: 16), + _buildSectionHeader('TRACK SPACING (HORIZONTAL GAP)'), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: _NumberInputField( + label: 'Gap Between Columns (mm)', + value: cfg.horizontalGapMm, + min: 0.0, + max: 50.0, + onChanged: (val) => widget.onUpdateConfig(cfg.copyWith(horizontalGapMm: val)), + ), + ), + ], + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: cfg.totalWebWidthMm <= 104.0 + ? Colors.teal.withValues(alpha: 0.08) + : Colors.amber.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: cfg.totalWebWidthMm <= 104.0 + ? Colors.teal.withValues(alpha: 0.3) + : Colors.amber.shade700, + ), + ), + child: Row( + children: [ + Icon( + cfg.totalWebWidthMm <= 104.0 ? LucideIcons.check : LucideIcons.alertTriangle, + size: 14, + color: cfg.totalWebWidthMm <= 104.0 ? Colors.teal : Colors.amber.shade800, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Total Roll Width: ${cfg.totalWebWidthMm.toStringAsFixed(1)} mm' + '${cfg.totalWebWidthMm <= 104.0 ? " (Fits 4\" printer, e.g. ZT411)" : " (Exceeds 104mm standard)"}', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: cfg.totalWebWidthMm <= 104.0 ? Colors.teal.shade800 : Colors.amber.shade900, + ), + ), + ), + ], + ), + ), + ], + + const SizedBox(height: 16), + _buildSectionHeader('PRINTER CONFIGURATION'), + const SizedBox(height: 8), + DropdownButtonFormField( + isExpanded: true, + initialValue: cfg.dpi, + decoration: _inputDecoration('Resolution / DPI'), + items: const [ + DropdownMenuItem(value: 203, child: Text('203 DPI (Standard Thermal, e.g. ZT411)', style: TextStyle(fontSize: 12), overflow: TextOverflow.ellipsis)), + DropdownMenuItem(value: 300, child: Text('300 DPI (Citizen CL-E331 / High Precision)', style: TextStyle(fontSize: 12), overflow: TextOverflow.ellipsis)), + DropdownMenuItem(value: 600, child: Text('600 DPI (Ultra Fine)', style: TextStyle(fontSize: 12), overflow: TextOverflow.ellipsis)), + ], + onChanged: (val) { + if (val != null) widget.onUpdateConfig(cfg.copyWith(dpi: val)); + }, + ), + const SizedBox(height: 12), + DropdownButtonFormField( + isExpanded: true, + initialValue: cfg.printerLanguage, + decoration: _inputDecoration('Command Language'), + items: const [ + DropdownMenuItem(value: 'ZPL', child: Text('ZPL (Zebra / TSC / Honeywell)', style: TextStyle(fontSize: 12), overflow: TextOverflow.ellipsis)), + DropdownMenuItem(value: 'TSPL', child: Text('TSPL (TSC / GPrinter)', style: TextStyle(fontSize: 12), overflow: TextOverflow.ellipsis)), + ], + onChanged: (val) { + if (val != null) widget.onUpdateConfig(cfg.copyWith(printerLanguage: val)); + }, + ), + + const SizedBox(height: 24), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.blue.withValues(alpha: 0.2)), + ), + child: Row( + children: [ + const Icon(LucideIcons.info, size: 16, color: Colors.blue), + const SizedBox(width: 10), + Expanded( + child: Text( + cfg.columnsAcross > 1 + ? 'Print Width: ${cfg.totalWebWidthDots} dots (${cfg.totalWebWidthMm.toStringAsFixed(1)}mm) × ${cfg.heightDots} dots (${cfg.columnsAcross}-Across)' + : 'Calculated Dots: ${cfg.widthDots} × ${cfg.heightDots} dots at ${cfg.dpi} DPI', + style: const TextStyle(fontSize: 11, color: Colors.blue), + ), + ), + ], + ), + ), + ], + ); + } + + Widget _buildSectionHeader(String title) { + return Text( + title, + style: TextStyle( + fontSize: 10.5, + fontWeight: FontWeight.bold, + letterSpacing: 1.0, + color: Colors.grey.shade500, + ), + ); + } + + InputDecoration _inputDecoration(String label) { + return InputDecoration( + labelText: label, + labelStyle: const TextStyle(fontSize: 12), + isDense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + ); + } +} + +class _NumberInputField extends StatefulWidget { + final String label; + final double value; + final ValueChanged onChanged; + final double min; + final double max; + + const _NumberInputField({ + required this.label, + required this.value, + required this.onChanged, + this.min = 0.0, + this.max = 500.0, + }); + + @override + State<_NumberInputField> createState() => _NumberInputFieldState(); +} + +class _NumberInputFieldState extends State<_NumberInputField> { + late TextEditingController _controller; + late FocusNode _focusNode; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: _format(widget.value)); + _focusNode = FocusNode(); + _focusNode.addListener(_onFocusChange); + } + + String _format(double v) { + return (v % 1 == 0) ? v.toInt().toString() : v.toStringAsFixed(1); + } + + void _onFocusChange() { + if (!_focusNode.hasFocus) { + final parsed = double.tryParse(_controller.text.trim()); + if (parsed != null && parsed >= widget.min && parsed <= widget.max) { + widget.onChanged(parsed); + } else { + _controller.text = _format(widget.value); + } + } + } + + @override + void didUpdateWidget(covariant _NumberInputField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.value != widget.value && !_focusNode.hasFocus) { + _controller.text = _format(widget.value); + } + } + + @override + void dispose() { + _focusNode.removeListener(_onFocusChange); + _focusNode.dispose(); + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return TextFormField( + controller: _controller, + focusNode: _focusNode, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: InputDecoration( + labelText: widget.label, + labelStyle: const TextStyle(fontSize: 12), + isDense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + ), + style: const TextStyle(fontSize: 12), + onChanged: (val) { + final parsed = double.tryParse(val.trim()); + if (parsed != null && parsed >= widget.min && parsed <= widget.max) { + widget.onChanged(parsed); + } + }, + onFieldSubmitted: (val) { + final parsed = double.tryParse(val.trim()); + if (parsed != null && parsed >= widget.min && parsed <= widget.max) { + widget.onChanged(parsed); + } + }, + ); + } +} diff --git a/kifi-app/lib/features/barcode_designer/presentation/widgets/printer_command_preview.dart b/kifi-app/lib/features/barcode_designer/presentation/widgets/printer_command_preview.dart new file mode 100644 index 0000000..c7a662d --- /dev/null +++ b/kifi-app/lib/features/barcode_designer/presentation/widgets/printer_command_preview.dart @@ -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 createState() => _PrinterCommandPreviewState(); +} + +class _PrinterCommandPreviewState extends State { + 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, + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/kifi-app/lib/features/barcode_designer/presentation/widgets/product_picker_dialog.dart b/kifi-app/lib/features/barcode_designer/presentation/widgets/product_picker_dialog.dart new file mode 100644 index 0000000..4369975 --- /dev/null +++ b/kifi-app/lib/features/barcode_designer/presentation/widgets/product_picker_dialog.dart @@ -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 show(BuildContext context) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => const ProductPickerDialog(), + ); + } + + @override + ConsumerState createState() => _ProductPickerDialogState(); +} + +class _ProductPickerDialogState extends ConsumerState { + 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, + ), + ], + ), + ], + ), + ), + ), + ), + ); + }, + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/kifi-app/lib/features/barcode_designer/presentation/widgets/product_variables_panel.dart b/kifi-app/lib/features/barcode_designer/presentation/widgets/product_variables_panel.dart new file mode 100644 index 0000000..62aa71c --- /dev/null +++ b/kifi-app/lib/features/barcode_designer/presentation/widgets/product_variables_panel.dart @@ -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> 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( + 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), + ], + ), + ); + } +} diff --git a/kifi-app/lib/features/barcode_designer/presentation/widgets/test_print_modal.dart b/kifi-app/lib/features/barcode_designer/presentation/widgets/test_print_modal.dart new file mode 100644 index 0000000..4fd7477 --- /dev/null +++ b/kifi-app/lib/features/barcode_designer/presentation/widgets/test_print_modal.dart @@ -0,0 +1,1157 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../domain/label_document.dart'; +import '../../services/printer_service.dart'; +import '../../services/zpl_compiler.dart'; +import '../../services/template_data_resolver.dart'; +import '../../../inventory/domain/product.dart'; +import '../../../inventory/domain/inventory_item.dart'; +import 'product_picker_dialog.dart'; + +class TestPrintModal extends StatefulWidget { + final LabelDocument document; + final Product? sampleProduct; + final InventoryItem? sampleInventoryItem; + final Function(Product p, InventoryItem? item) onSelectProduct; + final Function(LabelConfiguration cfg)? onUpdateConfig; + + const TestPrintModal({ + super.key, + required this.document, + required this.sampleProduct, + this.sampleInventoryItem, + required this.onSelectProduct, + this.onUpdateConfig, + }); + + static Future show( + BuildContext context, { + required LabelDocument document, + required Product? sampleProduct, + InventoryItem? sampleInventoryItem, + required Function(Product p, InventoryItem? item) onSelectProduct, + Function(LabelConfiguration cfg)? onUpdateConfig, + }) { + return showDialog( + context: context, + builder: (_) => TestPrintModal( + document: document, + sampleProduct: sampleProduct, + sampleInventoryItem: sampleInventoryItem, + onSelectProduct: onSelectProduct, + onUpdateConfig: onUpdateConfig, + ), + ); + } + + @override + State createState() => _TestPrintModalState(); +} + +class _TestPrintModalState extends State { + // Connection Mode: 'auto' (Wi-Fi Auto-detect) or 'manual' (Manual IP) + String _connectionMode = 'auto'; + + // Wi-Fi Auto Detection state + bool _isScanningWifi = false; + List _discoveredPrinters = []; + PrinterDevice? _selectedDiscoveredPrinter; + + // Manual configuration controllers + final TextEditingController _ipCtrl = TextEditingController(text: '192.168.1.100'); + final TextEditingController _portCtrl = TextEditingController(text: '9100'); + String _selectedLanguage = 'ZPL'; + int _selectedDpi = 300; + + // State flags + bool _isPrinting = false; + String? _statusMessage; + bool _isSuccess = false; + bool _showRawCommands = false; + + @override + void initState() { + super.initState(); + _selectedLanguage = widget.document.config.printerLanguage; + if (_selectedLanguage != 'ZPL' && _selectedLanguage != 'TSPL') { + _selectedLanguage = 'ZPL'; + } + _selectedDpi = widget.document.config.dpi; + if (_selectedDpi != 203 && _selectedDpi != 300 && _selectedDpi != 600) { + _selectedDpi = 203; + } + _initSettingsAndScan(); + } + + Future _initSettingsAndScan() async { + final prefs = await PrinterService.loadPrinterPreferences(); + if (mounted) { + setState(() { + _connectionMode = prefs['mode'] as String? ?? 'auto'; + _ipCtrl.text = prefs['ip'] as String? ?? '192.168.1.100'; + _portCtrl.text = (prefs['port'] as int? ?? 9100).toString(); + }); + } + + if (_connectionMode == 'auto') { + _scanWifiPrinters(); + } + } + + Future _scanWifiPrinters() async { + if (_isScanningWifi) return; + setState(() { + _isScanningWifi = true; + _statusMessage = null; + }); + + final printers = await PrinterService.discoverWifiPrinters(); + + if (mounted) { + setState(() { + _isScanningWifi = false; + _discoveredPrinters = printers; + + if (printers.isNotEmpty) { + // If previously saved name matches, pick it, else pick the first + _selectedDiscoveredPrinter = printers.first; + if (_selectedDiscoveredPrinter!.ipAddress.isNotEmpty) { + _ipCtrl.text = _selectedDiscoveredPrinter!.ipAddress; + } + } + }); + } + } + + @override + void dispose() { + _ipCtrl.dispose(); + _portCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final screenWidth = MediaQuery.of(context).size.width; + final isMobile = screenWidth < 600; + + // Build effective document with selected resolution and printer language + final effectiveDoc = widget.document.copyWith( + config: widget.document.config.copyWith( + dpi: _selectedDpi, + printerLanguage: _selectedLanguage, + ), + ); + + // Run live validation + final validation = PrinterService.validateDocument( + document: effectiveDoc, + sampleProduct: widget.sampleProduct, + isPrinting: true, + ); + + // Resolve sample product data + final resolvedDoc = TemplateDataResolver.resolveDocument( + document: effectiveDoc, + product: widget.sampleProduct, + inventoryItem: widget.sampleInventoryItem, + ); + + final generatedCommands = PrintCompiler.compile( + document: resolvedDoc, + overrideLanguage: _selectedLanguage, + copies: 1, + ); + + return Dialog( + insetPadding: EdgeInsets.symmetric( + horizontal: isMobile ? 12 : 24, + vertical: 20, + ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + backgroundColor: isDark ? const Color(0xFF0F172A) : Colors.white, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: 580, + maxHeight: MediaQuery.of(context).size.height * 0.9, + ), + child: SingleChildScrollView( + padding: EdgeInsets.all(isMobile ? 16 : 22), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.green.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon(LucideIcons.printer, color: Colors.green, size: 22), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Test Print Label', + style: TextStyle(fontSize: 17, fontWeight: FontWeight.bold), + ), + Text( + 'Verify physical alignment before saving (Prints 1 label)', + style: TextStyle(fontSize: 11.5, color: Colors.grey.shade500), + ), + ], + ), + ), + IconButton( + icon: const Icon(LucideIcons.x, size: 20), + onPressed: () => Navigator.pop(context), + ), + ], + ), + + const Divider(height: 20), + + // Sample Product Status Card + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E293B) : Colors.grey.shade100, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: widget.sampleProduct != null + ? Colors.green.withValues(alpha: 0.3) + : Colors.amber.withValues(alpha: 0.3), + ), + ), + child: Row( + children: [ + Icon( + widget.sampleProduct != null ? LucideIcons.circleCheck : LucideIcons.circleAlert, + color: widget.sampleProduct != null ? Colors.green : Colors.amber, + size: 20, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.sampleProduct != null + ? 'Sample: ${widget.sampleProduct!.name}' + : 'No Sample Product Selected', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12.5), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 1), + Text( + widget.sampleProduct != null + ? ((widget.sampleProduct!.sku?.isNotEmpty == true && widget.sampleProduct!.barcode?.isNotEmpty == true) + ? 'SKU: ${widget.sampleProduct!.sku} • Barcode: ${widget.sampleProduct!.barcode}' + : (widget.sampleProduct!.sku?.isNotEmpty == true + ? 'SKU: ${widget.sampleProduct!.sku}' + : (widget.sampleProduct!.barcode?.isNotEmpty == true + ? 'Barcode: ${widget.sampleProduct!.barcode}' + : 'Sample item auto-resolved'))) + : 'Dynamic fields will use placeholders unless resolved', + style: TextStyle(color: Colors.grey.shade500, fontSize: 11), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(width: 6), + TextButton.icon( + style: TextButton.styleFrom( + foregroundColor: Colors.blue, + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + icon: const Icon(LucideIcons.packageSearch, size: 14), + label: Text( + widget.sampleProduct != null ? 'Change' : 'Select', + style: const TextStyle(fontSize: 11.5, fontWeight: FontWeight.w600), + ), + onPressed: () async { + final result = await ProductPickerDialog.show(context); + if (result != null) { + widget.onSelectProduct(result.product, result.inventoryItem); + setState(() {}); + } + }, + ), + ], + ), + ), + + const SizedBox(height: 10), + + // Roll Layout & Web Width Banner (Responsive Wrap) + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E293B) : Colors.blue.shade50, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.blue.withValues(alpha: 0.2)), + ), + child: Wrap( + spacing: 8, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(LucideIcons.layoutGrid, size: 14, color: Colors.blue), + const SizedBox(width: 5), + Text( + 'Label: ${effectiveDoc.config.widthMm.toStringAsFixed(effectiveDoc.config.widthMm % 1 == 0 ? 0 : 1)}×${effectiveDoc.config.heightMm.toStringAsFixed(effectiveDoc.config.heightMm % 1 == 0 ? 0 : 1)} mm', + style: const TextStyle(fontSize: 11.5, fontWeight: FontWeight.bold), + ), + ], + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: effectiveDoc.config.columnsAcross > 1 + ? Colors.purple.withValues(alpha: 0.15) + : Colors.grey.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + effectiveDoc.config.columnsAcross > 1 + ? '${effectiveDoc.config.columnsAcross}-Across (${effectiveDoc.config.columnsAcross == 2 ? "Twin Track" : "${effectiveDoc.config.columnsAcross}-Up"})' + : '1-Up (Single)', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: effectiveDoc.config.columnsAcross > 1 ? Colors.purple : Colors.grey.shade700, + ), + ), + ), + Text( + '• Roll: ${effectiveDoc.config.totalWebWidthMm.toStringAsFixed(1)} mm', + style: TextStyle(fontSize: 11, color: Colors.grey.shade600, fontWeight: FontWeight.w500), + ), + ], + ), + ), + + const SizedBox(height: 12), + + // Validation Alerts + if (validation.hasErrors) ...[ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.red.withValues(alpha: 0.3)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(LucideIcons.octagonAlert, color: Colors.red, size: 16), + const SizedBox(width: 8), + Text( + 'Cannot Test Print (${validation.errors.length} errors)', + style: const TextStyle( + color: Colors.red, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ], + ), + const SizedBox(height: 4), + ...validation.errors.map((err) => Padding( + padding: const EdgeInsets.only(left: 24, top: 2), + child: Text('• $err', style: const TextStyle(color: Colors.red, fontSize: 11)), + )), + ], + ), + ), + const SizedBox(height: 10), + ], + + if (validation.hasWarnings) ...[ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.amber.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.amber.withValues(alpha: 0.2)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...validation.warnings.map((w) => Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(LucideIcons.triangleAlert, color: Colors.amber, size: 14), + const SizedBox(width: 6), + Expanded(child: Text(w, style: const TextStyle(fontSize: 10.5, color: Colors.amber))), + ], + )), + ], + ), + ), + const SizedBox(height: 10), + ], + + // PRINTER CONNECTION SECTION + // Mode Selector Tabs (Auto-Detect vs Manual IP) + Container( + decoration: BoxDecoration( + color: isDark ? Colors.white.withValues(alpha: 0.06) : Colors.grey.shade200, + borderRadius: BorderRadius.circular(10), + ), + padding: const EdgeInsets.all(3), + child: Row( + children: [ + Expanded( + child: _buildModeTab( + icon: LucideIcons.wifi, + title: 'Auto-Detect (Wi-Fi)', + isSelected: _connectionMode == 'auto', + onTap: () { + setState(() => _connectionMode = 'auto'); + if (_discoveredPrinters.isEmpty) { + _scanWifiPrinters(); + } + }, + isDark: isDark, + ), + ), + Expanded( + child: _buildModeTab( + icon: LucideIcons.network, + title: 'Manual IP / Port', + isSelected: _connectionMode == 'manual', + onTap: () => setState(() => _connectionMode = 'manual'), + isDark: isDark, + ), + ), + ], + ), + ), + + const SizedBox(height: 10), + + // Auto-Detect Mode View + if (_connectionMode == 'auto') ...[ + _buildAutoDetectView(isDark), + ] else ...[ + // Manual IP Mode View + _buildManualModeView(isDark), + ], + + const SizedBox(height: 12), + + // Applied Print Settings Feedback Card + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E293B) : const Color(0xFFF1F5F9), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: isDark ? Colors.white12 : Colors.grey.shade300, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(LucideIcons.checkCheck, size: 14, color: Colors.green), + const SizedBox(width: 6), + const Text( + 'APPLIED PRINT COMMAND SETTINGS', + style: TextStyle( + fontSize: 10.5, + fontWeight: FontWeight.bold, + letterSpacing: 0.6, + color: Colors.grey, + ), + ), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + '$_selectedLanguage • $_selectedDpi DPI', + style: const TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: Colors.blue, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Wrap( + spacing: 12, + runSpacing: 6, + children: [ + _buildAppliedSettingPill( + icon: LucideIcons.scanLine, + label: 'Tag Size', + value: '${effectiveDoc.config.widthMm.toStringAsFixed(effectiveDoc.config.widthMm % 1 == 0 ? 0 : 1)} × ${effectiveDoc.config.heightMm.toStringAsFixed(effectiveDoc.config.heightMm % 1 == 0 ? 0 : 1)} mm', + ), + _buildAppliedSettingPill( + icon: LucideIcons.arrowDownUp, + label: 'Feed Gap', + value: '${effectiveDoc.config.verticalGapMm.toStringAsFixed(effectiveDoc.config.verticalGapMm % 1 == 0 ? 0 : 1)} mm', + ), + _buildAppliedSettingPill( + icon: LucideIcons.columns2, + label: 'Track', + value: effectiveDoc.config.columnsAcross > 1 + ? '${effectiveDoc.config.columnsAcross}-Across (${effectiveDoc.config.horizontalGapMm.toStringAsFixed(1)}mm track gap)' + : '1-Up Single', + ), + _buildAppliedSettingPill( + icon: LucideIcons.ruler, + label: 'Roll Width', + value: '${effectiveDoc.config.totalWebWidthMm.toStringAsFixed(1)} mm (${effectiveDoc.config.totalWebWidthDots} dots)', + ), + _buildAppliedSettingPill( + icon: LucideIcons.binary, + label: 'Canvas Dots', + value: '${effectiveDoc.config.widthDots} × ${effectiveDoc.config.heightDots} dots', + ), + ], + ), + ], + ), + ), + + const SizedBox(height: 12), + + // Collapsible Raw ZPL/TSPL Commands + InkWell( + onTap: () => setState(() => _showRawCommands = !_showRawCommands), + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Icon( + _showRawCommands ? LucideIcons.chevronDown : LucideIcons.chevronRight, + size: 15, + color: Colors.grey.shade500, + ), + const SizedBox(width: 4), + Text( + 'View Raw Printer Commands ($_selectedLanguage)', + style: TextStyle( + fontSize: 11, + color: Colors.grey.shade600, + fontWeight: FontWeight.w500, + ), + ), + const Spacer(), + if (_showRawCommands) + IconButton( + icon: const Icon(LucideIcons.copy, size: 14), + tooltip: 'Copy commands', + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: () { + Clipboard.setData(ClipboardData(text: generatedCommands)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Commands copied to clipboard'), duration: Duration(seconds: 1)), + ); + }, + ), + ], + ), + ), + ), + + if (_showRawCommands) ...[ + const SizedBox(height: 6), + Container( + constraints: const BoxConstraints(maxHeight: 110, minHeight: 60), + width: double.infinity, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF020617) : const Color(0xFF1E293B), + borderRadius: BorderRadius.circular(8), + ), + child: SingleChildScrollView( + child: Text( + generatedCommands, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 10.5, + height: 1.35, + color: Color(0xFF38BDF8), + ), + ), + ), + ), + ], + + if (_statusMessage != null) ...[ + const SizedBox(height: 10), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: _isSuccess + ? Colors.green.withValues(alpha: 0.1) + : Colors.red.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: _isSuccess + ? Colors.green.withValues(alpha: 0.25) + : Colors.red.withValues(alpha: 0.25), + ), + ), + child: Row( + children: [ + Icon( + _isSuccess ? LucideIcons.circleCheck : LucideIcons.circleAlert, + color: _isSuccess ? Colors.green : Colors.red, + size: 16, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _statusMessage!, + style: TextStyle( + fontSize: 11.5, + fontWeight: FontWeight.w600, + color: _isSuccess ? Colors.green.shade700 : Colors.red.shade700, + ), + ), + ), + ], + ), + ), + ], + + const SizedBox(height: 16), + + // Actions + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + OutlinedButton( + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + onPressed: () => Navigator.pop(context), + child: const Text('Close'), + ), + const SizedBox(width: 10), + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + icon: _isPrinting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2), + ) + : const Icon(LucideIcons.printer, size: 16), + label: Text( + _isPrinting ? 'Sending...' : 'Send Test Label', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13), + ), + onPressed: (!validation.isValid || _isPrinting) + ? null + : () async { + setState(() { + _isPrinting = true; + _statusMessage = null; + }); + + final targetIp = _connectionMode == 'auto' + ? (_selectedDiscoveredPrinter?.ipAddress ?? _ipCtrl.text.trim()) + : _ipCtrl.text.trim(); + + final targetPort = _connectionMode == 'auto' + ? (_selectedDiscoveredPrinter?.port ?? 9100) + : (int.tryParse(_portCtrl.text) ?? 9100); + + final printer = PrinterDevice( + id: _selectedDiscoveredPrinter?.id ?? 'printer_1', + name: _selectedDiscoveredPrinter?.name ?? 'Thermal Printer', + ipAddress: targetIp, + port: targetPort, + language: _selectedLanguage, + connectionType: _connectionMode, + ); + + // Save preferences + await PrinterService.savePrinterPreferences( + mode: _connectionMode, + ip: targetIp, + port: targetPort, + language: _selectedLanguage, + dpi: _selectedDpi, + name: printer.name, + ); + + final result = await PrinterService.sendToPrinter( + printerCommands: generatedCommands, + printer: printer, + ); + + if (mounted) { + setState(() { + _isPrinting = false; + _isSuccess = result['success'] as bool? ?? false; + _statusMessage = result['message'] as String?; + }); + } + }, + ), + ], + ), + ], + ), + ), + ), + ); + } + + Widget _buildModeTab({ + required IconData icon, + required String title, + required bool isSelected, + required VoidCallback onTap, + required bool isDark, + }) { + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(vertical: 7, horizontal: 4), + decoration: BoxDecoration( + color: isSelected + ? (isDark ? const Color(0xFF1E293B) : Colors.white) + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + boxShadow: isSelected + ? [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 4, + offset: const Offset(0, 1), + ) + ] + : null, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: 14, + color: isSelected ? Theme.of(context).colorScheme.primary : Colors.grey.shade500, + ), + const SizedBox(width: 6), + Flexible( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 11.5, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + color: isSelected + ? (isDark ? Colors.white : Colors.black87) + : Colors.grey.shade500, + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildAutoDetectView(bool isDark) { + if (_isScanningWifi) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E293B) : Colors.grey.shade50, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.grey.shade300), + ), + child: Row( + children: [ + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Scanning Wi-Fi network for printers...', + style: TextStyle(fontWeight: FontWeight.w600, fontSize: 12), + ), + Text( + 'Checking AirPrint, Bonjour and network broadcast services', + style: TextStyle(fontSize: 10.5, color: Colors.grey.shade500), + ), + ], + ), + ), + ], + ), + ); + } + + if (_discoveredPrinters.isNotEmpty) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E293B) : Colors.green.shade50.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.green.withValues(alpha: 0.3)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(LucideIcons.wifi, size: 16, color: Colors.green), + const SizedBox(width: 8), + Text( + 'Found ${_discoveredPrinters.length} Wi-Fi Printer${_discoveredPrinters.length > 1 ? "s" : ""}', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: Colors.green), + ), + const Spacer(), + TextButton.icon( + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + icon: const Icon(LucideIcons.rotateCw, size: 12), + label: const Text('Rescan', style: TextStyle(fontSize: 11)), + onPressed: _scanWifiPrinters, + ), + ], + ), + const SizedBox(height: 8), + DropdownButtonFormField( + isExpanded: true, + initialValue: _selectedDiscoveredPrinter ?? (_discoveredPrinters.isNotEmpty ? _discoveredPrinters.first : null), + decoration: InputDecoration( + isDense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + ), + items: _discoveredPrinters.map((p) { + return DropdownMenuItem( + value: p, + child: Text( + '${p.name}${p.ipAddress.isNotEmpty ? " (${p.ipAddress})" : ""}', + style: const TextStyle(fontSize: 12), + overflow: TextOverflow.ellipsis, + ), + ); + }).toList(), + onChanged: (p) { + if (p != null) { + setState(() { + _selectedDiscoveredPrinter = p; + _selectedLanguage = p.language; + if (p.ipAddress.isNotEmpty) { + _ipCtrl.text = p.ipAddress; + } + }); + } + }, + ), + ], + ), + ); + } + + // No printers found via broadcast + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E293B) : Colors.amber.shade50.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.amber.withValues(alpha: 0.3)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(LucideIcons.wifiOff, size: 16, color: Colors.amber), + const SizedBox(width: 8), + const Expanded( + child: Text( + 'No broadcast printer detected on this Wi-Fi network', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12), + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Ensure your mobile device is on the same 2.4/5GHz Wi-Fi as your thermal printer, or use Manual IP.', + style: TextStyle(fontSize: 11, color: Colors.grey.shade600), + ), + const SizedBox(height: 8), + Row( + children: [ + OutlinedButton.icon( + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + minimumSize: Size.zero, + ), + icon: const Icon(LucideIcons.rotateCw, size: 13), + label: const Text('Scan Again', style: TextStyle(fontSize: 11)), + onPressed: _scanWifiPrinters, + ), + const SizedBox(width: 8), + TextButton.icon( + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + minimumSize: Size.zero, + ), + icon: const Icon(LucideIcons.pencil, size: 13), + label: const Text('Enter IP Manually', style: TextStyle(fontSize: 11)), + onPressed: () => setState(() => _connectionMode = 'manual'), + ), + ], + ), + ], + ), + ); + } + + Widget _buildManualModeView(bool isDark) { + return Column( + children: [ + Row( + children: [ + Expanded( + child: TextFormField( + controller: _ipCtrl, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: InputDecoration( + labelText: 'Printer IP Address', + hintText: '192.168.1.100', + isDense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + ), + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 84, + child: TextFormField( + controller: _portCtrl, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: 'Port', + hintText: '9100', + isDense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: DropdownButtonFormField( + isExpanded: true, + initialValue: _selectedLanguage, + selectedItemBuilder: (context) => [ + const Align( + alignment: Alignment.centerLeft, + child: Text( + 'ZPL (Zebra)', + style: TextStyle(fontSize: 11.5), + overflow: TextOverflow.ellipsis, + ), + ), + const Align( + alignment: Alignment.centerLeft, + child: Text( + 'TSPL (TSC)', + style: TextStyle(fontSize: 11.5), + overflow: TextOverflow.ellipsis, + ), + ), + ], + decoration: InputDecoration( + labelText: 'Printer Language', + isDense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + ), + items: const [ + DropdownMenuItem( + value: 'ZPL', + child: Text( + 'ZPL (Zebra / Citizen / TSC)', + style: TextStyle(fontSize: 11.5), + overflow: TextOverflow.ellipsis, + ), + ), + DropdownMenuItem( + value: 'TSPL', + child: Text( + 'TSPL (TSC / GPrinter / Argox)', + style: TextStyle(fontSize: 11.5), + overflow: TextOverflow.ellipsis, + ), + ), + ], + onChanged: (val) { + if (val != null) { + setState(() => _selectedLanguage = val); + widget.onUpdateConfig?.call(widget.document.config.copyWith( + printerLanguage: val, + dpi: _selectedDpi, + )); + } + }, + ), + ), + const SizedBox(width: 8), + Expanded( + child: DropdownButtonFormField( + isExpanded: true, + initialValue: _selectedDpi, + selectedItemBuilder: (context) => [ + const Align( + alignment: Alignment.centerLeft, + child: Text( + '300 DPI (High Res)', + style: TextStyle(fontSize: 11.5), + overflow: TextOverflow.ellipsis, + ), + ), + const Align( + alignment: Alignment.centerLeft, + child: Text( + '203 DPI (Standard)', + style: TextStyle(fontSize: 11.5), + overflow: TextOverflow.ellipsis, + ), + ), + const Align( + alignment: Alignment.centerLeft, + child: Text( + '600 DPI (Ultra Fine)', + style: TextStyle(fontSize: 11.5), + overflow: TextOverflow.ellipsis, + ), + ), + ], + decoration: InputDecoration( + labelText: 'Resolution / DPI', + isDense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + ), + items: const [ + DropdownMenuItem( + value: 300, + child: Text( + '300 DPI (Citizen CL-E331 / High Res)', + style: TextStyle(fontSize: 11.5), + overflow: TextOverflow.ellipsis, + ), + ), + DropdownMenuItem( + value: 203, + child: Text( + '203 DPI (Standard Thermal)', + style: TextStyle(fontSize: 11.5), + overflow: TextOverflow.ellipsis, + ), + ), + DropdownMenuItem( + value: 600, + child: Text( + '600 DPI (Ultra Fine)', + style: TextStyle(fontSize: 11.5), + overflow: TextOverflow.ellipsis, + ), + ), + ], + onChanged: (val) { + if (val != null) { + setState(() => _selectedDpi = val); + widget.onUpdateConfig?.call(widget.document.config.copyWith( + dpi: val, + printerLanguage: _selectedLanguage, + )); + } + }, + ), + ), + ], + ), + ], + ); + } + + Widget _buildAppliedSettingPill({ + required IconData icon, + required String label, + required String value, + }) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 12, color: Colors.grey.shade500), + const SizedBox(width: 4), + Text( + '$label: ', + style: TextStyle(fontSize: 11, color: Colors.grey.shade600), + ), + Text( + value, + style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600), + ), + ], + ); + } +} diff --git a/kifi-app/lib/features/barcode_designer/providers/barcode_designer_provider.dart b/kifi-app/lib/features/barcode_designer/providers/barcode_designer_provider.dart new file mode 100644 index 0000000..140937b --- /dev/null +++ b/kifi-app/lib/features/barcode_designer/providers/barcode_designer_provider.dart @@ -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 undoStack; + final List 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? undoStack, + List? 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 { + @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.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.from(state.undoStack); + final previousDoc = newUndo.removeLast(); + final newRedo = List.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.from(state.redoStack); + final nextDoc = newRedo.removeLast(); + final newUndo = List.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.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.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.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.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.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.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(() { + return BarcodeDesignerNotifier(); +}); diff --git a/kifi-app/lib/features/barcode_designer/providers/barcode_templates_provider.dart b/kifi-app/lib/features/barcode_designer/providers/barcode_templates_provider.dart new file mode 100644 index 0000000..74874b0 --- /dev/null +++ b/kifi-app/lib/features/barcode_designer/providers/barcode_templates_provider.dart @@ -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> { + @override + FutureOr> build() async { + return _fetchTemplates(); + } + + Future> _fetchTemplates() async { + try { + final response = await DioClient().dio.get('/barcode-templates'); + if (response.statusCode == 200) { + final List data = response.data; + return data.map((e) => BarcodeTemplate.fromJson(e)).toList(); + } + return []; + } catch (e) { + print('Error fetching barcode templates: $e'); + return []; + } + } + + Future refresh() async { + state = const AsyncValue.loading(); + try { + final templates = await _fetchTemplates(); + state = AsyncValue.data(templates); + } catch (e, stack) { + state = AsyncValue.error(e, stack); + } + } + + Future 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 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>(() { + return BarcodeTemplatesNotifier(); +}); diff --git a/kifi-app/lib/features/barcode_designer/services/printer_service.dart b/kifi-app/lib/features/barcode_designer/services/printer_service.dart new file mode 100644 index 0000000..4666ce1 --- /dev/null +++ b/kifi-app/lib/features/barcode_designer/services/printer_service.dart @@ -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 errors; + final List 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 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> 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> discoverWifiPrinters() async { + final discovered = []; + + 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 = []; + final warnings = []; + + 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> 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, + }; + } + } + } +} diff --git a/kifi-app/lib/features/barcode_designer/services/template_data_resolver.dart b/kifi-app/lib/features/barcode_designer/services/template_data_resolver.dart new file mode 100644 index 0000000..c9c2790 --- /dev/null +++ b/kifi-app/lib/features/barcode_designer/services/template_data_resolver.dart @@ -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 = []; + + 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); + } +} diff --git a/kifi-app/lib/features/barcode_designer/services/zpl_compiler.dart b/kifi-app/lib/features/barcode_designer/services/zpl_compiler.dart new file mode 100644 index 0000000..600c0db --- /dev/null +++ b/kifi-app/lib/features/barcode_designer/services/zpl_compiler.dart @@ -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.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(); + } +} diff --git a/kifi-app/lib/features/business/presentation/hub/business_hub_screen.dart b/kifi-app/lib/features/business/presentation/hub/business_hub_screen.dart index 08720d5..e238e81 100644 --- a/kifi-app/lib/features/business/presentation/hub/business_hub_screen.dart +++ b/kifi-app/lib/features/business/presentation/hub/business_hub_screen.dart @@ -13,6 +13,7 @@ import '../widgets/business_profile_form_sheet.dart'; import '../../../inventory/providers/products_provider.dart'; import '../../../vendor/presentation/vendors_list_screen.dart'; import '../../../vendor/presentation/purchase_orders_list_screen.dart'; +import '../widgets/utilities_section.dart'; class BusinessHubScreen extends ConsumerWidget { const BusinessHubScreen({super.key}); @@ -233,6 +234,8 @@ class BusinessHubScreen extends ConsumerWidget { ); }, ), + const SizedBox(height: 32), + const UtilitiesSection(), if (showInventory) ...[ const SizedBox(height: 32), Text( diff --git a/kifi-app/lib/features/business/presentation/widgets/utilities_section.dart b/kifi-app/lib/features/business/presentation/widgets/utilities_section.dart new file mode 100644 index 0000000..01d20b0 --- /dev/null +++ b/kifi-app/lib/features/business/presentation/widgets/utilities_section.dart @@ -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? 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 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(), + ), + ], + ], + ), + ), + ), + ), + ); + } +} diff --git a/kifi-app/lib/features/inventory/domain/product.dart b/kifi-app/lib/features/inventory/domain/product.dart index 46351b8..a2cab54 100644 --- a/kifi-app/lib/features/inventory/domain/product.dart +++ b/kifi-app/lib/features/inventory/domain/product.dart @@ -9,6 +9,7 @@ class Product { final String? sku; final String? barcode; final String? description; + final double? mrp; final double? sellingPrice; final double? gstRate; final String? dimensions; @@ -60,6 +61,7 @@ class Product { this.sku, this.barcode, this.description, + this.mrp, this.sellingPrice, this.gstRate, this.dimensions, @@ -116,7 +118,12 @@ class Product { sku: json['sku'], barcode: json['barcode'], 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(), dimensions: json['dimensions'], color: json['color'], @@ -172,6 +179,7 @@ class Product { 'sku': sku, 'barcode': barcode, 'description': description, + 'mrp': mrp, 'sellingPrice': sellingPrice, 'gstRate': gstRate, 'dimensions': dimensions, diff --git a/kifi-app/lib/features/inventory/presentation/add_product_screen.dart b/kifi-app/lib/features/inventory/presentation/add_product_screen.dart index 4d7a3c5..d383a91 100644 --- a/kifi-app/lib/features/inventory/presentation/add_product_screen.dart +++ b/kifi-app/lib/features/inventory/presentation/add_product_screen.dart @@ -27,6 +27,8 @@ class _AddProductScreenState extends ConsumerState { final _formKey = GlobalKey(); final _hsnController = TextEditingController(); final _gstController = TextEditingController(); + final _mrpController = TextEditingController(); + final _sellingPriceController = TextEditingController(); final _makingChargesController = TextEditingController(); String _makingChargesType = 'PER_GRAM'; @@ -83,6 +85,8 @@ class _AddProductScreenState extends ConsumerState { _hsnController.text = p.hsnCode ?? ''; _uomId = p.uomId; _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() : ''; _makingChargesType = p.makingChargesType ?? 'PER_GRAM'; @@ -132,6 +136,8 @@ class _AddProductScreenState extends ConsumerState { void dispose() { _hsnController.dispose(); _gstController.dispose(); + _mrpController.dispose(); + _sellingPriceController.dispose(); _makingChargesController.dispose(); _colorController.dispose(); _manufacturerCodeController.dispose(); @@ -252,6 +258,8 @@ class _AddProductScreenState extends ConsumerState { categoryId: _selectedCategory?.id, uomId: _uomId, color: _colorController.text.isNotEmpty ? _colorController.text.trim() : null, + mrp: double.tryParse(_mrpController.text), + sellingPrice: double.tryParse(_sellingPriceController.text), gstRate: double.tryParse(_gstController.text) ?? 0, makingCharges: double.tryParse(_makingChargesController.text) ?? 0.0, makingChargesType: _makingChargesType, @@ -457,6 +465,34 @@ class _AddProductScreenState extends ConsumerState { const Text('Pricing & Taxes', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), 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( children: [ Expanded( diff --git a/kifi-app/lib/features/inventory/presentation/product_detail_screen.dart b/kifi-app/lib/features/inventory/presentation/product_detail_screen.dart index 5208d2d..9a5d8d2 100644 --- a/kifi-app/lib/features/inventory/presentation/product_detail_screen.dart +++ b/kifi-app/lib/features/inventory/presentation/product_detail_screen.dart @@ -148,6 +148,10 @@ class ProductDetailScreen extends ConsumerWidget { if (uom != null) _buildDetailRow("Unit of Measure", uom.displayName), _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( "GST Rate", "${p.gstRate?.toStringAsFixed(1) ?? '0'}%", diff --git a/kifi-app/pubspec.lock b/kifi-app/pubspec.lock index f8eb4bb..60b33ce 100644 --- a/kifi-app/pubspec.lock +++ b/kifi-app/pubspec.lock @@ -65,6 +65,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: diff --git a/kifi-app/pubspec.yaml b/kifi-app/pubspec.yaml index fc17704..eee3ade 100644 --- a/kifi-app/pubspec.yaml +++ b/kifi-app/pubspec.yaml @@ -60,6 +60,7 @@ dependencies: intl_phone_field: ^3.2.0 url_launcher: ^6.3.2 lucide_icons_flutter: ^3.1.17 + barcode_widget: ^2.0.4 dev_dependencies: flutter_test: