Files
Kifi/kifi-app/lib/features/barcode_designer/presentation/barcode_designer_screen.dart

1970 lines
76 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../domain/label_document.dart';
import '../domain/dynamic_field_definition.dart';
import '../domain/barcode_template.dart';
import '../providers/barcode_designer_provider.dart';
import '../providers/barcode_templates_provider.dart';
import 'widgets/canvas_ruler.dart';
import 'widgets/designer_toolbox.dart';
import 'widgets/product_variables_panel.dart';
import 'widgets/element_properties_panel.dart';
import 'widgets/printer_command_preview.dart';
import 'widgets/test_print_modal.dart';
import 'widgets/product_picker_dialog.dart';
import '../../inventory/domain/product.dart';
import '../../inventory/domain/inventory_item.dart';
import '../../inventory/providers/products_provider.dart';
import '../services/template_data_resolver.dart';
import 'package:barcode_widget/barcode_widget.dart' as bc;
import 'saved_templates_screen.dart';
class BarcodeDesignerScreen extends ConsumerStatefulWidget {
final BarcodeTemplate? initialTemplate;
const BarcodeDesignerScreen({super.key, this.initialTemplate});
@override
ConsumerState<BarcodeDesignerScreen> createState() => _BarcodeDesignerScreenState();
}
class _BarcodeDesignerScreenState extends ConsumerState<BarcodeDesignerScreen> {
int _leftTabIndex = 0; // 0: Toolbox, 1: Product Fields
bool _isSaving = false;
bool _isMobileBottomPanelExpanded = true;
late final FocusNode _canvasFocusNode;
@override
void initState() {
super.initState();
_canvasFocusNode = FocusNode(debugLabel: 'BarcodeDesignerCanvas');
WidgetsBinding.instance.addPostFrameCallback((_) {
if (widget.initialTemplate != null) {
final doc = widget.initialTemplate!.toLabelDocument();
ref.read(barcodeDesignerProvider.notifier).loadDocument(doc, widget.initialTemplate!.id);
}
});
}
@override
void dispose() {
_canvasFocusNode.dispose();
super.dispose();
}
/// Returns true if the user currently has focus inside any text input field
/// so that typing, deleting text, or backspacing is NOT intercepted by canvas shortcuts.
bool _isUserTyping() {
final primaryFocus = FocusManager.instance.primaryFocus;
if (primaryFocus == null) return false;
if (primaryFocus == _canvasFocusNode) return false;
final context = primaryFocus.context;
if (context != null && context.mounted) {
if (context.widget is EditableText) return true;
if (context.findAncestorWidgetOfExactType<EditableText>() != null) return true;
if (context.findAncestorStateOfType<EditableTextState>() != null) return true;
if (context.findAncestorWidgetOfExactType<TextField>() != null) return true;
if (context.findAncestorWidgetOfExactType<TextFormField>() != null) return true;
final renderObject = context.findRenderObject();
if (renderObject != null && renderObject.runtimeType.toString().contains('RenderEditable')) {
return true;
}
}
final debugLabel = primaryFocus.debugLabel?.toLowerCase() ?? '';
if (debugLabel.contains('editabletext') || debugLabel.contains('textfield')) {
return true;
}
return false;
}
// Pixels per millimeter based on zoom level
double _getScale(double zoom) => 3.2 * zoom;
@override
Widget build(BuildContext context) {
final state = ref.watch(barcodeDesignerProvider);
final notifier = ref.read(barcodeDesignerProvider.notifier);
final isDark = Theme.of(context).brightness == Brightness.dark;
// Keep sampleProduct in sync with the live products list in case it was updated
final products = ref.watch(productsProvider).value ?? [];
final currentSampleProduct = state.sampleProduct != null
? (products.where((p) => p.id == state.sampleProduct!.id).firstOrNull ?? state.sampleProduct)
: null;
ref.listen(productsProvider, (prev, next) {
final list = next.value;
if (list != null && state.sampleProduct != null) {
final updatedProd = list.where((p) => p.id == state.sampleProduct!.id).firstOrNull;
if (updatedProd != null &&
(updatedProd.mrp != state.sampleProduct!.mrp ||
updatedProd.sellingPrice != state.sampleProduct!.sellingPrice ||
updatedProd.name != state.sampleProduct!.name ||
updatedProd.barcode != state.sampleProduct!.barcode)) {
notifier.setSampleProduct(updatedProd, state.sampleInventoryItem);
}
}
});
final doc = state.document;
final cfg = doc.config;
final scale = _getScale(state.zoom);
// Active elements to render (resolved in preview mode using live product data)
final renderDoc = state.isPreviewMode
? (currentSampleProduct != null
? TemplateDataResolver.resolveDocument(
document: state.document,
product: currentSampleProduct,
inventoryItem: state.sampleInventoryItem,
)
: state.resolvedDocument)
: state.document;
return Scaffold(
backgroundColor: isDark ? const Color(0xFF090D16) : const Color(0xFFF8FAFC),
appBar: _buildTopAppBar(context, state, notifier, isDark),
body: Focus(
focusNode: _canvasFocusNode,
autofocus: true,
onKeyEvent: (node, event) {
if (event is KeyDownEvent || event is KeyRepeatEvent) {
// When user is typing inside any text input (e.g. properties panel),
// ignore canvas shortcuts so Backspace/Delete/Arrows edit text normally.
if (_isUserTyping()) {
return KeyEventResult.ignored;
}
final isDeleteKey = event.logicalKey == LogicalKeyboardKey.delete ||
event.logicalKey == LogicalKeyboardKey.backspace ||
event.physicalKey == PhysicalKeyboardKey.delete ||
event.physicalKey == PhysicalKeyboardKey.backspace;
if (isDeleteKey) {
if (event is KeyDownEvent && state.selectedElementId != null) {
if (state.selectedElement?.isLocked == true) {
return KeyEventResult.handled;
}
notifier.deleteElement(state.selectedElementId!);
return KeyEventResult.handled;
}
}
if (state.selectedElement != null) {
final elem = state.selectedElement!;
final step = HardwareKeyboard.instance.isShiftPressed ? 2.0 : 0.5;
if (event.logicalKey == LogicalKeyboardKey.arrowLeft) {
notifier.setElementPosition(elem.id, elem.xMm - step, elem.yMm);
return KeyEventResult.handled;
} else if (event.logicalKey == LogicalKeyboardKey.arrowRight) {
notifier.setElementPosition(elem.id, elem.xMm + step, elem.yMm);
return KeyEventResult.handled;
} else if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
notifier.setElementPosition(elem.id, elem.xMm, elem.yMm - step);
return KeyEventResult.handled;
} else if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
notifier.setElementPosition(elem.id, elem.xMm, elem.yMm + step);
return KeyEventResult.handled;
}
}
if (event.logicalKey == LogicalKeyboardKey.escape) {
notifier.selectElement(null);
return KeyEventResult.handled;
}
if ((HardwareKeyboard.instance.isControlPressed || HardwareKeyboard.instance.isMetaPressed) &&
event.logicalKey == LogicalKeyboardKey.keyD) {
if (event is KeyDownEvent && state.selectedElementId != null) {
notifier.duplicateElement(state.selectedElementId!);
return KeyEventResult.handled;
}
}
if ((HardwareKeyboard.instance.isControlPressed || HardwareKeyboard.instance.isMetaPressed) &&
event.logicalKey == LogicalKeyboardKey.keyZ) {
if (event is KeyDownEvent) {
if (HardwareKeyboard.instance.isShiftPressed) {
notifier.redo();
} else {
notifier.undo();
}
return KeyEventResult.handled;
}
}
// Bring to Front (]) and Send to Back ([) shortcuts
if (event is KeyDownEvent && state.selectedElementId != null) {
if (event.logicalKey == LogicalKeyboardKey.bracketRight) {
notifier.bringToFront(state.selectedElementId!);
return KeyEventResult.handled;
} else if (event.logicalKey == LogicalKeyboardKey.bracketLeft) {
notifier.sendToBack(state.selectedElementId!);
return KeyEventResult.handled;
}
}
}
return KeyEventResult.ignored;
},
child: LayoutBuilder(
builder: (context, constraints) {
final isDesktop = constraints.maxWidth >= 960;
if (!isDesktop) {
return _buildMobileLayout(context, state, notifier, renderDoc, scale, isDark, currentSampleProduct);
}
return Column(
children: [
Expanded(
child: Row(
children: [
// Left Panel (Toolbox & Variables)
SizedBox(
width: 270,
child: Container(
decoration: BoxDecoration(
color: isDark ? const Color(0xFF111827) : Colors.white,
border: Border(
right: BorderSide(
color: isDark ? Colors.white10 : Colors.grey.shade200,
),
),
),
child: Column(
children: [
// Tab selector
Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 4),
child: SegmentedButton<int>(
segments: const [
ButtonSegment(
value: 0,
label: Text('Tools', style: TextStyle(fontSize: 12)),
icon: Icon(LucideIcons.wrench, size: 14),
),
ButtonSegment(
value: 1,
label: Text('Variables', style: TextStyle(fontSize: 12)),
icon: Icon(LucideIcons.variable, size: 14),
),
],
selected: {_leftTabIndex},
onSelectionChanged: (set) => setState(() => _leftTabIndex = set.first),
),
),
const Divider(height: 12),
Expanded(
child: _leftTabIndex == 0
? DesignerToolbox(
onAddElement: (type) => _addNewElement(type, notifier, cfg),
)
: ProductVariablesPanel(
onAddField: (field) {
notifier.addBoundFieldElement(
fieldDef: field,
dropXMm: 4.0,
dropYMm: 6.0,
);
},
),
),
],
),
),
),
// Center Interactive Canvas
Expanded(
child: _buildCenterCanvasArea(
context,
state,
notifier,
renderDoc,
scale,
isDark,
currentSampleProduct,
),
),
// Right Properties Panel
SizedBox(
width: 290,
child: ElementPropertiesPanel(
document: state.document,
selectedElement: state.selectedElement,
onUpdateElement: notifier.updateElement,
onDeleteElement: notifier.deleteElement,
onDuplicateElement: notifier.duplicateElement,
onBringToFront: notifier.bringToFront,
onSendToBack: notifier.sendToBack,
onUpdateConfig: notifier.updateLabelConfig,
),
),
],
),
),
// Bottom Live Printer Command Preview
PrinterCommandPreview(
commands: state.liveCommands,
language: cfg.printerLanguage,
),
],
);
},
),
),
);
}
PreferredSizeWidget _buildTopAppBar(
BuildContext context,
BarcodeDesignerState state,
BarcodeDesignerNotifier notifier,
bool isDark,
) {
final screenWidth = MediaQuery.of(context).size.width;
final isMobile = screenWidth < 960;
return AppBar(
elevation: 1,
backgroundColor: isDark ? const Color(0xFF111827) : Colors.white,
leading: IconButton(
icon: const Icon(LucideIcons.arrowLeft),
onPressed: () => Navigator.pop(context),
),
titleSpacing: 0,
title: Row(
mainAxisSize: isMobile ? MainAxisSize.min : MainAxisSize.max,
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(LucideIcons.barcode, color: Colors.blue, size: 20),
),
const SizedBox(width: 10),
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: () => _showRenameDialog(context, state.document.name, notifier),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
state.document.name,
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 6),
Icon(LucideIcons.pencil, size: 12, color: Colors.grey.shade400),
],
),
),
Text(
'${state.document.config.widthMm.round()} × ${state.document.config.heightMm.round()} mm • ${state.document.config.dpi} DPI',
style: TextStyle(fontSize: 11, color: Colors.grey.shade500),
overflow: TextOverflow.ellipsis,
),
],
),
),
if (!isMobile) ...[
const SizedBox(width: 16),
// Design vs Preview Mode Switch
Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
decoration: BoxDecoration(
color: isDark ? Colors.white10 : Colors.grey.shade200,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildModeButton(
label: 'Design',
icon: LucideIcons.draftingCompass,
isSelected: !state.isPreviewMode,
onTap: () => notifier.togglePreviewMode(false),
isDark: isDark,
),
_buildModeButton(
label: 'Preview',
icon: LucideIcons.eye,
isSelected: state.isPreviewMode,
onTap: () => notifier.togglePreviewMode(true),
isDark: isDark,
),
],
),
),
const SizedBox(width: 12),
// Sample Product Selector
if (state.sampleProduct != null)
InputChip(
avatar: const Icon(LucideIcons.package, size: 14, color: Colors.blue),
label: Text(
state.sampleProduct!.name,
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
),
onDeleted: () => notifier.setSampleProduct(null),
onPressed: () => _pickSampleProduct(context, notifier),
)
else
ActionChip(
avatar: const Icon(LucideIcons.packagePlus, size: 14, color: Colors.blue),
label: const Text('Sample Product', style: TextStyle(fontSize: 11)),
onPressed: () => _pickSampleProduct(context, notifier),
),
],
],
),
actions: isMobile
? [
IconButton(
icon: const Icon(LucideIcons.printer, color: Colors.green, size: 20),
tooltip: 'Test Print',
onPressed: () {
TestPrintModal.show(
context,
document: state.document,
sampleProduct: state.sampleProduct,
sampleInventoryItem: state.sampleInventoryItem,
onSelectProduct: (p, item) => notifier.setSampleProduct(p, item),
onUpdateConfig: (cfg) => notifier.updateLabelConfig(cfg),
);
},
),
IconButton(
icon: _isSaving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(LucideIcons.save, size: 20),
tooltip: 'Save Template',
onPressed: _isSaving ? null : () => _saveTemplateToBackend(context, state, notifier),
),
PopupMenuButton<String>(
icon: const Icon(LucideIcons.ellipsisVertical, size: 18),
onSelected: (val) {
switch (val) {
case 'undo':
if (state.undoStack.isNotEmpty) notifier.undo();
break;
case 'redo':
if (state.redoStack.isNotEmpty) notifier.redo();
break;
case 'zoom_in':
notifier.setZoom(state.zoom + 0.3);
break;
case 'zoom_out':
notifier.setZoom(state.zoom - 0.3);
break;
case 'zoom_reset':
notifier.setZoom(1.0);
break;
case 'templates':
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const SavedTemplatesScreen()),
);
break;
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'undo',
enabled: state.undoStack.isNotEmpty,
child: const Row(
children: [
Icon(LucideIcons.undo, size: 16),
SizedBox(width: 8),
Text('Undo'),
],
),
),
PopupMenuItem(
value: 'redo',
enabled: state.redoStack.isNotEmpty,
child: const Row(
children: [
Icon(LucideIcons.redo, size: 16),
SizedBox(width: 8),
Text('Redo'),
],
),
),
const PopupMenuDivider(),
PopupMenuItem(
value: 'zoom_in',
child: Row(
children: [
const Icon(LucideIcons.zoomIn, size: 16),
const SizedBox(width: 8),
Text('Zoom In (${(state.zoom * 100).round()}%)'),
],
),
),
const PopupMenuItem(
value: 'zoom_out',
child: Row(
children: [
Icon(LucideIcons.zoomOut, size: 16),
SizedBox(width: 8),
Text('Zoom Out'),
],
),
),
const PopupMenuItem(
value: 'zoom_reset',
child: Row(
children: [
Icon(LucideIcons.maximize2, size: 16),
SizedBox(width: 8),
Text('Reset Zoom (100%)'),
],
),
),
const PopupMenuDivider(),
const PopupMenuItem(
value: 'templates',
child: Row(
children: [
Icon(LucideIcons.folderOpen, size: 16),
SizedBox(width: 8),
Text('Saved Templates'),
],
),
),
],
),
const SizedBox(width: 4),
]
: [
// Desktop actions
IconButton(
icon: const Icon(LucideIcons.undo, size: 18),
tooltip: 'Undo',
onPressed: state.undoStack.isNotEmpty ? notifier.undo : null,
),
IconButton(
icon: const Icon(LucideIcons.redo, size: 18),
tooltip: 'Redo',
onPressed: state.redoStack.isNotEmpty ? notifier.redo : null,
),
const VerticalDivider(width: 20, indent: 12, endIndent: 12),
// Zoom Controls
IconButton(
icon: const Icon(LucideIcons.zoomOut, size: 18),
tooltip: 'Zoom Out',
onPressed: () => notifier.setZoom(state.zoom - 0.3),
),
Text(
'${(state.zoom * 100).round()}%',
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600),
),
IconButton(
icon: const Icon(LucideIcons.zoomIn, size: 18),
tooltip: 'Zoom In',
onPressed: () => notifier.setZoom(state.zoom + 0.3),
),
const VerticalDivider(width: 20, indent: 12, endIndent: 12),
// Test Print Button
OutlinedButton.icon(
style: OutlinedButton.styleFrom(
foregroundColor: Colors.green,
side: const BorderSide(color: Colors.green),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
icon: const Icon(LucideIcons.printer, size: 16),
label: const Text('Test Print', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12)),
onPressed: () {
TestPrintModal.show(
context,
document: state.document,
sampleProduct: state.sampleProduct,
sampleInventoryItem: state.sampleInventoryItem,
onSelectProduct: (p, item) => notifier.setSampleProduct(p, item),
onUpdateConfig: (cfg) => notifier.updateLabelConfig(cfg),
);
},
),
const SizedBox(width: 10),
// Save Template Button
ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
icon: _isSaving
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2),
)
: const Icon(LucideIcons.save, size: 16),
label: const Text('Save Template', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12)),
onPressed: _isSaving ? null : () => _saveTemplateToBackend(context, state, notifier),
),
const SizedBox(width: 8),
// Saved Templates Screen
IconButton(
icon: const Icon(LucideIcons.folderOpen, size: 18),
tooltip: 'Saved Templates',
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const SavedTemplatesScreen()),
);
},
),
const SizedBox(width: 8),
],
);
}
Widget _buildModeButton({
required String label,
required IconData icon,
required bool isSelected,
required VoidCallback onTap,
required bool isDark,
}) {
final isPreview = label == 'Preview';
Color? activeBg;
Color? activeFg;
if (isSelected) {
if (isPreview) {
activeBg = isDark ? const Color(0xFF065F46) : const Color(0xFF10B981);
activeFg = Colors.white;
} else {
activeBg = isDark ? const Color(0xFF2563EB) : Colors.white;
activeFg = isDark ? Colors.white : const Color(0xFF2563EB);
}
}
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: isSelected ? activeBg : Colors.transparent,
borderRadius: BorderRadius.circular(16),
boxShadow: isSelected && !isDark
? [const BoxShadow(color: Colors.black12, blurRadius: 4, offset: Offset(0, 1))]
: null,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 13,
color: isSelected ? activeFg : (isDark ? Colors.white60 : Colors.grey.shade600),
),
const SizedBox(width: 5),
Text(
label,
style: TextStyle(
fontSize: 11,
fontWeight: isSelected ? FontWeight.bold : FontWeight.w500,
color: isSelected ? activeFg : (isDark ? Colors.white60 : Colors.grey.shade600),
),
),
],
),
),
);
}
Widget _buildCenterCanvasArea(
BuildContext context,
BarcodeDesignerState state,
BarcodeDesignerNotifier notifier,
LabelDocument renderDoc,
double scale,
bool isDark,
Product? currentSampleProduct,
) {
final cfg = renderDoc.config;
final canvasWidthPx = cfg.widthMm * scale;
final canvasHeightPx = cfg.heightMm * scale;
return Container(
color: isDark ? const Color(0xFF090D16) : const Color(0xFFF1F5F9),
child: Column(
children: [
// Canvas Toolbar Controls
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF111827) : Colors.white,
border: Border(
bottom: BorderSide(
color: isDark ? Colors.white10 : Colors.grey.shade200,
),
),
),
child: Row(
children: [
FilterChip(
label: const Text('Grid', style: TextStyle(fontSize: 11)),
selected: state.showGrid,
onSelected: (_) => notifier.toggleGrid(),
avatar: const Icon(LucideIcons.grid, size: 14),
),
const SizedBox(width: 8),
FilterChip(
label: const Text('Snap to Grid', style: TextStyle(fontSize: 11)),
selected: state.snapToGrid,
onSelected: (_) => notifier.toggleSnap(),
avatar: const Icon(LucideIcons.magnet, size: 14),
),
if (MediaQuery.of(context).size.width >= 700) ...[
const Spacer(),
Text(
'Canvas: ${cfg.widthMm.round()} × ${cfg.heightMm.round()} mm'
'${cfg.columnsAcross > 1 ? "${cfg.columnsAcross}-Across" : ""}',
style: TextStyle(fontSize: 11, color: Colors.grey.shade500),
),
],
],
),
),
// Scrollable Canvas Area with Physical Rulers
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => _canvasFocusNode.requestFocus(),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: SingleChildScrollView(
scrollDirection: Axis.vertical,
child: Padding(
padding: const EdgeInsets.all(40.0),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Top Horizontal Ruler (only in Design Mode)
if (!state.isPreviewMode)
Padding(
padding: const EdgeInsets.only(left: 20.0),
child: CanvasRuler(
lengthMm: cfg.widthMm,
scale: scale,
isHorizontal: true,
thickness: 18,
),
),
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Left Vertical Ruler (only in Design Mode)
if (!state.isPreviewMode)
CanvasRuler(
lengthMm: cfg.heightMm,
scale: scale,
isHorizontal: false,
thickness: 20,
),
// Multi-Across Preview Mode or Interactive Single Canvas Box
if (state.isPreviewMode && cfg.columnsAcross > 1)
Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(cfg.columnsAcross, (colIndex) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: canvasWidthPx,
height: canvasHeightPx,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
boxShadow: const [
BoxShadow(
color: Colors.black26,
blurRadius: 10,
offset: Offset(0, 3),
),
],
),
child: Stack(
clipBehavior: Clip.none,
children: renderDoc.elements.map((elem) {
return _CanvasElementWidget(
key: ValueKey('${elem.id}_col_$colIndex'),
elem: elem,
scale: scale,
isSelected: false,
isPreviewMode: true,
sampleProduct: currentSampleProduct ?? state.sampleProduct,
sampleInventoryItem: state.sampleInventoryItem,
notifier: notifier,
onSelect: null,
onBringToFront: null,
onSendToBack: null,
onBringForward: null,
onSendBackward: null,
onDuplicate: null,
onDelete: null,
renderContent: _renderElementContent,
);
}).toList(),
),
),
if (colIndex < cfg.columnsAcross - 1)
Container(
width: cfg.horizontalGapMm * scale,
height: canvasHeightPx,
alignment: Alignment.center,
child: Container(
width: 1.5,
height: canvasHeightPx * 0.8,
color: Colors.grey.shade400,
),
),
],
);
}),
)
else
// The Physical Label Canvas Box
DragTarget<Object>(
onAcceptWithDetails: (details) {
final box = context.findRenderObject() as RenderBox?;
final localOffset = box != null ? box.globalToLocal(details.offset) : Offset.zero;
final dropXMm = (localOffset.dx / scale).clamp(0.0, cfg.widthMm - 10.0);
final dropYMm = (localOffset.dy / scale).clamp(0.0, cfg.heightMm - 5.0);
final data = details.data;
if (data is ElementType) {
_addNewElementAt(data, dropXMm, dropYMm, notifier, cfg);
} else if (data is DynamicFieldDefinition) {
notifier.addBoundFieldElement(
fieldDef: data,
dropXMm: dropXMm,
dropYMm: dropYMm,
);
_canvasFocusNode.requestFocus();
}
},
builder: (context, candidateData, rejectedData) {
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
notifier.selectElement(null);
_canvasFocusNode.requestFocus();
},
child: Container(
width: canvasWidthPx,
height: canvasHeightPx,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: candidateData.isNotEmpty
? Colors.blue
: Colors.grey.shade400,
width: candidateData.isNotEmpty ? 2.0 : 1.0,
),
boxShadow: const [
BoxShadow(
color: Colors.black26,
blurRadius: 12,
offset: Offset(0, 4),
),
],
),
child: Stack(
clipBehavior: Clip.none,
children: [
// Grid background
if (state.showGrid && !state.isPreviewMode)
_buildGridOverlay(cfg.widthMm, cfg.heightMm, scale),
// Render elements
...renderDoc.elements.map((elem) {
final isSelected = elem.id == state.selectedElementId;
return _CanvasElementWidget(
key: ValueKey(elem.id),
elem: elem,
scale: scale,
isSelected: isSelected,
isPreviewMode: state.isPreviewMode,
sampleProduct: currentSampleProduct ?? state.sampleProduct,
sampleInventoryItem: state.sampleInventoryItem,
notifier: notifier,
onSelect: () => _canvasFocusNode.requestFocus(),
onBringToFront: () => notifier.bringToFront(elem.id),
onSendToBack: () => notifier.sendToBack(elem.id),
onBringForward: () => notifier.bringForward(elem.id),
onSendBackward: () => notifier.sendBackward(elem.id),
onDuplicate: () => notifier.duplicateElement(elem.id),
onDelete: () => notifier.deleteElement(elem.id),
renderContent: _renderElementContent,
);
}),
],
),
),
);
},
),
],
),
],
),
),
),
),
),
),
),
],
),
);
}
Widget _buildGridOverlay(double widthMm, double heightMm, double scale) {
return CustomPaint(
size: Size(widthMm * scale, heightMm * scale),
painter: _GridPainter(scale: scale),
);
}
Alignment _toAlignment(TextAlign align) {
switch (align) {
case TextAlign.center:
return Alignment.center;
case TextAlign.right:
case TextAlign.end:
return Alignment.centerRight;
case TextAlign.left:
case TextAlign.start:
case TextAlign.justify:
return Alignment.centerLeft;
}
}
bc.Barcode _getBarcodeSymbology(BarcodeType type) {
switch (type) {
case BarcodeType.ean13:
return bc.Barcode.ean13();
case BarcodeType.code39:
return bc.Barcode.code39();
case BarcodeType.upca:
return bc.Barcode.upcA();
case BarcodeType.qrCode:
return bc.Barcode.qrCode();
case BarcodeType.code128:
return bc.Barcode.code128();
}
}
String _sanitizeBarcodeData(BarcodeType type, String rawData) {
var data = rawData.trim();
if (data.isEmpty) data = '12345678';
switch (type) {
case BarcodeType.ean13:
final digits = data.replaceAll(RegExp(r'[^0-9]'), '');
if (digits.length >= 12) return digits.substring(0, 12);
return digits.padLeft(12, '0');
case BarcodeType.upca:
final digits = data.replaceAll(RegExp(r'[^0-9]'), '');
if (digits.length >= 11) return digits.substring(0, 11);
return digits.padLeft(11, '0');
case BarcodeType.code39:
final cleaned = data.toUpperCase().replaceAll(RegExp(r'[^0-9A-Z \-\.\$\/\+\%]'), '');
return cleaned.isEmpty ? 'CODE39' : cleaned;
case BarcodeType.code128:
case BarcodeType.qrCode:
return data;
}
}
Widget _renderElementContent(
LabelElement elem,
bool isPreviewMode,
double scale,
Product? sampleProduct,
InventoryItem? sampleInventoryItem,
) {
if (elem is TextElement) {
return Container(
alignment: _toAlignment(elem.alignment),
padding: EdgeInsets.all(elem.paddingMm * scale * 0.3),
child: Text(
elem.text,
textAlign: elem.alignment,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: (elem.fontSizePt * scale * 0.32).clamp(7.0, 48.0),
fontWeight: elem.isBold ? FontWeight.bold : FontWeight.normal,
fontStyle: elem.isItalic ? FontStyle.italic : FontStyle.normal,
color: Colors.black,
fontFamily: elem.fontFamily,
height: 1.1,
),
),
);
} else if (elem is DynamicTextElement) {
final rawVal = TemplateDataResolver.resolveFieldValue(
source: elem.source,
fieldKey: elem.fieldKey,
product: sampleProduct,
inventoryItem: sampleInventoryItem,
);
final isShowingRealData = rawVal != null;
final displayText = isShowingRealData
? elem.formatValue(rawVal)
: (elem.fieldKey.isNotEmpty
? '${elem.prefix ?? ''}{{${elem.source}.${elem.fieldKey}}}${elem.suffix ?? ''}'
: '{{select_field}}');
return Container(
alignment: _toAlignment(elem.alignment),
padding: EdgeInsets.all(elem.paddingMm * scale * 0.3),
child: Text(
displayText,
textAlign: elem.alignment,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: (elem.fontSizePt * scale * 0.32).clamp(7.0, 48.0),
fontWeight: elem.isBold ? FontWeight.bold : FontWeight.normal,
fontStyle: elem.isItalic ? FontStyle.italic : FontStyle.normal,
color: isPreviewMode
? Colors.black
: (isShowingRealData ? Colors.purple.shade900 : Colors.purple.shade700),
fontFamily: isShowingRealData ? elem.fontFamily : 'monospace',
height: 1.1,
),
),
);
} else if (elem is BarcodeElement) {
String barcodeValue = elem.staticValue;
if (elem.valueType == 'dynamic') {
final raw = TemplateDataResolver.resolveFieldValue(
source: elem.source,
fieldKey: elem.fieldKey,
product: sampleProduct,
inventoryItem: sampleInventoryItem,
);
if (raw != null && raw.isNotEmpty) {
barcodeValue = raw;
} else {
barcodeValue = elem.fieldKey.isNotEmpty ? '12345678' : elem.staticValue;
}
}
final sanitized = _sanitizeBarcodeData(elem.barcodeType, barcodeValue);
final barcodeSym = _getBarcodeSymbology(elem.barcodeType);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 2.0, vertical: 1.0),
child: bc.BarcodeWidget(
barcode: barcodeSym,
data: sanitized,
drawText: elem.showText,
style: TextStyle(
fontSize: (scale * 1.8).clamp(6.0, 11.0),
fontFamily: 'monospace',
fontWeight: FontWeight.bold,
color: Colors.black,
),
color: Colors.black,
errorBuilder: (context, error) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(LucideIcons.triangleAlert, size: 12, color: Colors.amber),
Text(
'Invalid for ${elem.barcodeType.name.toUpperCase()}',
style: const TextStyle(fontSize: 8, color: Colors.red),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
),
);
} else if (elem is QrCodeElement) {
String qrText = elem.staticValue;
if (elem.valueType == 'dynamic') {
final raw = TemplateDataResolver.resolveFieldValue(
source: elem.source,
fieldKey: elem.fieldKey,
product: sampleProduct,
inventoryItem: sampleInventoryItem,
);
if (raw != null && raw.isNotEmpty) {
qrText = raw;
} else {
qrText = elem.fieldKey.isNotEmpty ? 'https://kifi.app' : elem.staticValue;
}
}
return Padding(
padding: const EdgeInsets.all(2.0),
child: bc.BarcodeWidget(
barcode: bc.Barcode.qrCode(),
data: qrText.isEmpty ? 'https://kifi.app' : qrText,
drawText: false,
color: Colors.black,
errorBuilder: (context, error) => const Center(
child: Icon(LucideIcons.qrCode, color: Colors.black),
),
),
);
} else if (elem is RectangleElement) {
return Container(
decoration: BoxDecoration(
color: elem.isFilled ? Colors.black : Colors.transparent,
border: Border.all(color: Colors.black, width: 1.5),
borderRadius: BorderRadius.circular(elem.cornerRadiusMm * scale),
),
);
} else if (elem is LineElement) {
return Center(
child: Container(
color: Colors.black,
width: elem.isVertical ? 2.0 : double.infinity,
height: elem.isVertical ? double.infinity : 2.0,
),
);
}
return const SizedBox();
}
Widget _buildMobileLayout(
BuildContext context,
BarcodeDesignerState state,
BarcodeDesignerNotifier notifier,
LabelDocument renderDoc,
double scale,
bool isDark,
Product? currentSampleProduct,
) {
return Column(
children: [
// Mobile Sub-bar: Mode Switch & Sample Product Picker
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF111827) : Colors.white,
border: Border(
bottom: BorderSide(
color: isDark ? Colors.white10 : Colors.grey.shade200,
),
),
),
child: Row(
children: [
// Design vs Preview Mode Switch
Container(
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 2),
decoration: BoxDecoration(
color: isDark ? Colors.white10 : Colors.grey.shade200,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildModeButton(
label: 'Design',
icon: LucideIcons.draftingCompass,
isSelected: !state.isPreviewMode,
onTap: () => notifier.togglePreviewMode(false),
isDark: isDark,
),
_buildModeButton(
label: 'Preview',
icon: LucideIcons.eye,
isSelected: state.isPreviewMode,
onTap: () => notifier.togglePreviewMode(true),
isDark: isDark,
),
],
),
),
const SizedBox(width: 8),
// Sample Product Selector
Expanded(
child: (currentSampleProduct ?? state.sampleProduct) != null
? InputChip(
avatar: const Icon(LucideIcons.package, size: 14, color: Colors.blue),
label: Text(
(currentSampleProduct ?? state.sampleProduct)!.name,
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
),
onDeleted: () => notifier.setSampleProduct(null),
onPressed: () => _pickSampleProduct(context, notifier),
)
: ActionChip(
avatar: const Icon(LucideIcons.packagePlus, size: 14, color: Colors.blue),
label: const Text('Sample Product', style: TextStyle(fontSize: 11), overflow: TextOverflow.ellipsis),
onPressed: () => _pickSampleProduct(context, notifier),
),
),
],
),
),
Expanded(
child: _buildCenterCanvasArea(context, state, notifier, renderDoc, scale, isDark, currentSampleProduct),
),
// Bottom Tool/Properties Panel (Collapsible on Mobile)
AnimatedContainer(
duration: const Duration(milliseconds: 200),
height: _isMobileBottomPanelExpanded ? 260 : 44,
decoration: BoxDecoration(
color: isDark ? const Color(0xFF111827) : Colors.white,
border: Border(
top: BorderSide(
color: isDark ? Colors.white10 : Colors.grey.shade200,
),
),
),
child: DefaultTabController(
length: 3,
initialIndex: state.selectedElement != null ? 2 : 0,
child: Column(
children: [
Row(
children: [
Expanded(
child: TabBar(
labelColor: Theme.of(context).colorScheme.primary,
unselectedLabelColor: Colors.grey,
indicatorColor: Theme.of(context).colorScheme.primary,
labelPadding: const EdgeInsets.symmetric(horizontal: 4),
tabs: [
const Tab(
icon: Icon(LucideIcons.wrench, size: 15),
text: 'Tools',
),
const Tab(
icon: Icon(LucideIcons.variable, size: 15),
text: 'Variables',
),
Tab(
icon: Badge(
isLabelVisible: state.selectedElement != null,
smallSize: 8,
child: const Icon(LucideIcons.slidersHorizontal, size: 15),
),
text: state.selectedElement != null ? 'Properties ●' : 'Properties',
),
],
),
),
IconButton(
icon: Icon(
_isMobileBottomPanelExpanded ? LucideIcons.chevronDown : LucideIcons.chevronUp,
size: 18,
color: Colors.grey.shade500,
),
tooltip: _isMobileBottomPanelExpanded ? 'Collapse panel' : 'Expand panel',
onPressed: () {
setState(() => _isMobileBottomPanelExpanded = !_isMobileBottomPanelExpanded);
},
),
],
),
if (_isMobileBottomPanelExpanded)
Expanded(
child: TabBarView(
children: [
DesignerToolbox(onAddElement: (type) => _addNewElement(type, notifier, renderDoc.config)),
ProductVariablesPanel(onAddField: (f) => notifier.addBoundFieldElement(fieldDef: f, dropXMm: 4, dropYMm: 6)),
ElementPropertiesPanel(
document: state.document,
selectedElement: state.selectedElement,
onUpdateElement: notifier.updateElement,
onDeleteElement: notifier.deleteElement,
onDuplicateElement: notifier.duplicateElement,
onBringToFront: notifier.bringToFront,
onSendToBack: notifier.sendToBack,
onUpdateConfig: notifier.updateLabelConfig,
),
],
),
),
],
),
),
),
],
);
}
void _addNewElement(ElementType type, BarcodeDesignerNotifier notifier, LabelConfiguration cfg) {
_addNewElementAt(type, 4.0, 6.0, notifier, cfg);
}
void _addNewElementAt(
ElementType type,
double xMm,
double yMm,
BarcodeDesignerNotifier notifier,
LabelConfiguration cfg,
) {
_canvasFocusNode.requestFocus();
final id = 'elem_${DateTime.now().millisecondsSinceEpoch}';
switch (type) {
case ElementType.text:
notifier.addElement(
TextElement(
id: id,
xMm: xMm,
yMm: yMm,
widthMm: 24.0,
heightMm: 4.5,
text: 'kifi',
fontSizePt: 9.0,
isBold: true,
),
);
break;
case ElementType.dynamicText:
notifier.addElement(
DynamicTextElement(
id: id,
xMm: xMm,
yMm: yMm,
widthMm: 32.0,
heightMm: 4.5,
source: 'product',
fieldKey: 'name',
fontSizePt: 8.5,
),
);
break;
case ElementType.barcode:
notifier.addElement(
BarcodeElement(
id: id,
xMm: xMm,
yMm: yMm,
widthMm: 38.0,
heightMm: 8.5,
valueType: 'dynamic',
source: 'product',
fieldKey: 'barcode',
showText: true,
),
);
break;
case ElementType.qrCode:
notifier.addElement(
QrCodeElement(
id: id,
xMm: xMm,
yMm: yMm,
widthMm: 12.0,
heightMm: 12.0,
valueType: 'dynamic',
source: 'product',
fieldKey: 'barcode',
),
);
break;
case ElementType.rectangle:
notifier.addElement(
RectangleElement(
id: id,
xMm: xMm,
yMm: yMm,
widthMm: 24.0,
heightMm: 12.0,
),
);
break;
case ElementType.line:
notifier.addElement(
LineElement(
id: id,
xMm: xMm,
yMm: yMm,
widthMm: cfg.widthMm - 4.0,
heightMm: 1.0,
),
);
break;
}
}
void _pickSampleProduct(BuildContext context, BarcodeDesignerNotifier notifier) async {
ref.read(productsProvider.notifier).refresh();
final result = await ProductPickerDialog.show(context);
if (result != null) {
notifier.setSampleProduct(result.product, result.inventoryItem);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Selected sample: ${result.product.name}'),
duration: const Duration(seconds: 2),
),
);
}
}
void _showRenameDialog(BuildContext context, String currentName, BarcodeDesignerNotifier notifier) {
final ctrl = TextEditingController(text: currentName);
showDialog(
context: context,
builder: (_) => AlertDialog(
title: const Text('Template Name'),
content: TextField(
controller: ctrl,
autofocus: true,
decoration: const InputDecoration(labelText: 'Name'),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
ElevatedButton(
onPressed: () {
if (ctrl.text.trim().isNotEmpty) {
notifier.updateTemplateName(ctrl.text.trim());
}
Navigator.pop(context);
},
child: const Text('Save'),
),
],
),
);
}
Future<void> _saveTemplateToBackend(
BuildContext context,
BarcodeDesignerState state,
BarcodeDesignerNotifier notifier,
) async {
setState(() => _isSaving = true);
try {
final template = BarcodeTemplate.fromLabelDocument(
id: state.savedTemplateId,
doc: state.document,
);
final saved = await ref.read(barcodeTemplatesProvider.notifier).saveTemplate(template);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Template "${state.document.name}" saved successfully!'),
backgroundColor: Colors.green,
),
);
if (saved != null && saved.id != null) {
notifier.loadDocument(state.document, saved.id);
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error saving template: $e'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) setState(() => _isSaving = false);
}
}
}
class _GridPainter extends CustomPainter {
final double scale;
_GridPainter({required this.scale});
@override
void paint(Canvas canvas, Size size) {
final paint1mm = Paint()
..color = Colors.black.withValues(alpha: 0.04)
..strokeWidth = 0.5;
final paint5mm = Paint()
..color = Colors.black.withValues(alpha: 0.1)
..strokeWidth = 1.0;
for (double x = 0; x <= size.width; x += scale) {
final is5 = (x / scale).round() % 5 == 0;
canvas.drawLine(Offset(x, 0), Offset(x, size.height), is5 ? paint5mm : paint1mm);
}
for (double y = 0; y <= size.height; y += scale) {
final is5 = (y / scale).round() % 5 == 0;
canvas.drawLine(Offset(0, y), Offset(size.width, y), is5 ? paint5mm : paint1mm);
}
}
@override
bool shouldRepaint(covariant _GridPainter oldDelegate) => oldDelegate.scale != scale;
}
class _CanvasElementWidget extends StatefulWidget {
final LabelElement elem;
final double scale;
final bool isSelected;
final bool isPreviewMode;
final Product? sampleProduct;
final InventoryItem? sampleInventoryItem;
final BarcodeDesignerNotifier notifier;
final VoidCallback? onSelect;
final VoidCallback? onBringToFront;
final VoidCallback? onSendToBack;
final VoidCallback? onBringForward;
final VoidCallback? onSendBackward;
final VoidCallback? onDuplicate;
final VoidCallback? onDelete;
final Widget Function(
LabelElement elem,
bool isPreviewMode,
double scale,
Product? sampleProduct,
InventoryItem? sampleInventoryItem,
) renderContent;
const _CanvasElementWidget({
super.key,
required this.elem,
required this.scale,
required this.isSelected,
required this.isPreviewMode,
this.sampleProduct,
this.sampleInventoryItem,
required this.notifier,
this.onSelect,
this.onBringToFront,
this.onSendToBack,
this.onBringForward,
this.onSendBackward,
this.onDuplicate,
this.onDelete,
required this.renderContent,
});
@override
State<_CanvasElementWidget> createState() => _CanvasElementWidgetState();
}
class _CanvasElementWidgetState extends State<_CanvasElementWidget> {
double _dragStartX = 0.0;
double _dragStartY = 0.0;
double _accumDx = 0.0;
double _accumDy = 0.0;
double _resizeStartW = 0.0;
double _resizeStartH = 0.0;
double _accumResizeW = 0.0;
double _accumResizeH = 0.0;
void _showContextMenu(BuildContext context, Offset globalPos) {
final isDark = Theme.of(context).brightness == Brightness.dark;
showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(
globalPos.dx,
globalPos.dy,
globalPos.dx + 1,
globalPos.dy + 1,
),
color: isDark ? const Color(0xFF1E293B) : Colors.white,
elevation: 8,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(color: isDark ? Colors.white12 : Colors.grey.shade200),
),
items: [
PopupMenuItem(
value: 'front',
height: 34,
child: Row(
children: [
Icon(LucideIcons.arrowUpToLine, size: 15, color: isDark ? Colors.white70 : Colors.black87),
const SizedBox(width: 8),
const Expanded(child: Text('Bring to Front', style: TextStyle(fontSize: 12))),
Text(']', style: TextStyle(fontSize: 11, color: isDark ? Colors.white38 : Colors.black38)),
],
),
),
PopupMenuItem(
value: 'forward',
height: 34,
child: Row(
children: [
Icon(LucideIcons.arrowUp, size: 15, color: isDark ? Colors.white70 : Colors.black87),
const SizedBox(width: 8),
const Expanded(child: Text('Bring Forward', style: TextStyle(fontSize: 12))),
],
),
),
PopupMenuItem(
value: 'backward',
height: 34,
child: Row(
children: [
Icon(LucideIcons.arrowDown, size: 15, color: isDark ? Colors.white70 : Colors.black87),
const SizedBox(width: 8),
const Expanded(child: Text('Send Backward', style: TextStyle(fontSize: 12))),
],
),
),
PopupMenuItem(
value: 'back',
height: 34,
child: Row(
children: [
Icon(LucideIcons.arrowDownToLine, size: 15, color: isDark ? Colors.white70 : Colors.black87),
const SizedBox(width: 8),
const Expanded(child: Text('Send to Back', style: TextStyle(fontSize: 12))),
Text('[', style: TextStyle(fontSize: 11, color: isDark ? Colors.white38 : Colors.black38)),
],
),
),
const PopupMenuDivider(height: 8),
PopupMenuItem(
value: 'duplicate',
height: 34,
child: Row(
children: [
Icon(LucideIcons.copy, size: 15, color: isDark ? Colors.white70 : Colors.black87),
const SizedBox(width: 8),
const Expanded(child: Text('Duplicate', style: TextStyle(fontSize: 12))),
Text('Ctrl+D', style: TextStyle(fontSize: 11, color: isDark ? Colors.white38 : Colors.black38)),
],
),
),
PopupMenuItem(
value: 'delete',
height: 34,
child: const Row(
children: [
Icon(LucideIcons.trash2, size: 15, color: Colors.redAccent),
SizedBox(width: 8),
Expanded(child: Text('Delete', style: TextStyle(fontSize: 12, color: Colors.redAccent))),
Text('Del', style: TextStyle(fontSize: 11, color: Colors.redAccent)),
],
),
),
],
).then((value) {
if (value == null) return;
widget.onSelect?.call();
switch (value) {
case 'front':
widget.onBringToFront?.call();
break;
case 'forward':
widget.onBringForward?.call();
break;
case 'backward':
widget.onSendBackward?.call();
break;
case 'back':
widget.onSendToBack?.call();
break;
case 'duplicate':
widget.onDuplicate?.call();
break;
case 'delete':
widget.onDelete?.call();
break;
}
});
}
Widget _buildQuickActionBtn({
required IconData icon,
required String tooltip,
required VoidCallback? onTap,
required bool isDark,
Color? color,
}) {
return Tooltip(
message: tooltip,
waitDuration: const Duration(milliseconds: 300),
child: InkWell(
borderRadius: BorderRadius.circular(4),
onTap: () {
widget.onSelect?.call();
onTap?.call();
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 4),
child: Icon(
icon,
size: 13,
color: color ?? (isDark ? Colors.white70 : Colors.black87),
),
),
),
);
}
@override
Widget build(BuildContext context) {
final elem = widget.elem;
final scale = widget.scale;
final isSelected = widget.isSelected && !widget.isPreviewMode;
final notifier = widget.notifier;
final isDark = Theme.of(context).brightness == Brightness.dark;
final left = elem.xMm * scale;
final top = elem.yMm * scale;
final width = elem.widthMm * scale;
final height = elem.heightMm * scale;
return Positioned(
left: left,
top: top,
width: width,
height: height,
child: DragTarget<DynamicFieldDefinition>(
onAcceptWithDetails: widget.isPreviewMode
? null
: (details) {
notifier.bindFieldToElement(elem.id, details.data.source, details.data.fieldKey);
},
builder: (context, candidateFields, rejectedData) {
return MouseRegion(
cursor: widget.isPreviewMode ? SystemMouseCursors.basic : SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.isPreviewMode
? null
: () {
notifier.selectElement(elem.id);
widget.onSelect?.call();
},
onSecondaryTapDown: widget.isPreviewMode
? null
: (details) {
notifier.selectElement(elem.id);
widget.onSelect?.call();
_showContextMenu(context, details.globalPosition);
},
onPanStart: widget.isPreviewMode
? null
: (details) {
notifier.selectElement(elem.id);
widget.onSelect?.call();
notifier.recordUndo();
_dragStartX = elem.xMm;
_dragStartY = elem.yMm;
_accumDx = 0.0;
_accumDy = 0.0;
},
onPanUpdate: widget.isPreviewMode
? null
: (details) {
_accumDx += details.delta.dx / scale;
_accumDy += details.delta.dy / scale;
notifier.setElementPosition(
elem.id,
_dragStartX + _accumDx,
_dragStartY + _accumDy,
);
},
child: Stack(
clipBehavior: Clip.none,
children: [
// Visual Content Container
Container(
width: width,
height: height,
decoration: BoxDecoration(
color: (candidateFields.isNotEmpty && !widget.isPreviewMode)
? Colors.purple.withValues(alpha: 0.15)
: (isSelected
? Colors.blue.withValues(alpha: 0.04)
: Colors.transparent),
border: widget.isPreviewMode
? (elem.borderWidthMm > 0
? Border.all(
color: Colors.black,
width: (elem.borderWidthMm * scale * 0.4).clamp(1.0, 4.0),
)
: null)
: Border.all(
color: isSelected
? Colors.blue
: (candidateFields.isNotEmpty
? Colors.purple
: (elem.borderWidthMm > 0
? Colors.black
: Colors.blue.withValues(alpha: 0.25))),
width: isSelected
? 1.5
: (elem.borderWidthMm > 0
? (elem.borderWidthMm * scale * 0.4).clamp(1.0, 4.0)
: 0.8),
),
),
child: widget.renderContent(
elem,
widget.isPreviewMode,
scale,
widget.sampleProduct,
widget.sampleInventoryItem,
),
),
// Floating Quick Action Toolbar in Preview
if (isSelected)
Positioned(
top: (top > 34) ? -32 : height + 4,
left: 0,
child: Material(
elevation: 6,
borderRadius: BorderRadius.circular(6),
color: isDark ? const Color(0xFF1E293B) : Colors.white,
shadowColor: Colors.black38,
child: Container(
height: 26,
padding: const EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: isDark ? Colors.white12 : Colors.grey.shade300,
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildQuickActionBtn(
icon: LucideIcons.arrowUpToLine,
tooltip: 'Bring to Front (])',
onTap: widget.onBringToFront,
isDark: isDark,
),
_buildQuickActionBtn(
icon: LucideIcons.arrowDownToLine,
tooltip: 'Send to Back ([)',
onTap: widget.onSendToBack,
isDark: isDark,
),
Container(
width: 1,
height: 12,
margin: const EdgeInsets.symmetric(horizontal: 2),
color: isDark ? Colors.white24 : Colors.grey.shade300,
),
_buildQuickActionBtn(
icon: LucideIcons.copy,
tooltip: 'Duplicate (Ctrl+D)',
onTap: widget.onDuplicate,
isDark: isDark,
),
_buildQuickActionBtn(
icon: LucideIcons.trash2,
tooltip: 'Delete (Del)',
color: Colors.redAccent,
onTap: widget.onDelete,
isDark: isDark,
),
],
),
),
),
),
// Selection Box and Corner Resize Handle
if (isSelected) ...[
Positioned(
right: -5,
bottom: -5,
child: MouseRegion(
cursor: SystemMouseCursors.resizeDownRight,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onPanStart: (details) {
widget.onSelect?.call();
notifier.recordUndo();
_resizeStartW = elem.widthMm;
_resizeStartH = elem.heightMm;
_accumResizeW = 0.0;
_accumResizeH = 0.0;
},
onPanUpdate: (details) {
_accumResizeW += details.delta.dx / scale;
_accumResizeH += details.delta.dy / scale;
notifier.setElementSize(
elem.id,
_resizeStartW + _accumResizeW,
_resizeStartH + _accumResizeH,
);
},
child: Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: Colors.blue,
border: Border.all(color: Colors.white, width: 2),
borderRadius: BorderRadius.circular(3),
boxShadow: const [
BoxShadow(
color: Colors.black26,
blurRadius: 3,
offset: Offset(0, 1),
),
],
),
),
),
),
),
],
],
),
),
);
},
),
);
}
}