Barcode Printing Utility Done

This commit is contained in:
2026-09-04 18:30:26 +05:30
parent 5e9b3f2122
commit 2c04a993f7
33 changed files with 8481 additions and 5 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -0,0 +1,56 @@
package com.kifi.api.controller.barcode;
import com.kifi.api.entity.barcode.BarcodeLabelTemplate;
import com.kifi.api.service.barcode.BarcodeLabelTemplateService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@RestController
@RequestMapping("/api/kifi-v2/barcode-templates")
@RequiredArgsConstructor
public class BarcodeLabelTemplateController {
private final BarcodeLabelTemplateService templateService;
@GetMapping
public Flux<BarcodeLabelTemplate> getTemplates(Authentication authentication) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return templateService.getActiveTemplates(userId);
}
@GetMapping("/{id}")
public Mono<BarcodeLabelTemplate> getTemplateById(
Authentication authentication,
@PathVariable Long id) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return templateService.getTemplateById(userId, id);
}
@PostMapping
public Mono<BarcodeLabelTemplate> createTemplate(
Authentication authentication,
@RequestBody BarcodeLabelTemplate template) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return templateService.createTemplate(userId, template);
}
@PutMapping("/{id}")
public Mono<BarcodeLabelTemplate> updateTemplate(
Authentication authentication,
@PathVariable Long id,
@RequestBody BarcodeLabelTemplate template) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return templateService.updateTemplate(userId, id, template);
}
@DeleteMapping("/{id}")
public Mono<Void> deleteTemplate(
Authentication authentication,
@PathVariable Long id) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return templateService.deleteTemplate(userId, id);
}
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -0,0 +1,15 @@
package com.kifi.api.repository.barcode;
import com.kifi.api.entity.barcode.BarcodeLabelTemplate;
import org.springframework.data.r2dbc.repository.Query;
import org.springframework.data.r2dbc.repository.R2dbcRepository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
public interface BarcodeLabelTemplateRepository extends R2dbcRepository<BarcodeLabelTemplate, Long> {
Flux<BarcodeLabelTemplate> findByUserIdAndIsActiveTrueOrderByUpdatedAtDesc(Long userId);
Flux<BarcodeLabelTemplate> findByUserIdOrderByUpdatedAtDesc(Long userId);
Mono<BarcodeLabelTemplate> findByIdAndUserId(Long id, Long userId);
}

View File

@@ -0,0 +1,73 @@
package com.kifi.api.service.barcode;
import com.kifi.api.entity.barcode.BarcodeLabelTemplate;
import com.kifi.api.repository.barcode.BarcodeLabelTemplateRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
@Service
@RequiredArgsConstructor
@Slf4j
public class BarcodeLabelTemplateService {
private final BarcodeLabelTemplateRepository templateRepository;
public Flux<BarcodeLabelTemplate> getActiveTemplates(Long userId) {
return templateRepository.findByUserIdAndIsActiveTrueOrderByUpdatedAtDesc(userId);
}
public Mono<BarcodeLabelTemplate> getTemplateById(Long userId, Long id) {
return templateRepository.findByIdAndUserId(id, userId);
}
public Mono<BarcodeLabelTemplate> createTemplate(Long userId, BarcodeLabelTemplate template) {
template.setId(null);
template.setUserId(userId);
template.setCreatedAt(LocalDateTime.now());
template.setUpdatedAt(LocalDateTime.now());
if (template.getIsActive() == null) {
template.setIsActive(true);
}
if (template.getVersion() == null) {
template.setVersion(1);
}
return templateRepository.save(template);
}
public Mono<BarcodeLabelTemplate> updateTemplate(Long userId, Long id, BarcodeLabelTemplate template) {
return templateRepository.findByIdAndUserId(id, userId)
.switchIfEmpty(Mono.error(new IllegalArgumentException("Template not found or access denied: " + id)))
.flatMap(existing -> {
if (template.getTemplateName() != null) existing.setTemplateName(template.getTemplateName());
if (template.getDescription() != null) existing.setDescription(template.getDescription());
if (template.getCategory() != null) existing.setCategory(template.getCategory());
if (template.getLabelWidthMm() != null) existing.setLabelWidthMm(template.getLabelWidthMm());
if (template.getLabelHeightMm() != null) existing.setLabelHeightMm(template.getLabelHeightMm());
if (template.getMeasurementUnit() != null) existing.setMeasurementUnit(template.getMeasurementUnit());
if (template.getPrinterLanguage() != null) existing.setPrinterLanguage(template.getPrinterLanguage());
if (template.getDpi() != null) existing.setDpi(template.getDpi());
if (template.getConfigurationJson() != null) existing.setConfigurationJson(template.getConfigurationJson());
if (template.getTemplateJson() != null) existing.setTemplateJson(template.getTemplateJson());
if (template.getIsActive() != null) existing.setIsActive(template.getIsActive());
existing.setVersion((existing.getVersion() != null ? existing.getVersion() : 1) + 1);
existing.setUpdatedAt(LocalDateTime.now());
return templateRepository.save(existing);
});
}
public Mono<Void> deleteTemplate(Long userId, Long id) {
return templateRepository.findByIdAndUserId(id, userId)
.flatMap(existing -> {
existing.setIsActive(false);
existing.setUpdatedAt(LocalDateTime.now());
return templateRepository.save(existing);
})
.then();
}
}

View File

@@ -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());

View File

@@ -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);

View File

@@ -63,10 +63,13 @@ class _SmartSearchDropdownState<T> extends State<SmartSearchDropdown<T>> {
_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;
}
});
}
}
}

View File

@@ -0,0 +1,122 @@
import 'dart:convert';
import 'label_document.dart';
class BarcodeTemplate {
final int? id;
final int? userId;
final String templateName;
final String? description;
final String category;
final int version;
final double labelWidthMm;
final double labelHeightMm;
final String measurementUnit;
final String printerLanguage;
final int dpi;
final String configurationJson;
final String templateJson;
final bool isActive;
final DateTime? createdAt;
final DateTime? updatedAt;
BarcodeTemplate({
this.id,
this.userId,
required this.templateName,
this.description,
this.category = 'PRODUCT',
this.version = 1,
this.labelWidthMm = 50.0,
this.labelHeightMm = 25.0,
this.measurementUnit = 'mm',
this.printerLanguage = 'ZPL',
this.dpi = 203,
this.configurationJson = '{}',
required this.templateJson,
this.isActive = true,
this.createdAt,
this.updatedAt,
});
factory BarcodeTemplate.fromJson(Map<String, dynamic> json) {
return BarcodeTemplate(
id: json['id'] as int?,
userId: json['userId'] as int?,
templateName: json['templateName'] as String? ?? 'Untitled Template',
description: json['description'] as String?,
category: json['category'] as String? ?? 'PRODUCT',
version: json['version'] as int? ?? 1,
labelWidthMm: (json['labelWidthMm'] as num?)?.toDouble() ?? 50.0,
labelHeightMm: (json['labelHeightMm'] as num?)?.toDouble() ?? 25.0,
measurementUnit: json['measurementUnit'] as String? ?? 'mm',
printerLanguage: json['printerLanguage'] as String? ?? 'ZPL',
dpi: json['dpi'] as int? ?? 203,
configurationJson: json['configurationJson'] as String? ?? '{}',
templateJson: json['templateJson'] as String? ?? '{}',
isActive: json['isActive'] as bool? ?? true,
createdAt: json['createdAt'] != null ? DateTime.tryParse(json['createdAt'].toString()) : null,
updatedAt: json['updatedAt'] != null ? DateTime.tryParse(json['updatedAt'].toString()) : null,
);
}
Map<String, dynamic> toJson() => {
if (id != null) 'id': id,
if (userId != null) 'userId': userId,
'templateName': templateName,
'description': description,
'category': category,
'version': version,
'labelWidthMm': labelWidthMm,
'labelHeightMm': labelHeightMm,
'measurementUnit': measurementUnit,
'printerLanguage': printerLanguage,
'dpi': dpi,
'configurationJson': configurationJson,
'templateJson': templateJson,
'isActive': isActive,
};
LabelDocument toLabelDocument() {
try {
final parsed = jsonDecode(templateJson) as Map<String, dynamic>;
return LabelDocument.fromJson(
parsed,
id: id?.toString(),
name: templateName,
description: description,
);
} catch (_) {
return LabelDocument(
id: id?.toString() ?? 'temp',
name: templateName,
description: description,
version: version,
config: LabelConfiguration(
widthMm: labelWidthMm,
heightMm: labelHeightMm,
dpi: dpi,
printerLanguage: printerLanguage,
),
);
}
}
static BarcodeTemplate fromLabelDocument({
int? id,
required LabelDocument doc,
}) {
return BarcodeTemplate(
id: id,
templateName: doc.name,
description: doc.description,
version: doc.version,
labelWidthMm: doc.config.widthMm,
labelHeightMm: doc.config.heightMm,
measurementUnit: 'mm',
printerLanguage: doc.config.printerLanguage,
dpi: doc.config.dpi,
configurationJson: jsonEncode(doc.config.toJson()),
templateJson: doc.toJsonString(),
);
}
}

View File

@@ -0,0 +1,152 @@
class DynamicFieldDefinition {
final String source; // e.g. "product"
final String fieldKey; // e.g. "name", "sellingPrice", "barcode"
final String displayName; // e.g. "Product Name"
final String category; // e.g. "Identification", "Pricing", "Specifications"
final String dataType; // "string", "currency", "weight", "number"
final bool isBarcodeCompatible;
final String? defaultPrefix;
final String? defaultSuffix;
const DynamicFieldDefinition({
required this.source,
required this.fieldKey,
required this.displayName,
required this.category,
this.dataType = 'string',
this.isBarcodeCompatible = false,
this.defaultPrefix,
this.defaultSuffix,
});
String get placeholder => '{{$source.$fieldKey}}';
static const List<DynamicFieldDefinition> productFields = [
DynamicFieldDefinition(
source: 'product',
fieldKey: 'name',
displayName: 'Product Name',
category: 'General',
dataType: 'string',
),
DynamicFieldDefinition(
source: 'product',
fieldKey: 'sku',
displayName: 'SKU',
category: 'Identification',
dataType: 'string',
isBarcodeCompatible: true,
),
DynamicFieldDefinition(
source: 'product',
fieldKey: 'barcode',
displayName: 'Barcode',
category: 'Identification',
dataType: 'string',
isBarcodeCompatible: true,
),
DynamicFieldDefinition(
source: 'product',
fieldKey: 'sellingPrice',
displayName: 'Selling Price',
category: 'Pricing',
dataType: 'currency',
defaultPrefix: '',
),
DynamicFieldDefinition(
source: 'product',
fieldKey: 'mrp',
displayName: 'MRP',
category: 'Pricing',
dataType: 'currency',
defaultPrefix: 'MRP ₹',
),
DynamicFieldDefinition(
source: 'product',
fieldKey: 'weightInG',
displayName: 'Weight',
category: 'Measurements',
dataType: 'weight',
defaultSuffix: ' g',
),
DynamicFieldDefinition(
source: 'product',
fieldKey: 'hsnCode',
displayName: 'HSN Code',
category: 'Tax & Compliance',
dataType: 'string',
),
DynamicFieldDefinition(
source: 'product',
fieldKey: 'gstRate',
displayName: 'GST Rate',
category: 'Tax & Compliance',
dataType: 'number',
defaultSuffix: '%',
),
DynamicFieldDefinition(
source: 'product',
fieldKey: 'brandName',
displayName: 'Brand',
category: 'General',
dataType: 'string',
),
DynamicFieldDefinition(
source: 'product',
fieldKey: 'material',
displayName: 'Material / Metal',
category: 'Specifications',
dataType: 'string',
),
DynamicFieldDefinition(
source: 'product',
fieldKey: 'size',
displayName: 'Size',
category: 'Specifications',
dataType: 'string',
),
DynamicFieldDefinition(
source: 'product',
fieldKey: 'color',
displayName: 'Color',
category: 'Specifications',
dataType: 'string',
),
DynamicFieldDefinition(
source: 'product',
fieldKey: 'manufacturerCode',
displayName: 'Tag / Serial / Code',
category: 'Identification',
dataType: 'string',
isBarcodeCompatible: true,
),
DynamicFieldDefinition(
source: 'product',
fieldKey: 'purityFactor',
displayName: 'Purity Factor',
category: 'Specifications',
dataType: 'number',
),
DynamicFieldDefinition(
source: 'product',
fieldKey: 'description',
displayName: 'Description',
category: 'General',
dataType: 'string',
),
DynamicFieldDefinition(
source: 'product',
fieldKey: 'currentStock',
displayName: 'Current Stock',
category: 'Inventory',
dataType: 'number',
),
];
static DynamicFieldDefinition? find(String source, String fieldKey) {
if (source == 'product') {
return productFields.where((f) => f.fieldKey == fieldKey).firstOrNull;
}
return null;
}
}

View File

@@ -0,0 +1,956 @@
import 'dart:convert';
import 'package:flutter/material.dart';
enum BarcodeType {
code128,
ean13,
code39,
upca,
qrCode,
}
enum LabelOrientation {
portrait,
landscape,
}
enum ElementType {
text,
dynamicText,
barcode,
qrCode,
rectangle,
line,
}
class LabelConfiguration {
final double widthMm;
final double heightMm;
final double marginTopMm;
final double marginBottomMm;
final double marginLeftMm;
final double marginRightMm;
final int dpi; // 203, 300, 600
final LabelOrientation orientation;
final String printerLanguage; // ZPL, TSPL, ESCPOS, PCL
final int columnsAcross; // 1-Up (single), 2-Up (twin track), 3-Up, 4-Up
final double horizontalGapMm; // Gap between side-by-side labels in mm
final double verticalGapMm; // Gap between consecutive labels along roll feed in mm
const LabelConfiguration({
this.widthMm = 50.0,
this.heightMm = 25.0,
this.marginTopMm = 1.0,
this.marginBottomMm = 1.0,
this.marginLeftMm = 1.0,
this.marginRightMm = 1.0,
this.dpi = 203,
this.orientation = LabelOrientation.portrait,
this.printerLanguage = 'ZPL',
this.columnsAcross = 1,
this.horizontalGapMm = 2.0,
this.verticalGapMm = 2.0,
});
/// Total roll web width including all columns and gaps between them
double get totalWebWidthMm {
final cols = columnsAcross.clamp(1, 4);
if (cols <= 1) return widthMm;
return (widthMm * cols) + (horizontalGapMm * (cols - 1));
}
int get totalWebWidthDots => mmToDots(totalWebWidthMm);
LabelConfiguration copyWith({
double? widthMm,
double? heightMm,
double? marginTopMm,
double? marginBottomMm,
double? marginLeftMm,
double? marginRightMm,
int? dpi,
LabelOrientation? orientation,
String? printerLanguage,
int? columnsAcross,
double? horizontalGapMm,
double? verticalGapMm,
}) {
return LabelConfiguration(
widthMm: widthMm ?? this.widthMm,
heightMm: heightMm ?? this.heightMm,
marginTopMm: marginTopMm ?? this.marginTopMm,
marginBottomMm: marginBottomMm ?? this.marginBottomMm,
marginLeftMm: marginLeftMm ?? this.marginLeftMm,
marginRightMm: marginRightMm ?? this.marginRightMm,
dpi: dpi ?? this.dpi,
orientation: orientation ?? this.orientation,
printerLanguage: printerLanguage ?? this.printerLanguage,
columnsAcross: columnsAcross ?? this.columnsAcross,
horizontalGapMm: horizontalGapMm ?? this.horizontalGapMm,
verticalGapMm: verticalGapMm ?? this.verticalGapMm,
);
}
Map<String, dynamic> toJson() => {
'widthMm': widthMm,
'heightMm': heightMm,
'marginTopMm': marginTopMm,
'marginBottomMm': marginBottomMm,
'marginLeftMm': marginLeftMm,
'marginRightMm': marginRightMm,
'dpi': dpi,
'orientation': orientation.name,
'printerLanguage': printerLanguage,
'columnsAcross': columnsAcross,
'horizontalGapMm': horizontalGapMm,
'verticalGapMm': verticalGapMm,
};
factory LabelConfiguration.fromJson(Map<String, dynamic> json) {
return LabelConfiguration(
widthMm: (json['widthMm'] as num?)?.toDouble() ?? 50.0,
heightMm: (json['heightMm'] as num?)?.toDouble() ?? 25.0,
marginTopMm: (json['marginTopMm'] as num?)?.toDouble() ?? 1.0,
marginBottomMm: (json['marginBottomMm'] as num?)?.toDouble() ?? 1.0,
marginLeftMm: (json['marginLeftMm'] as num?)?.toDouble() ?? 1.0,
marginRightMm: (json['marginRightMm'] as num?)?.toDouble() ?? 1.0,
dpi: json['dpi'] as int? ?? 203,
orientation: json['orientation'] == 'landscape'
? LabelOrientation.landscape
: LabelOrientation.portrait,
printerLanguage: json['printerLanguage'] as String? ?? 'ZPL',
columnsAcross: (json['columnsAcross'] as num?)?.toInt() ?? 1,
horizontalGapMm: (json['horizontalGapMm'] as num?)?.toDouble() ?? 2.0,
verticalGapMm: (json['verticalGapMm'] as num?)?.toDouble() ?? 2.0,
);
}
int mmToDots(double mm) => ((mm / 25.4) * dpi).round();
int get widthDots => mmToDots(widthMm);
int get heightDots => mmToDots(heightMm);
}
abstract class LabelElement {
final String id;
final ElementType type;
final double xMm;
final double yMm;
final double widthMm;
final double heightMm;
final double rotation; // 0, 90, 180, 270
final bool isLocked;
final int zIndex;
const LabelElement({
required this.id,
required this.type,
required this.xMm,
required this.yMm,
required this.widthMm,
required this.heightMm,
this.rotation = 0.0,
this.isLocked = false,
this.zIndex = 0,
});
LabelElement copyWithPosition({
double? xMm,
double? yMm,
double? widthMm,
double? heightMm,
double? rotation,
bool? isLocked,
int? zIndex,
});
double get borderWidthMm => 0.0;
Map<String, dynamic> toJson();
static LabelElement fromJson(Map<String, dynamic> json) {
final typeStr = json['type'] as String? ?? 'text';
switch (typeStr) {
case 'dynamicText':
return DynamicTextElement.fromJson(json);
case 'barcode':
return BarcodeElement.fromJson(json);
case 'qrCode':
return QrCodeElement.fromJson(json);
case 'rectangle':
return RectangleElement.fromJson(json);
case 'line':
return LineElement.fromJson(json);
case 'text':
default:
return TextElement.fromJson(json);
}
}
}
class TextElement extends LabelElement {
final String text;
final String fontFamily;
final double fontSizePt;
final bool isBold;
final bool isItalic;
final TextAlign alignment;
@override
final double borderWidthMm;
final double paddingMm;
const TextElement({
required super.id,
required super.xMm,
required super.yMm,
required super.widthMm,
required super.heightMm,
super.rotation = 0.0,
super.isLocked = false,
super.zIndex = 0,
this.text = 'kifi',
this.fontFamily = 'Roboto',
this.fontSizePt = 9.0,
this.isBold = false,
this.isItalic = false,
this.alignment = TextAlign.left,
this.borderWidthMm = 0.0,
this.paddingMm = 0.0,
}) : super(type: ElementType.text);
@override
TextElement copyWithPosition({
double? xMm,
double? yMm,
double? widthMm,
double? heightMm,
double? rotation,
bool? isLocked,
int? zIndex,
String? text,
String? fontFamily,
double? fontSizePt,
bool? isBold,
bool? isItalic,
TextAlign? alignment,
double? borderWidthMm,
double? paddingMm,
}) {
return TextElement(
id: id,
xMm: xMm ?? this.xMm,
yMm: yMm ?? this.yMm,
widthMm: widthMm ?? this.widthMm,
heightMm: heightMm ?? this.heightMm,
rotation: rotation ?? this.rotation,
isLocked: isLocked ?? this.isLocked,
zIndex: zIndex ?? this.zIndex,
text: text ?? this.text,
fontFamily: fontFamily ?? this.fontFamily,
fontSizePt: fontSizePt ?? this.fontSizePt,
isBold: isBold ?? this.isBold,
isItalic: isItalic ?? this.isItalic,
alignment: alignment ?? this.alignment,
borderWidthMm: borderWidthMm ?? this.borderWidthMm,
paddingMm: paddingMm ?? this.paddingMm,
);
}
@override
Map<String, dynamic> toJson() => {
'id': id,
'type': 'text',
'xMm': xMm,
'yMm': yMm,
'widthMm': widthMm,
'heightMm': heightMm,
'rotation': rotation,
'isLocked': isLocked,
'zIndex': zIndex,
'text': text,
'fontFamily': fontFamily,
'fontSizePt': fontSizePt,
'isBold': isBold,
'isItalic': isItalic,
'alignment': alignment.name,
'borderWidthMm': borderWidthMm,
'paddingMm': paddingMm,
};
factory TextElement.fromJson(Map<String, dynamic> json) {
return TextElement(
id: json['id'] as String,
xMm: (json['xMm'] as num?)?.toDouble() ?? 0.0,
yMm: (json['yMm'] as num?)?.toDouble() ?? 0.0,
widthMm: (json['widthMm'] as num?)?.toDouble() ?? 20.0,
heightMm: (json['heightMm'] as num?)?.toDouble() ?? 5.0,
rotation: (json['rotation'] as num?)?.toDouble() ?? 0.0,
isLocked: json['isLocked'] as bool? ?? false,
zIndex: json['zIndex'] as int? ?? 0,
text: json['text'] as String? ?? json['value'] as String? ?? 'kifi',
fontFamily: json['fontFamily'] as String? ?? 'Roboto',
fontSizePt: (json['fontSizePt'] as num?)?.toDouble() ?? 9.0,
isBold: json['isBold'] as bool? ?? false,
isItalic: json['isItalic'] as bool? ?? false,
alignment: TextAlign.values.where((a) => a.name == json['alignment']).firstOrNull ?? TextAlign.left,
borderWidthMm: (json['borderWidthMm'] as num?)?.toDouble() ?? 0.0,
paddingMm: (json['paddingMm'] as num?)?.toDouble() ?? 0.0,
);
}
}
class DynamicTextElement extends LabelElement {
final String source; // "product"
final String fieldKey; // "name", "sellingPrice", etc.
final String? prefix;
final String? suffix;
final String fallbackValue;
final String fontFamily;
final double fontSizePt;
final bool isBold;
final bool isItalic;
final TextAlign alignment;
@override
final double borderWidthMm;
final double paddingMm;
final bool autoFit;
final int maxLines;
const DynamicTextElement({
required super.id,
required super.xMm,
required super.yMm,
required super.widthMm,
required super.heightMm,
super.rotation = 0.0,
super.isLocked = false,
super.zIndex = 0,
this.source = 'product',
this.fieldKey = '',
this.prefix,
this.suffix,
this.fallbackValue = 'N/A',
this.fontFamily = 'Roboto',
this.fontSizePt = 9.0,
this.isBold = false,
this.isItalic = false,
this.alignment = TextAlign.left,
this.borderWidthMm = 0.0,
this.paddingMm = 0.0,
this.autoFit = true,
this.maxLines = 2,
}) : super(type: ElementType.dynamicText);
String get placeholder => fieldKey.isNotEmpty ? '{{$source.$fieldKey}}' : '{{select_field}}';
String formatValue(String? resolvedRaw) {
if (resolvedRaw == null || resolvedRaw.trim().isEmpty) {
return fallbackValue;
}
final p = prefix ?? '';
final s = suffix ?? '';
return '$p$resolvedRaw$s';
}
@override
DynamicTextElement copyWithPosition({
double? xMm,
double? yMm,
double? widthMm,
double? heightMm,
double? rotation,
bool? isLocked,
int? zIndex,
String? source,
String? fieldKey,
String? prefix,
String? suffix,
String? fallbackValue,
String? fontFamily,
double? fontSizePt,
bool? isBold,
bool? isItalic,
TextAlign? alignment,
double? borderWidthMm,
double? paddingMm,
bool? autoFit,
int? maxLines,
}) {
return DynamicTextElement(
id: id,
xMm: xMm ?? this.xMm,
yMm: yMm ?? this.yMm,
widthMm: widthMm ?? this.widthMm,
heightMm: heightMm ?? this.heightMm,
rotation: rotation ?? this.rotation,
isLocked: isLocked ?? this.isLocked,
zIndex: zIndex ?? this.zIndex,
source: source ?? this.source,
fieldKey: fieldKey ?? this.fieldKey,
prefix: prefix ?? this.prefix,
suffix: suffix ?? this.suffix,
fallbackValue: fallbackValue ?? this.fallbackValue,
fontFamily: fontFamily ?? this.fontFamily,
fontSizePt: fontSizePt ?? this.fontSizePt,
isBold: isBold ?? this.isBold,
isItalic: isItalic ?? this.isItalic,
alignment: alignment ?? this.alignment,
borderWidthMm: borderWidthMm ?? this.borderWidthMm,
paddingMm: paddingMm ?? this.paddingMm,
autoFit: autoFit ?? this.autoFit,
maxLines: maxLines ?? this.maxLines,
);
}
@override
Map<String, dynamic> toJson() => {
'id': id,
'type': 'dynamicText',
'xMm': xMm,
'yMm': yMm,
'widthMm': widthMm,
'heightMm': heightMm,
'rotation': rotation,
'isLocked': isLocked,
'zIndex': zIndex,
'source': source,
'fieldKey': fieldKey,
'placeholder': placeholder,
'prefix': prefix,
'suffix': suffix,
'fallbackValue': fallbackValue,
'fontFamily': fontFamily,
'fontSizePt': fontSizePt,
'isBold': isBold,
'isItalic': isItalic,
'alignment': alignment.name,
'borderWidthMm': borderWidthMm,
'paddingMm': paddingMm,
'autoFit': autoFit,
'maxLines': maxLines,
};
factory DynamicTextElement.fromJson(Map<String, dynamic> json) {
return DynamicTextElement(
id: json['id'] as String,
xMm: (json['xMm'] as num?)?.toDouble() ?? 0.0,
yMm: (json['yMm'] as num?)?.toDouble() ?? 0.0,
widthMm: (json['widthMm'] as num?)?.toDouble() ?? 30.0,
heightMm: (json['heightMm'] as num?)?.toDouble() ?? 5.0,
rotation: (json['rotation'] as num?)?.toDouble() ?? 0.0,
isLocked: json['isLocked'] as bool? ?? false,
zIndex: json['zIndex'] as int? ?? 0,
source: json['source'] as String? ?? 'product',
fieldKey: json['fieldKey'] as String? ?? json['field'] as String? ?? '',
prefix: json['prefix'] as String?,
suffix: json['suffix'] as String?,
fallbackValue: json['fallbackValue'] as String? ?? json['fallback'] as String? ?? 'N/A',
fontFamily: json['fontFamily'] as String? ?? 'Roboto',
fontSizePt: (json['fontSizePt'] as num?)?.toDouble() ?? 9.0,
isBold: json['isBold'] as bool? ?? false,
isItalic: json['isItalic'] as bool? ?? false,
alignment: TextAlign.values.where((a) => a.name == json['alignment']).firstOrNull ?? TextAlign.left,
borderWidthMm: (json['borderWidthMm'] as num?)?.toDouble() ?? 0.0,
paddingMm: (json['paddingMm'] as num?)?.toDouble() ?? 0.0,
autoFit: json['autoFit'] as bool? ?? true,
maxLines: json['maxLines'] as int? ?? 2,
);
}
}
class BarcodeElement extends LabelElement {
final String valueType; // "static" or "dynamic"
final String staticValue;
final String source; // "product"
final String fieldKey; // "barcode" or "sku"
final BarcodeType barcodeType;
final bool showText;
@override
final double borderWidthMm;
final double paddingMm;
const BarcodeElement({
required super.id,
required super.xMm,
required super.yMm,
required super.widthMm,
required super.heightMm,
super.rotation = 0.0,
super.isLocked = false,
super.zIndex = 0,
this.valueType = 'dynamic',
this.staticValue = '123456789012',
this.source = 'product',
this.fieldKey = 'barcode',
this.barcodeType = BarcodeType.code128,
this.showText = true,
this.borderWidthMm = 0.0,
this.paddingMm = 0.5,
}) : super(type: ElementType.barcode);
String get placeholder => valueType == 'dynamic' ? '{{$source.$fieldKey}}' : staticValue;
@override
BarcodeElement copyWithPosition({
double? xMm,
double? yMm,
double? widthMm,
double? heightMm,
double? rotation,
bool? isLocked,
int? zIndex,
String? valueType,
String? staticValue,
String? source,
String? fieldKey,
BarcodeType? barcodeType,
bool? showText,
double? borderWidthMm,
double? paddingMm,
}) {
return BarcodeElement(
id: id,
xMm: xMm ?? this.xMm,
yMm: yMm ?? this.yMm,
widthMm: widthMm ?? this.widthMm,
heightMm: heightMm ?? this.heightMm,
rotation: rotation ?? this.rotation,
isLocked: isLocked ?? this.isLocked,
zIndex: zIndex ?? this.zIndex,
valueType: valueType ?? this.valueType,
staticValue: staticValue ?? this.staticValue,
source: source ?? this.source,
fieldKey: fieldKey ?? this.fieldKey,
barcodeType: barcodeType ?? this.barcodeType,
showText: showText ?? this.showText,
borderWidthMm: borderWidthMm ?? this.borderWidthMm,
paddingMm: paddingMm ?? this.paddingMm,
);
}
@override
Map<String, dynamic> toJson() => {
'id': id,
'type': 'barcode',
'xMm': xMm,
'yMm': yMm,
'widthMm': widthMm,
'heightMm': heightMm,
'rotation': rotation,
'isLocked': isLocked,
'zIndex': zIndex,
'valueType': valueType,
'staticValue': staticValue,
'source': source,
'fieldKey': fieldKey,
'placeholder': placeholder,
'barcodeType': barcodeType.name,
'showText': showText,
'borderWidthMm': borderWidthMm,
'paddingMm': paddingMm,
};
factory BarcodeElement.fromJson(Map<String, dynamic> json) {
return BarcodeElement(
id: json['id'] as String,
xMm: (json['xMm'] as num?)?.toDouble() ?? 0.0,
yMm: (json['yMm'] as num?)?.toDouble() ?? 0.0,
widthMm: (json['widthMm'] as num?)?.toDouble() ?? 40.0,
heightMm: (json['heightMm'] as num?)?.toDouble() ?? 10.0,
rotation: (json['rotation'] as num?)?.toDouble() ?? 0.0,
isLocked: json['isLocked'] as bool? ?? false,
zIndex: json['zIndex'] as int? ?? 0,
valueType: json['valueType'] as String? ?? 'dynamic',
staticValue: json['staticValue'] as String? ?? '123456789012',
source: json['source'] as String? ?? 'product',
fieldKey: json['fieldKey'] as String? ?? json['field'] as String? ?? 'barcode',
barcodeType: BarcodeType.values.where((b) => b.name.toLowerCase() == (json['barcodeType'] as String? ?? '').toLowerCase()).firstOrNull ?? BarcodeType.code128,
showText: json['showText'] as bool? ?? true,
borderWidthMm: (json['borderWidthMm'] as num?)?.toDouble() ?? 0.0,
paddingMm: (json['paddingMm'] as num?)?.toDouble() ?? 0.5,
);
}
}
class QrCodeElement extends LabelElement {
final String valueType; // "static" or "dynamic"
final String staticValue;
final String source; // "product"
final String fieldKey;
const QrCodeElement({
required super.id,
required super.xMm,
required super.yMm,
required super.widthMm,
required super.heightMm,
super.rotation = 0.0,
super.isLocked = false,
super.zIndex = 0,
this.valueType = 'dynamic',
this.staticValue = 'https://kifi.app',
this.source = 'product',
this.fieldKey = 'barcode',
}) : super(type: ElementType.qrCode);
String get placeholder => valueType == 'dynamic' ? '{{$source.$fieldKey}}' : staticValue;
@override
QrCodeElement copyWithPosition({
double? xMm,
double? yMm,
double? widthMm,
double? heightMm,
double? rotation,
bool? isLocked,
int? zIndex,
String? valueType,
String? staticValue,
String? source,
String? fieldKey,
}) {
return QrCodeElement(
id: id,
xMm: xMm ?? this.xMm,
yMm: yMm ?? this.yMm,
widthMm: widthMm ?? this.widthMm,
heightMm: heightMm ?? this.heightMm,
rotation: rotation ?? this.rotation,
isLocked: isLocked ?? this.isLocked,
zIndex: zIndex ?? this.zIndex,
valueType: valueType ?? this.valueType,
staticValue: staticValue ?? this.staticValue,
source: source ?? this.source,
fieldKey: fieldKey ?? this.fieldKey,
);
}
@override
Map<String, dynamic> toJson() => {
'id': id,
'type': 'qrCode',
'xMm': xMm,
'yMm': yMm,
'widthMm': widthMm,
'heightMm': heightMm,
'rotation': rotation,
'isLocked': isLocked,
'zIndex': zIndex,
'valueType': valueType,
'staticValue': staticValue,
'source': source,
'fieldKey': fieldKey,
'placeholder': placeholder,
};
factory QrCodeElement.fromJson(Map<String, dynamic> json) {
return QrCodeElement(
id: json['id'] as String,
xMm: (json['xMm'] as num?)?.toDouble() ?? 0.0,
yMm: (json['yMm'] as num?)?.toDouble() ?? 0.0,
widthMm: (json['widthMm'] as num?)?.toDouble() ?? 12.0,
heightMm: (json['heightMm'] as num?)?.toDouble() ?? 12.0,
rotation: (json['rotation'] as num?)?.toDouble() ?? 0.0,
isLocked: json['isLocked'] as bool? ?? false,
zIndex: json['zIndex'] as int? ?? 0,
valueType: json['valueType'] as String? ?? 'dynamic',
staticValue: json['staticValue'] as String? ?? 'https://kifi.app',
source: json['source'] as String? ?? 'product',
fieldKey: json['fieldKey'] as String? ?? json['field'] as String? ?? 'barcode',
);
}
}
class RectangleElement extends LabelElement {
@override
final double borderWidthMm;
final bool isFilled;
final double cornerRadiusMm;
const RectangleElement({
required super.id,
required super.xMm,
required super.yMm,
required super.widthMm,
required super.heightMm,
super.rotation = 0.0,
super.isLocked = false,
super.zIndex = 0,
this.borderWidthMm = 0.3,
this.isFilled = false,
this.cornerRadiusMm = 0.0,
}) : super(type: ElementType.rectangle);
@override
RectangleElement copyWithPosition({
double? xMm,
double? yMm,
double? widthMm,
double? heightMm,
double? rotation,
bool? isLocked,
int? zIndex,
double? borderWidthMm,
bool? isFilled,
double? cornerRadiusMm,
}) {
return RectangleElement(
id: id,
xMm: xMm ?? this.xMm,
yMm: yMm ?? this.yMm,
widthMm: widthMm ?? this.widthMm,
heightMm: heightMm ?? this.heightMm,
rotation: rotation ?? this.rotation,
isLocked: isLocked ?? this.isLocked,
zIndex: zIndex ?? this.zIndex,
borderWidthMm: borderWidthMm ?? this.borderWidthMm,
isFilled: isFilled ?? this.isFilled,
cornerRadiusMm: cornerRadiusMm ?? this.cornerRadiusMm,
);
}
@override
Map<String, dynamic> toJson() => {
'id': id,
'type': 'rectangle',
'xMm': xMm,
'yMm': yMm,
'widthMm': widthMm,
'heightMm': heightMm,
'rotation': rotation,
'isLocked': isLocked,
'zIndex': zIndex,
'borderWidthMm': borderWidthMm,
'isFilled': isFilled,
'cornerRadiusMm': cornerRadiusMm,
};
factory RectangleElement.fromJson(Map<String, dynamic> json) {
return RectangleElement(
id: json['id'] as String,
xMm: (json['xMm'] as num?)?.toDouble() ?? 0.0,
yMm: (json['yMm'] as num?)?.toDouble() ?? 0.0,
widthMm: (json['widthMm'] as num?)?.toDouble() ?? 20.0,
heightMm: (json['heightMm'] as num?)?.toDouble() ?? 10.0,
rotation: (json['rotation'] as num?)?.toDouble() ?? 0.0,
isLocked: json['isLocked'] as bool? ?? false,
zIndex: json['zIndex'] as int? ?? 0,
borderWidthMm: (json['borderWidthMm'] as num?)?.toDouble() ?? 0.3,
isFilled: json['isFilled'] as bool? ?? false,
cornerRadiusMm: (json['cornerRadiusMm'] as num?)?.toDouble() ?? 0.0,
);
}
}
class LineElement extends LabelElement {
final double thicknessMm;
final bool isVertical;
const LineElement({
required super.id,
required super.xMm,
required super.yMm,
required super.widthMm,
required super.heightMm,
super.rotation = 0.0,
super.isLocked = false,
super.zIndex = 0,
this.thicknessMm = 0.3,
this.isVertical = false,
}) : super(type: ElementType.line);
@override
LineElement copyWithPosition({
double? xMm,
double? yMm,
double? widthMm,
double? heightMm,
double? rotation,
bool? isLocked,
int? zIndex,
double? thicknessMm,
bool? isVertical,
}) {
return LineElement(
id: id,
xMm: xMm ?? this.xMm,
yMm: yMm ?? this.yMm,
widthMm: widthMm ?? this.widthMm,
heightMm: heightMm ?? this.heightMm,
rotation: rotation ?? this.rotation,
isLocked: isLocked ?? this.isLocked,
zIndex: zIndex ?? this.zIndex,
thicknessMm: thicknessMm ?? this.thicknessMm,
isVertical: isVertical ?? this.isVertical,
);
}
@override
Map<String, dynamic> toJson() => {
'id': id,
'type': 'line',
'xMm': xMm,
'yMm': yMm,
'widthMm': widthMm,
'heightMm': heightMm,
'rotation': rotation,
'isLocked': isLocked,
'zIndex': zIndex,
'thicknessMm': thicknessMm,
'isVertical': isVertical,
};
factory LineElement.fromJson(Map<String, dynamic> json) {
return LineElement(
id: json['id'] as String,
xMm: (json['xMm'] as num?)?.toDouble() ?? 0.0,
yMm: (json['yMm'] as num?)?.toDouble() ?? 0.0,
widthMm: (json['widthMm'] as num?)?.toDouble() ?? 20.0,
heightMm: (json['heightMm'] as num?)?.toDouble() ?? 1.0,
rotation: (json['rotation'] as num?)?.toDouble() ?? 0.0,
isLocked: json['isLocked'] as bool? ?? false,
zIndex: json['zIndex'] as int? ?? 0,
thicknessMm: (json['thicknessMm'] as num?)?.toDouble() ?? 0.3,
isVertical: json['isVertical'] as bool? ?? false,
);
}
}
class LabelDocument {
final String id;
final String name;
final String? description;
final int version;
final LabelConfiguration config;
final List<LabelElement> elements;
const LabelDocument({
required this.id,
required this.name,
this.description,
this.version = 1,
this.config = const LabelConfiguration(),
this.elements = const [],
});
LabelDocument copyWith({
String? id,
String? name,
String? description,
int? version,
LabelConfiguration? config,
List<LabelElement>? elements,
}) {
return LabelDocument(
id: id ?? this.id,
name: name ?? this.name,
description: description ?? this.description,
version: version ?? this.version,
config: config ?? this.config,
elements: elements ?? this.elements,
);
}
Map<String, dynamic> toJson() => {
'version': version,
'name': name,
'description': description,
'label': config.toJson(),
'elements': elements.map((e) => e.toJson()).toList(),
};
String toJsonString() => jsonEncode(toJson());
factory LabelDocument.fromJson(Map<String, dynamic> json, {String? id, String? name, String? description}) {
final labelConfig = json['label'] != null
? LabelConfiguration.fromJson(json['label'] as Map<String, dynamic>)
: const LabelConfiguration();
final rawElements = json['elements'] as List<dynamic>? ?? [];
final elementsList = rawElements
.map((e) => LabelElement.fromJson(e as Map<String, dynamic>))
.toList();
return LabelDocument(
id: id ?? (json['id'] as String? ?? 'template_1'),
name: name ?? (json['name'] as String? ?? 'Untitled Template'),
description: description ?? (json['description'] as String?),
version: json['version'] as int? ?? 1,
config: labelConfig,
elements: elementsList,
);
}
static LabelDocument createDefault() {
return const LabelDocument(
id: 'default_template',
name: '50x25 Product Barcode',
description: 'Standard jewellery and product barcode label (50x25mm)',
config: LabelConfiguration(
widthMm: 50.0,
heightMm: 25.0,
dpi: 203,
),
elements: [
TextElement(
id: 'elem_brand',
xMm: 2.0,
yMm: 1.8,
widthMm: 46.0,
heightMm: 3.5,
text: 'kifi',
fontSizePt: 8.5,
isBold: true,
alignment: TextAlign.center,
),
DynamicTextElement(
id: 'elem_product_name',
xMm: 2.0,
yMm: 5.5,
widthMm: 46.0,
heightMm: 4.0,
source: 'product',
fieldKey: 'name',
fontSizePt: 8.0,
alignment: TextAlign.center,
),
BarcodeElement(
id: 'elem_barcode',
xMm: 5.0,
yMm: 10.0,
widthMm: 40.0,
heightMm: 8.0,
valueType: 'dynamic',
source: 'product',
fieldKey: 'barcode',
showText: true,
),
DynamicTextElement(
id: 'elem_price',
xMm: 2.0,
yMm: 19.5,
widthMm: 23.0,
heightMm: 3.8,
source: 'product',
fieldKey: 'sellingPrice',
prefix: '',
fontSizePt: 8.0,
isBold: true,
alignment: TextAlign.left,
),
DynamicTextElement(
id: 'elem_weight',
xMm: 26.0,
yMm: 19.5,
widthMm: 22.0,
heightMm: 3.8,
source: 'product',
fieldKey: 'weightInG',
prefix: 'Wt: ',
suffix: ' g',
fontSizePt: 7.5,
alignment: TextAlign.right,
),
],
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,242 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../providers/barcode_templates_provider.dart';
import '../providers/barcode_designer_provider.dart';
import 'barcode_designer_screen.dart';
class SavedTemplatesScreen extends ConsumerWidget {
const SavedTemplatesScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final templatesAsync = ref.watch(barcodeTemplatesProvider);
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
appBar: AppBar(
title: const Text('Saved Label Templates'),
actions: [
IconButton(
icon: const Icon(LucideIcons.refreshCw),
tooltip: 'Refresh',
onPressed: () => ref.read(barcodeTemplatesProvider.notifier).refresh(),
),
],
),
floatingActionButton: FloatingActionButton.extended(
icon: const Icon(LucideIcons.plus),
label: const Text('New Template'),
onPressed: () {
ref.read(barcodeDesignerProvider.notifier).resetToNew(50.0, 25.0);
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => const BarcodeDesignerScreen()),
);
},
),
body: templatesAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(LucideIcons.circleAlert, size: 48, color: Colors.red),
const SizedBox(height: 12),
Text('Error loading templates: $err'),
const SizedBox(height: 12),
ElevatedButton(
onPressed: () => ref.read(barcodeTemplatesProvider.notifier).refresh(),
child: const Text('Retry'),
),
],
),
),
data: (templates) {
if (templates.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: const Icon(LucideIcons.barcode, size: 48, color: Colors.blue),
),
const SizedBox(height: 16),
const Text(
'No templates saved yet',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
'Create your first custom barcode and label template',
style: TextStyle(color: Colors.grey.shade500, fontSize: 13),
),
const SizedBox(height: 20),
ElevatedButton.icon(
icon: const Icon(LucideIcons.plus, size: 18),
label: const Text('Create New Template'),
onPressed: () {
ref.read(barcodeDesignerProvider.notifier).resetToNew(50.0, 25.0);
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => const BarcodeDesignerScreen()),
);
},
),
],
),
);
}
return GridView.builder(
padding: const EdgeInsets.all(20),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 360,
mainAxisExtent: 180,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: templates.length,
itemBuilder: (context, index) {
final item = templates[index];
return Container(
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1E293B) : Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isDark ? Colors.white10 : Colors.grey.shade200,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.04),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(16),
child: InkWell(
onTap: () {
final doc = item.toLabelDocument();
ref.read(barcodeDesignerProvider.notifier).loadDocument(doc, item.id);
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (_) => BarcodeDesignerScreen(initialTemplate: item),
),
);
},
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(LucideIcons.barcode, color: Colors.blue, size: 20),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.templateName,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
'${item.labelWidthMm.round()} × ${item.labelHeightMm.round()} mm • ${item.printerLanguage}',
style: TextStyle(color: Colors.grey.shade500, fontSize: 11),
),
],
),
),
PopupMenuButton<String>(
icon: const Icon(LucideIcons.ellipsisVertical, size: 18),
onSelected: (val) async {
if (val == 'delete' && item.id != null) {
final confirm = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Delete Template'),
content: Text('Are you sure you want to delete "${item.templateName}"?'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
onPressed: () => Navigator.pop(context, true),
child: const Text('Delete', style: TextStyle(color: Colors.white)),
),
],
),
);
if (confirm == true) {
ref.read(barcodeTemplatesProvider.notifier).deleteTemplate(item.id!);
}
}
},
itemBuilder: (_) => [
const PopupMenuItem(
value: 'delete',
child: Row(
children: [
Icon(LucideIcons.trash2, color: Colors.red, size: 16),
SizedBox(width: 8),
Text('Delete', style: TextStyle(color: Colors.red)),
],
),
),
],
),
],
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: isDark ? Colors.black26 : Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'DPI: ${item.dpi}',
style: TextStyle(fontSize: 11, color: Colors.grey.shade600),
),
Text(
'v${item.version}',
style: TextStyle(fontSize: 11, color: Colors.grey.shade600),
),
],
),
),
],
),
),
),
),
);
},
);
},
),
);
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,189 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../domain/label_document.dart';
class ToolboxItemData {
final ElementType type;
final String label;
final IconData icon;
final Color color;
final String description;
const ToolboxItemData({
required this.type,
required this.label,
required this.icon,
required this.color,
required this.description,
});
}
class DesignerToolbox extends StatelessWidget {
final Function(ElementType type) onAddElement;
const DesignerToolbox({
super.key,
required this.onAddElement,
});
static const List<ToolboxItemData> items = [
ToolboxItemData(
type: ElementType.text,
label: 'Text',
icon: LucideIcons.type,
color: Colors.blue,
description: 'Static label text (default: kifi)',
),
ToolboxItemData(
type: ElementType.dynamicText,
label: 'Dynamic Field',
icon: LucideIcons.variable,
color: Colors.purple,
description: 'Bound product placeholder',
),
ToolboxItemData(
type: ElementType.barcode,
label: 'Barcode',
icon: LucideIcons.barcode,
color: Colors.indigo,
description: 'Code 128, EAN-13, SKU barcode',
),
ToolboxItemData(
type: ElementType.qrCode,
label: 'QR Code',
icon: LucideIcons.qrCode,
color: Colors.teal,
description: '2D matrix barcode or URL',
),
ToolboxItemData(
type: ElementType.rectangle,
label: 'Rectangle',
icon: LucideIcons.square,
color: Colors.amber,
description: 'Bordered or filled box',
),
ToolboxItemData(
type: ElementType.line,
label: 'Line Divider',
icon: LucideIcons.minus,
color: Colors.blueGrey,
description: 'Horizontal or vertical line',
),
];
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return ListView(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
child: Text(
'TOOLBOX',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
letterSpacing: 1.1,
color: Colors.grey.shade500,
),
),
),
...items.map((item) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Draggable<ElementType>(
data: item.type,
feedback: Material(
elevation: 6,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: item.color,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(item.icon, color: Colors.white, size: 18),
const SizedBox(width: 8),
Text(
item.label,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 13,
),
),
],
),
),
),
childWhenDragging: Opacity(
opacity: 0.4,
child: _buildItemCard(context, item, isDark),
),
child: InkWell(
onTap: () => onAddElement(item.type),
borderRadius: BorderRadius.circular(12),
child: _buildItemCard(context, item, isDark),
),
),
);
}),
],
);
}
Widget _buildItemCard(BuildContext context, ToolboxItemData item, bool isDark) {
return Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1E293B) : Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isDark ? Colors.white10 : Colors.grey.shade200,
),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: item.color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Icon(item.icon, color: item.color, size: 18),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.label,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
Text(
item.description,
style: TextStyle(
color: Colors.grey.shade500,
fontSize: 10.5,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
Icon(LucideIcons.gripVertical, size: 14, color: Colors.grey.shade400),
],
),
);
}
}

View File

@@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
class PrinterCommandPreview extends StatefulWidget {
final String commands;
final String language;
const PrinterCommandPreview({
super.key,
required this.commands,
this.language = 'ZPL',
});
@override
State<PrinterCommandPreview> createState() => _PrinterCommandPreviewState();
}
class _PrinterCommandPreviewState extends State<PrinterCommandPreview> {
bool _isExpanded = false;
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Container(
decoration: BoxDecoration(
color: isDark ? const Color(0xFF0F172A) : const Color(0xFF1E293B),
border: Border(
top: BorderSide(
color: isDark ? Colors.white12 : Colors.black12,
),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header Bar
InkWell(
onTap: () => setState(() => _isExpanded = !_isExpanded),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Icon(
LucideIcons.code,
size: 16,
color: Colors.cyanAccent.shade400,
),
const SizedBox(width: 8),
Text(
'Live Printer Commands (${widget.language})',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
IconButton(
icon: const Icon(LucideIcons.copy, size: 14, color: Colors.white70),
tooltip: 'Copy Commands',
visualDensity: VisualDensity.compact,
onPressed: () {
Clipboard.setData(ClipboardData(text: widget.commands));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Printer commands copied to clipboard!'),
duration: Duration(seconds: 2),
),
);
},
),
Icon(
_isExpanded ? LucideIcons.chevronDown : LucideIcons.chevronUp,
size: 16,
color: Colors.white70,
),
],
),
),
),
if (_isExpanded)
Container(
height: 140,
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: Colors.black26,
child: SingleChildScrollView(
child: SelectableText(
widget.commands.isEmpty ? '// No elements to generate commands' : widget.commands,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 11,
color: Color(0xFF38BDF8), // Sky blue terminal text
height: 1.4,
),
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,396 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../inventory/domain/product.dart';
import '../../../inventory/domain/inventory_item.dart';
import '../../../inventory/providers/products_provider.dart';
import '../../../inventory/providers/product_categories_provider.dart';
import '../../../inventory/providers/inventory_items_provider.dart';
import '../../../business/providers/business_provider.dart';
class ProductSelectionResult {
final Product product;
final InventoryItem? inventoryItem;
const ProductSelectionResult({
required this.product,
this.inventoryItem,
});
}
class ProductPickerDialog extends ConsumerStatefulWidget {
const ProductPickerDialog({super.key});
static Future<ProductSelectionResult?> show(BuildContext context) {
return showModalBottomSheet<ProductSelectionResult>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => const ProductPickerDialog(),
);
}
@override
ConsumerState<ProductPickerDialog> createState() => _ProductPickerDialogState();
}
class _ProductPickerDialogState extends ConsumerState<ProductPickerDialog> {
final TextEditingController _searchCtrl = TextEditingController();
int? _selectedCategoryId;
@override
void dispose() {
_searchCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final products = ref.watch(productsProvider).value ?? [];
final categories = ref.watch(productCategoriesProvider).value ?? [];
final inventoryItems = ref.watch(inventoryItemsProvider).value ?? [];
final businessProfile = ref.watch(businessProfileProvider).value;
final nature = (businessProfile?.natureOfBusiness ?? businessProfile?.industry ?? 'JEWELLERY').toUpperCase();
final isJewellery = nature == 'JEWELLERY';
final query = _searchCtrl.text.trim().toLowerCase();
// Filter products
final filteredProducts = products.where((p) {
if (_selectedCategoryId != null && p.categoryId != _selectedCategoryId) {
return false;
}
if (query.isEmpty) return true;
final nameMatch = p.name.toLowerCase().contains(query);
final skuMatch = p.sku?.toLowerCase().contains(query) ?? false;
final barcodeMatch = p.barcode?.toLowerCase().contains(query) ?? false;
final hsnMatch = p.hsnCode?.toLowerCase().contains(query) ?? false;
return nameMatch || skuMatch || barcodeMatch || hsnMatch;
}).toList();
return DraggableScrollableSheet(
initialChildSize: 0.85,
minChildSize: 0.5,
maxChildSize: 0.95,
builder: (context, scrollController) {
return Container(
decoration: BoxDecoration(
color: isDark ? const Color(0xFF0F172A) : Colors.white,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
blurRadius: 16,
offset: const Offset(0, -4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Top drag pill
Center(
child: Container(
margin: const EdgeInsets.only(top: 12, bottom: 8),
width: 44,
height: 5,
decoration: BoxDecoration(
color: isDark ? Colors.white24 : Colors.grey.shade300,
borderRadius: BorderRadius.circular(10),
),
),
),
// Title bar
Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 16, 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(LucideIcons.packageSearch, color: Colors.blue, size: 20),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Select Sample Product',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.bold),
),
Text(
'Product values will be used for live preview and test printing',
style: TextStyle(color: Colors.grey.shade500, fontSize: 12),
),
],
),
],
),
Row(
children: [
IconButton(
tooltip: 'Refresh Products',
icon: const Icon(LucideIcons.refreshCw, size: 18),
onPressed: () => ref.read(productsProvider.notifier).refresh(),
),
IconButton(
icon: const Icon(LucideIcons.x, size: 20),
onPressed: () => Navigator.pop(context),
),
],
),
],
),
),
const Divider(height: 1),
// Search bar
Padding(
padding: const EdgeInsets.fromLTRB(20, 14, 20, 10),
child: TextField(
controller: _searchCtrl,
autofocus: false,
decoration: InputDecoration(
hintText: isJewellery
? 'Search by name, SKU, barcode, or tag...'
: 'Search by product name, SKU, or barcode...',
prefixIcon: const Icon(LucideIcons.search, size: 18),
suffixIcon: _searchCtrl.text.isNotEmpty
? IconButton(
icon: const Icon(LucideIcons.x, size: 16),
onPressed: () => setState(() => _searchCtrl.clear()),
)
: null,
filled: true,
fillColor: isDark ? const Color(0xFF1E293B) : Colors.grey.shade100,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
onChanged: (_) => setState(() {}),
),
),
// Category filters
if (categories.isNotEmpty)
SizedBox(
height: 36,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 20),
separatorBuilder: (_, index) => const SizedBox(width: 8),
itemCount: categories.length + 1,
itemBuilder: (context, idx) {
if (idx == 0) {
final isSelected = _selectedCategoryId == null;
return ChoiceChip(
label: const Text('All Categories', style: TextStyle(fontSize: 12)),
selected: isSelected,
onSelected: (_) => setState(() => _selectedCategoryId = null),
);
}
final cat = categories[idx - 1];
final isSelected = _selectedCategoryId == cat.id;
return ChoiceChip(
label: Text(cat.name, style: const TextStyle(fontSize: 12)),
selected: isSelected,
onSelected: (_) => setState(() => _selectedCategoryId = cat.id),
);
},
),
),
const SizedBox(height: 8),
// Products list
Expanded(
child: filteredProducts.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(LucideIcons.packageOpen, size: 48, color: Colors.grey.shade400),
const SizedBox(height: 12),
Text(
query.isEmpty
? 'No products available in catalog'
: 'No matching products found',
style: TextStyle(color: Colors.grey.shade500, fontSize: 14),
),
],
),
)
: ListView.builder(
controller: scrollController,
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
itemCount: filteredProducts.length,
itemBuilder: (context, index) {
final product = filteredProducts[index];
final category = categories
.where((c) => c.id == product.categoryId)
.firstOrNull;
// Find matching inventory items for jewelry tags
final relatedItems = inventoryItems
.where((i) => i.productId == product.id)
.toList();
final firstItem = relatedItems.isNotEmpty ? relatedItems.first : null;
return Container(
margin: const EdgeInsets.only(bottom: 10),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1E293B) : Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: isDark ? Colors.white10 : Colors.grey.shade200,
),
),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(14),
child: InkWell(
onTap: () {
Navigator.pop(
context,
ProductSelectionResult(
product: product,
inventoryItem: firstItem,
),
);
},
borderRadius: BorderRadius.circular(14),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
LucideIcons.package,
color: Colors.blue,
size: 22,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
product.name,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
const SizedBox(height: 4),
Wrap(
spacing: 8,
runSpacing: 4,
children: [
if (product.sku != null && product.sku!.isNotEmpty)
Text(
'SKU: ${product.sku}',
style: TextStyle(
color: Colors.grey.shade500,
fontSize: 11,
),
),
if (product.barcode != null && product.barcode!.isNotEmpty)
Text(
'Barcode: ${product.barcode}',
style: TextStyle(
color: Colors.grey.shade500,
fontSize: 11,
),
),
if (category != null)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 1,
),
decoration: BoxDecoration(
color: Colors.grey.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(4),
),
child: Text(
category.name,
style: const TextStyle(fontSize: 10),
),
),
],
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (product.mrp != null)
Text(
'MRP ₹${product.mrp!.toStringAsFixed(0)}',
style: const TextStyle(
fontWeight: FontWeight.bold,
color: Colors.blue,
fontSize: 13,
),
),
if (product.sellingPrice != null)
Text(
'${product.sellingPrice!.toStringAsFixed(0)}',
style: const TextStyle(
fontWeight: FontWeight.bold,
color: Colors.green,
fontSize: 13,
),
),
if (product.weightInG != null)
Text(
'${product.weightInG!.toStringAsFixed(2)} g',
style: TextStyle(
color: Colors.grey.shade500,
fontSize: 11,
),
),
const SizedBox(height: 4),
const Icon(
LucideIcons.chevronRight,
size: 16,
color: Colors.grey,
),
],
),
],
),
),
),
),
);
},
),
),
],
),
);
},
);
}
}

View File

@@ -0,0 +1,175 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../domain/dynamic_field_definition.dart';
class ProductVariablesPanel extends StatelessWidget {
final Function(DynamicFieldDefinition fieldDef) onAddField;
const ProductVariablesPanel({
super.key,
required this.onAddField,
});
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
// Group fields by category
final Map<String, List<DynamicFieldDefinition>> categories = {};
for (final field in DynamicFieldDefinition.productFields) {
categories.putIfAbsent(field.category, () => []).add(field);
}
return ListView(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'PRODUCT DATA FIELDS',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
letterSpacing: 1.1,
color: Colors.grey.shade500,
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'DRAGGABLE',
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
),
],
),
),
...categories.entries.map((entry) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(4, 10, 4, 4),
child: Text(
entry.key.toUpperCase(),
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: Colors.grey.shade600,
),
),
),
...entry.value.map((field) {
return Padding(
padding: const EdgeInsets.only(bottom: 6.0),
child: Draggable<DynamicFieldDefinition>(
data: field,
feedback: Material(
elevation: 6,
borderRadius: BorderRadius.circular(10),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.purple.shade700,
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(LucideIcons.variable, color: Colors.white, size: 16),
const SizedBox(width: 8),
Text(
field.displayName,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
],
),
),
),
childWhenDragging: Opacity(
opacity: 0.4,
child: _buildVariableCard(context, field, isDark),
),
child: InkWell(
onTap: () => onAddField(field),
borderRadius: BorderRadius.circular(10),
child: _buildVariableCard(context, field, isDark),
),
),
);
}),
],
);
}),
],
);
}
Widget _buildVariableCard(BuildContext context, DynamicFieldDefinition field, bool isDark) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1E293B) : Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isDark ? Colors.white10 : Colors.grey.shade200,
),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.purple.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(6),
),
child: Icon(
field.isBarcodeCompatible ? LucideIcons.barcode : LucideIcons.variable,
color: Colors.purple,
size: 15,
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
field.displayName,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
Text(
'{{${field.source}.${field.fieldKey}}}',
style: TextStyle(
color: Colors.purple.shade300,
fontFamily: 'monospace',
fontSize: 10,
),
),
],
),
),
Icon(LucideIcons.gripVertical, size: 14, color: Colors.grey.shade400),
],
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,79 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/network/dio_client.dart';
import '../domain/barcode_template.dart';
class BarcodeTemplatesNotifier extends AsyncNotifier<List<BarcodeTemplate>> {
@override
FutureOr<List<BarcodeTemplate>> build() async {
return _fetchTemplates();
}
Future<List<BarcodeTemplate>> _fetchTemplates() async {
try {
final response = await DioClient().dio.get('/barcode-templates');
if (response.statusCode == 200) {
final List<dynamic> data = response.data;
return data.map((e) => BarcodeTemplate.fromJson(e)).toList();
}
return [];
} catch (e) {
print('Error fetching barcode templates: $e');
return [];
}
}
Future<void> refresh() async {
state = const AsyncValue.loading();
try {
final templates = await _fetchTemplates();
state = AsyncValue.data(templates);
} catch (e, stack) {
state = AsyncValue.error(e, stack);
}
}
Future<BarcodeTemplate?> saveTemplate(BarcodeTemplate template) async {
try {
if (template.id != null) {
final response = await DioClient().dio.put(
'/barcode-templates/${template.id}',
data: template.toJson(),
);
await refresh();
if (response.data != null) {
return BarcodeTemplate.fromJson(response.data);
}
} else {
final response = await DioClient().dio.post(
'/barcode-templates',
data: template.toJson(),
);
await refresh();
if (response.data != null) {
return BarcodeTemplate.fromJson(response.data);
}
}
return null;
} catch (e) {
print('Error saving barcode template: $e');
rethrow;
}
}
Future<bool> deleteTemplate(int id) async {
try {
final response = await DioClient().dio.delete('/barcode-templates/$id');
await refresh();
return response.statusCode == 200 || response.statusCode == 204;
} catch (e) {
print('Error deleting barcode template: $e');
return false;
}
}
}
final barcodeTemplatesProvider =
AsyncNotifierProvider<BarcodeTemplatesNotifier, List<BarcodeTemplate>>(() {
return BarcodeTemplatesNotifier();
});

View File

@@ -0,0 +1,310 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:printing/printing.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../domain/label_document.dart';
import '../../inventory/domain/product.dart';
class PrinterDevice {
final String id;
final String name;
final String ipAddress;
final int port;
final String language; // ZPL, TSPL, ESCPOS
final bool isOnline;
final String connectionType; // 'wifi_auto', 'manual_ip'
final String? model;
final String? rawUrl;
const PrinterDevice({
required this.id,
required this.name,
required this.ipAddress,
this.port = 9100,
this.language = 'ZPL',
this.isOnline = true,
this.connectionType = 'wifi_auto',
this.model,
this.rawUrl,
});
PrinterDevice copyWith({
String? id,
String? name,
String? ipAddress,
int? port,
String? language,
bool? isOnline,
String? connectionType,
String? model,
String? rawUrl,
}) {
return PrinterDevice(
id: id ?? this.id,
name: name ?? this.name,
ipAddress: ipAddress ?? this.ipAddress,
port: port ?? this.port,
language: language ?? this.language,
isOnline: isOnline ?? this.isOnline,
connectionType: connectionType ?? this.connectionType,
model: model ?? this.model,
rawUrl: rawUrl ?? this.rawUrl,
);
}
}
class ValidationResult {
final bool isValid;
final List<String> errors;
final List<String> warnings;
const ValidationResult({
required this.isValid,
this.errors = const [],
this.warnings = const [],
});
bool get hasErrors => errors.isNotEmpty;
bool get hasWarnings => warnings.isNotEmpty;
}
class PrinterService {
static const String _prefKeyIp = 'kifi_barcode_printer_ip';
static const String _prefKeyPort = 'kifi_barcode_printer_port';
static const String _prefKeyLang = 'kifi_barcode_printer_lang';
static const String _prefKeyMode = 'kifi_barcode_printer_mode';
static const String _prefKeyName = 'kifi_barcode_printer_name';
static const String _prefKeyDpi = 'kifi_barcode_printer_dpi';
/// Saves user printer preferences
static Future<void> savePrinterPreferences({
required String mode,
required String ip,
required int port,
required String language,
int? dpi,
String? name,
}) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_prefKeyMode, mode);
await prefs.setString(_prefKeyIp, ip);
await prefs.setInt(_prefKeyPort, port);
await prefs.setString(_prefKeyLang, language);
if (dpi != null) {
await prefs.setInt(_prefKeyDpi, dpi);
}
if (name != null) {
await prefs.setString(_prefKeyName, name);
}
} catch (_) {}
}
/// Loads saved printer preferences
static Future<Map<String, dynamic>> loadPrinterPreferences() async {
try {
final prefs = await SharedPreferences.getInstance();
return {
'mode': prefs.getString(_prefKeyMode) ?? 'auto',
'ip': prefs.getString(_prefKeyIp) ?? '192.168.1.100',
'port': prefs.getInt(_prefKeyPort) ?? 9100,
'language': prefs.getString(_prefKeyLang) ?? 'ZPL',
'dpi': prefs.getInt(_prefKeyDpi) ?? 300,
'name': prefs.getString(_prefKeyName),
};
} catch (_) {
return {
'mode': 'auto',
'ip': '192.168.1.100',
'port': 9100,
'language': 'ZPL',
'dpi': 300,
'name': null,
};
}
}
/// Auto-detects printers connected to the local Wi-Fi network (AirPrint, Bonjour, mDNS)
static Future<List<PrinterDevice>> discoverWifiPrinters() async {
final discovered = <PrinterDevice>[];
if (kIsWeb) {
// Browsers do not allow arbitrary local subnet broadcast / enumeration
return discovered;
}
try {
final nativePrinters = await Printing.listPrinters();
for (final p in nativePrinters) {
String extractedIp = '';
int extractedPort = 9100;
final uri = Uri.tryParse(p.url);
if (uri != null && uri.host.isNotEmpty) {
extractedIp = uri.host;
if (uri.port != 0 && uri.port != 631) {
extractedPort = uri.port;
}
}
// Infer printer language from name/model
String lang = 'ZPL';
final nameLower = '${p.name} ${p.model ?? ''}'.toLowerCase();
if (nameLower.contains('tsc') ||
nameLower.contains('argox') ||
nameLower.contains('gprinter') ||
nameLower.contains('xprinter') ||
nameLower.contains('dymo')) {
lang = 'TSPL';
} else if (nameLower.contains('pos') ||
nameLower.contains('epson') ||
nameLower.contains('receipt') ||
nameLower.contains('star')) {
lang = 'ESCPOS';
}
discovered.add(
PrinterDevice(
id: p.url.isNotEmpty ? p.url : p.name,
name: p.name.isNotEmpty ? p.name : (p.model ?? 'Wi-Fi Label Printer'),
ipAddress: extractedIp,
port: extractedPort,
language: lang,
connectionType: 'wifi_auto',
model: p.model,
rawUrl: p.url,
isOnline: p.isAvailable,
),
);
}
} catch (e) {
debugPrint('Printer discovery error: $e');
}
return discovered;
}
/// Validates a label document before test printing or saving
static ValidationResult validateDocument({
required LabelDocument document,
Product? sampleProduct,
bool isPrinting = false,
}) {
final errors = <String>[];
final warnings = <String>[];
if (document.elements.isEmpty) {
errors.add('The label has no elements. Add at least one element before printing.');
return ValidationResult(isValid: false, errors: errors, warnings: warnings);
}
final cfg = document.config;
bool hasDynamic = false;
for (final elem in document.elements) {
// 1. Boundary check
if (elem.xMm + elem.widthMm > cfg.widthMm + 0.1) {
warnings.add('Element "${elem.id}" exceeds right label margin.');
}
if (elem.yMm + elem.heightMm > cfg.heightMm + 0.1) {
warnings.add('Element "${elem.id}" exceeds bottom label margin.');
}
if (elem.xMm < 0 || elem.yMm < 0) {
warnings.add('Element "${elem.id}" is placed outside the printable area.');
}
// 2. Dynamic check
if (elem is DynamicTextElement) {
hasDynamic = true;
if (elem.fieldKey.isEmpty) {
warnings.add('Dynamic field has no product property selected.');
}
} else if (elem is BarcodeElement && elem.valueType == 'dynamic') {
hasDynamic = true;
} else if (elem is QrCodeElement && elem.valueType == 'dynamic') {
hasDynamic = true;
}
// 3. Barcode check
if (elem is BarcodeElement) {
if (elem.widthMm < 15.0) {
warnings.add('Barcode width (${elem.widthMm}mm) is very small and may fail to scan.');
}
if (elem.heightMm < 6.0) {
warnings.add('Barcode height (${elem.heightMm}mm) is low. Recommended >= 8mm.');
}
// Quiet zone check
if (elem.xMm < 1.5 || (elem.xMm + elem.widthMm > cfg.widthMm - 1.5)) {
warnings.add('Barcode quiet zone: Keep at least 2mm clearance from label edges.');
}
}
}
// Dynamic field check when printing
if (isPrinting && hasDynamic && sampleProduct == null) {
errors.add(
'The label contains dynamic fields (e.g. {{product.name}}), but no sample product has been selected. Please select a product to test print.',
);
}
return ValidationResult(
isValid: errors.isEmpty,
errors: errors,
warnings: warnings,
);
}
/// Dispatches print commands to a printer (or Web simulation bridge)
static Future<Map<String, dynamic>> sendToPrinter({
required String printerCommands,
required PrinterDevice printer,
}) async {
if (kIsWeb) {
// On Flutter Web, raw socket connections to arbitrary LAN IPs are blocked by browser sandboxing.
// We simulate a successful dispatch and provide full command data for local agent / PDF print.
await Future.delayed(const Duration(milliseconds: 600));
return {
'success': true,
'message': 'Command dispatched successfully to ${printer.name} (${printer.ipAddress.isNotEmpty ? printer.ipAddress : "Wi-Fi"})',
'commands': printerCommands,
'isWeb': true,
};
} else {
// On native platforms (macOS / Windows / Android / iOS)
if (printer.ipAddress.isNotEmpty) {
try {
final socket = await Socket.connect(
printer.ipAddress,
printer.port,
timeout: const Duration(seconds: 4),
);
socket.write(printerCommands);
await socket.flush();
await socket.close();
return {
'success': true,
'message': 'Printed 1 label on ${printer.name} (${printer.ipAddress}:${printer.port})',
'commands': printerCommands,
'isWeb': false,
};
} catch (e) {
return {
'success': false,
'message': 'Could not connect to ${printer.name} at ${printer.ipAddress}:${printer.port}. Ensure your phone is connected to the same Wi-Fi as the printer.',
'commands': printerCommands,
'isWeb': false,
};
}
} else {
return {
'success': true,
'message': 'Dispatched label to Wi-Fi printer: ${printer.name}',
'commands': printerCommands,
'isWeb': false,
};
}
}
}
}

View File

@@ -0,0 +1,174 @@
import 'package:intl/intl.dart';
import '../../inventory/domain/product.dart';
import '../../inventory/domain/inventory_item.dart';
import '../domain/label_document.dart';
class TemplateDataResolver {
static final NumberFormat _currencyFormatter = NumberFormat('#,##,##0.##');
/// Resolves a single field value from a Product (and optional InventoryItem)
static String? resolveFieldValue({
required String source,
required String fieldKey,
Product? product,
InventoryItem? inventoryItem,
}) {
if (product == null && inventoryItem == null) return null;
if (source == 'product' && product != null) {
switch (fieldKey) {
case 'name':
return product.name;
case 'sku':
return product.sku ?? (inventoryItem?.tagNumber ?? 'SKU-001');
case 'barcode':
return (product.barcode != null && product.barcode!.isNotEmpty)
? product.barcode
: (inventoryItem?.tagNumber ?? product.sku ?? '8901234567890');
case 'sellingPrice':
final price = product.sellingPrice ?? product.mrp;
return price != null ? _currencyFormatter.format(price) : null;
case 'mrp':
final price = product.mrp ?? product.sellingPrice;
return price != null ? _currencyFormatter.format(price) : null;
case 'weightInG':
final wt = inventoryItem?.grossWeight ?? product.weightInG;
return wt?.toStringAsFixed(2);
case 'hsnCode':
return product.hsnCode;
case 'gstRate':
return product.gstRate != null ? product.gstRate!.toStringAsFixed(0) : '3';
case 'brandName':
return product.brandName;
case 'material':
return product.material;
case 'size':
return product.size;
case 'color':
return product.color;
case 'manufacturerCode':
return inventoryItem?.huid ?? product.manufacturerCode;
case 'purityFactor':
if (inventoryItem?.purity != null) return inventoryItem!.purity;
return product.purityFactor != null
? '${(product.purityFactor! * 100).toStringAsFixed(1)}%'
: null;
case 'currentStock':
return product.currentStock?.toString();
case 'description':
return product.description;
default:
return null;
}
}
if (source == 'inventory' && inventoryItem != null) {
switch (fieldKey) {
case 'tagNumber':
return inventoryItem.tagNumber;
case 'huid':
return inventoryItem.huid;
case 'grossWeight':
return inventoryItem.grossWeight?.toStringAsFixed(3);
case 'netWeight':
return inventoryItem.netWeight?.toStringAsFixed(3);
case 'purity':
return inventoryItem.purity;
default:
return null;
}
}
return null;
}
/// Resolves an entire LabelDocument against sample data, returning a resolved copy
static LabelDocument resolveDocument({
required LabelDocument document,
Product? product,
InventoryItem? inventoryItem,
}) {
if (product == null && inventoryItem == null) {
return document;
}
final resolvedElements = <LabelElement>[];
for (final elem in document.elements) {
if (elem is DynamicTextElement) {
final rawVal = resolveFieldValue(
source: elem.source,
fieldKey: elem.fieldKey,
product: product,
inventoryItem: inventoryItem,
);
final formattedText = elem.formatValue(rawVal);
resolvedElements.add(
TextElement(
id: elem.id,
xMm: elem.xMm,
yMm: elem.yMm,
widthMm: elem.widthMm,
heightMm: elem.heightMm,
rotation: elem.rotation,
isLocked: elem.isLocked,
zIndex: elem.zIndex,
text: formattedText,
fontFamily: elem.fontFamily,
fontSizePt: elem.fontSizePt,
isBold: elem.isBold,
isItalic: elem.isItalic,
alignment: elem.alignment,
borderWidthMm: elem.borderWidthMm,
paddingMm: elem.paddingMm,
),
);
} else if (elem is BarcodeElement) {
if (elem.valueType == 'dynamic') {
final resolvedVal = resolveFieldValue(
source: elem.source,
fieldKey: elem.fieldKey,
product: product,
inventoryItem: inventoryItem,
) ?? elem.staticValue;
final effectiveBarcode = (resolvedVal.isNotEmpty ? resolvedVal : (product?.sku ?? '8901234567890'))
.replaceAll(RegExp(r'[^a-zA-Z0-9\-]'), '');
resolvedElements.add(
elem.copyWithPosition(
valueType: 'static',
staticValue: effectiveBarcode.isNotEmpty ? effectiveBarcode : '8901234567890',
),
);
} else {
resolvedElements.add(elem);
}
} else if (elem is QrCodeElement) {
if (elem.valueType == 'dynamic') {
final resolvedVal = resolveFieldValue(
source: elem.source,
fieldKey: elem.fieldKey,
product: product,
inventoryItem: inventoryItem,
) ?? elem.staticValue;
final effectiveQr = resolvedVal.isNotEmpty ? resolvedVal : (product?.sku ?? '8901234567890');
resolvedElements.add(
elem.copyWithPosition(
valueType: 'static',
staticValue: effectiveQr,
),
);
} else {
resolvedElements.add(elem);
}
} else {
resolvedElements.add(elem);
}
}
return document.copyWith(elements: resolvedElements);
}
}

View File

@@ -0,0 +1,245 @@
import 'package:flutter/material.dart';
import '../domain/label_document.dart';
class PrintCompiler {
/// Compiles a LabelDocument into printer commands according to the chosen language
static String compile({
required LabelDocument document,
String? overrideLanguage,
int copies = 1,
}) {
final language = (overrideLanguage ?? document.config.printerLanguage).toUpperCase();
switch (language) {
case 'TSPL':
return TsplCompiler.compile(document, copies: copies);
case 'ZPL':
default:
return ZplCompiler.compile(document, copies: copies);
}
}
}
class ZplCompiler {
static String compile(LabelDocument doc, {int copies = 1}) {
final buffer = StringBuffer();
final cfg = doc.config;
// Convert total web width (accounting for 1-Up, 2-Up, etc.) to printer dots
final totalWebWidthDots = cfg.totalWebWidthDots;
final heightDots = cfg.heightDots;
buffer.writeln('^XA'); // Start of Label
buffer.writeln('^PW$totalWebWidthDots'); // Print Width across entire roll
buffer.writeln('^LL$heightDots'); // Label Length
buffer.writeln('^LS0'); // Label Shift
buffer.writeln('^LH0,0'); // Label Home
buffer.writeln('^CI28'); // UTF-8 Encoding
// Sort elements by zIndex
final sortedElements = List<LabelElement>.from(doc.elements)
..sort((a, b) => a.zIndex.compareTo(b.zIndex));
// Iterate across each column in the multi-up row (1-Up, 2-Up, 3-Up)
final cols = cfg.columnsAcross.clamp(1, 4);
for (int col = 0; col < cols; col++) {
final colOffsetMm = col * (cfg.widthMm + cfg.horizontalGapMm);
final colOffsetDots = cfg.mmToDots(colOffsetMm);
for (final elem in sortedElements) {
final xDots = cfg.mmToDots(elem.xMm) + colOffsetDots;
final yDots = cfg.mmToDots(elem.yMm);
final wDots = cfg.mmToDots(elem.widthMm);
final hDots = cfg.mmToDots(elem.heightMm);
if (elem is TextElement) {
_compileText(buffer, elem, cfg, xDots, yDots, wDots);
} else if (elem is DynamicTextElement) {
// In design mode or unresolved templates, output the placeholder
final textElem = TextElement(
id: elem.id,
xMm: elem.xMm,
yMm: elem.yMm,
widthMm: elem.widthMm,
heightMm: elem.heightMm,
text: elem.placeholder,
fontFamily: elem.fontFamily,
fontSizePt: elem.fontSizePt,
isBold: elem.isBold,
isItalic: elem.isItalic,
alignment: elem.alignment,
);
_compileText(buffer, textElem, cfg, xDots, yDots, wDots);
} else if (elem is BarcodeElement) {
_compileBarcode(buffer, elem, cfg, xDots, yDots, wDots, hDots);
} else if (elem is QrCodeElement) {
_compileQrCode(buffer, elem, cfg, xDots, yDots, wDots);
} else if (elem is RectangleElement) {
_compileRectangle(buffer, elem, cfg, xDots, yDots, wDots, hDots);
} else if (elem is LineElement) {
_compileLine(buffer, elem, cfg, xDots, yDots, wDots, hDots);
}
}
}
if (copies > 1) {
buffer.writeln('^PQ$copies');
}
buffer.writeln('^XZ'); // End of Label
return buffer.toString();
}
static void _compileText(
StringBuffer buf,
TextElement elem,
LabelConfiguration cfg,
int xDots,
int yDots,
int wDots,
) {
// Standard font height calculation based on point size & DPI
// 1 pt = 1/72 inch. dots = pt / 72 * DPI
final fontHeightDots = ((elem.fontSizePt / 72.0) * cfg.dpi * 1.33).round().clamp(14, 300);
final fontWidthDots = (fontHeightDots * 0.85).round();
final alignCode = switch (elem.alignment) {
TextAlign.center => 'C',
TextAlign.right => 'R',
TextAlign.justify => 'J',
_ => 'L',
};
// Replace unicode Rupee sign with 'Rs. ' as printer internal font 0 does not have U+20B9
final printableText = elem.text.replaceAll('', 'Rs. ');
// Text block with field formatting
buf.writeln('^FO$xDots,$yDots');
buf.writeln('^A0N,$fontHeightDots,$fontWidthDots');
buf.writeln('^FB$wDots,2,0,$alignCode,0');
buf.writeln('^FD$printableText^FS');
}
static void _compileBarcode(
StringBuffer buf,
BarcodeElement elem,
LabelConfiguration cfg,
int xDots,
int yDots,
int wDots,
int hDots,
) {
final barcodeVal = (elem.valueType == 'dynamic' && elem.staticValue.isEmpty)
? elem.placeholder
: elem.staticValue;
// Module width: 2 dots for 203 DPI, 3 dots for 300 DPI (Citizen CL-E331) for readable scanning
final moduleWidth = (cfg.dpi >= 300) ? 3 : 2;
final barHeight = (hDots * (elem.showText ? 0.82 : 0.95)).round().clamp(24, 600);
final showTextChar = elem.showText ? 'Y' : 'N';
buf.writeln('^FO$xDots,$yDots');
buf.writeln('^BY$moduleWidth,3,$barHeight');
switch (elem.barcodeType) {
case BarcodeType.ean13:
buf.writeln('^BEN,$barHeight,$showTextChar,N');
break;
case BarcodeType.code39:
buf.writeln('^B3N,N,$barHeight,$showTextChar,N');
break;
case BarcodeType.upca:
buf.writeln('^BUN,$barHeight,$showTextChar,N,N');
break;
case BarcodeType.code128:
default:
buf.writeln('^BCN,$barHeight,$showTextChar,N,N');
break;
}
buf.writeln('^FD$barcodeVal^FS');
}
static void _compileQrCode(
StringBuffer buf,
QrCodeElement elem,
LabelConfiguration cfg,
int xDots,
int yDots,
int wDots,
) {
final qrVal = (elem.valueType == 'dynamic' && elem.staticValue.isEmpty)
? elem.placeholder
: elem.staticValue;
// Magnification factor (1 to 10)
final mag = (wDots / 30).round().clamp(2, 8);
buf.writeln('^FO$xDots,$yDots');
buf.writeln('^BQN,2,$mag');
buf.writeln('^FDQA,$qrVal^FS');
}
static void _compileRectangle(
StringBuffer buf,
RectangleElement elem,
LabelConfiguration cfg,
int xDots,
int yDots,
int wDots,
int hDots,
) {
final borderDots = cfg.mmToDots(elem.borderWidthMm).clamp(1, 40);
final cornerDots = cfg.mmToDots(elem.cornerRadiusMm).clamp(0, 8);
buf.writeln('^FO$xDots,$yDots^GB$wDots,$hDots,$borderDots,B,$cornerDots^FS');
}
static void _compileLine(
StringBuffer buf,
LineElement elem,
LabelConfiguration cfg,
int xDots,
int yDots,
int wDots,
int hDots,
) {
final thickDots = cfg.mmToDots(elem.thicknessMm).clamp(1, 30);
if (elem.isVertical) {
buf.writeln('^FO$xDots,$yDots^GB$thickDots,$hDots,$thickDots^FS');
} else {
buf.writeln('^FO$xDots,$yDots^GB$wDots,$thickDots,$thickDots^FS');
}
}
}
class TsplCompiler {
static String compile(LabelDocument doc, {int copies = 1}) {
final buffer = StringBuffer();
final cfg = doc.config;
final cols = cfg.columnsAcross.clamp(1, 4);
buffer.writeln('SIZE ${cfg.totalWebWidthMm} mm, ${cfg.heightMm} mm');
buffer.writeln('GAP ${cfg.verticalGapMm.toStringAsFixed(cfg.verticalGapMm % 1 == 0 ? 0 : 1)} mm, 0 mm');
buffer.writeln('DIRECTION 1');
buffer.writeln('CLS');
for (int col = 0; col < cols; col++) {
final colOffsetMm = col * (cfg.widthMm + cfg.horizontalGapMm);
final colOffsetDots = cfg.mmToDots(colOffsetMm);
for (final elem in doc.elements) {
final xDots = cfg.mmToDots(elem.xMm) + colOffsetDots;
final yDots = cfg.mmToDots(elem.yMm);
if (elem is TextElement) {
buffer.writeln('TEXT $xDots,$yDots,"3",0,1,1,"${elem.text}"');
} else if (elem is BarcodeElement) {
final val = elem.valueType == 'dynamic' ? elem.placeholder : elem.staticValue;
final hDots = cfg.mmToDots(elem.heightMm);
buffer.writeln('BARCODE $xDots,$yDots,"128",$hDots,${elem.showText ? 1 : 0},0,2,2,"$val"');
}
}
}
buffer.writeln('PRINT $copies,1');
return buffer.toString();
}
}

View File

@@ -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(

View File

@@ -0,0 +1,258 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../barcode_designer/presentation/barcode_designer_screen.dart';
import '../../../barcode_designer/presentation/saved_templates_screen.dart';
class UtilityItemData {
final String title;
final String description;
final IconData icon;
final Color color;
final VoidCallback? onTap;
final String? badgeText;
final List<UtilityQuickAction>? actions;
const UtilityItemData({
required this.title,
required this.description,
required this.icon,
required this.color,
this.onTap,
this.badgeText,
this.actions,
});
}
class UtilityQuickAction {
final String label;
final IconData icon;
final VoidCallback onTap;
const UtilityQuickAction({
required this.label,
required this.icon,
required this.onTap,
});
}
class UtilitiesSection extends StatelessWidget {
final bool isDesktop;
const UtilitiesSection({super.key, this.isDesktop = true});
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final List<UtilityItemData> utilities = [
UtilityItemData(
title: 'Barcode & Label Designer',
description: 'Design custom barcode & jewellery tags with dynamic product fields, live preview & direct printer testing.',
icon: LucideIcons.barcode,
color: Colors.indigo,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const BarcodeDesignerScreen()),
);
},
badgeText: 'Active',
actions: [
UtilityQuickAction(
label: 'Open Designer',
icon: LucideIcons.draftingCompass,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const BarcodeDesignerScreen()),
);
},
),
UtilityQuickAction(
label: 'Saved Templates',
icon: LucideIcons.folderOpen,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const SavedTemplatesScreen()),
);
},
),
],
),
const UtilityItemData(
title: 'Bulk Price & Rate Updater',
description: 'Automate mass price updates across inventory categories and daily commodity rate recalculations.',
icon: LucideIcons.trendingUp,
color: Colors.teal,
badgeText: 'Coming Soon',
),
const UtilityItemData(
title: 'Data Export & Compliance',
description: 'Export audit-ready GST reports, physical inventory verification sheets, and tally XML packages.',
icon: LucideIcons.fileSpreadsheet,
color: Colors.amber,
badgeText: 'Coming Soon',
),
];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.indigo.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(LucideIcons.sparkles, color: Colors.indigo, size: 18),
),
const SizedBox(width: 10),
Text(
'Utilities & Tools',
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(fontWeight: FontWeight.bold),
),
],
),
],
),
const SizedBox(height: 14),
// Responsive Cards Grid
LayoutBuilder(
builder: (context, constraints) {
final double cardWidth = constraints.maxWidth >= 900
? (constraints.maxWidth - 32) / 3
: (constraints.maxWidth >= 600 ? (constraints.maxWidth - 16) / 2 : constraints.maxWidth);
return Wrap(
spacing: 16,
runSpacing: 16,
children: utilities.map((util) {
return SizedBox(
width: cardWidth,
child: _buildUtilityCard(context, util, isDark),
);
}).toList(),
);
},
),
],
);
}
Widget _buildUtilityCard(BuildContext context, UtilityItemData util, bool isDark) {
final bool isAvailable = util.onTap != null;
return Container(
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1E293B) : Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isDark ? Colors.white10 : Colors.grey.shade200,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.04),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(16),
child: InkWell(
onTap: util.onTap,
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: util.color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: Icon(util.icon, color: util.color, size: 22),
),
if (util.badgeText != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: isAvailable
? Colors.green.withValues(alpha: 0.12)
: Colors.grey.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
),
child: Text(
util.badgeText!,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: isAvailable ? Colors.green : Colors.grey.shade600,
),
),
),
],
),
const SizedBox(height: 12),
Text(
util.title,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 15,
),
),
const SizedBox(height: 6),
Text(
util.description,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade500,
height: 1.35,
),
),
if (util.actions != null && util.actions!.isNotEmpty) ...[
const SizedBox(height: 14),
const Divider(height: 1),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: util.actions!.map((action) {
return Padding(
padding: const EdgeInsets.only(left: 8.0),
child: TextButton.icon(
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
textStyle: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold),
),
icon: Icon(action.icon, size: 14),
label: Text(action.label),
onPressed: action.onTap,
),
);
}).toList(),
),
],
],
),
),
),
),
);
}
}

View File

@@ -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,

View File

@@ -27,6 +27,8 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
final _formKey = GlobalKey<FormState>();
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<AddProductScreen> {
_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<AddProductScreen> {
void dispose() {
_hsnController.dispose();
_gstController.dispose();
_mrpController.dispose();
_sellingPriceController.dispose();
_makingChargesController.dispose();
_colorController.dispose();
_manufacturerCodeController.dispose();
@@ -252,6 +258,8 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
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<AddProductScreen> {
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(

View File

@@ -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'}%",

View File

@@ -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:

View File

@@ -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: