1970 lines
76 KiB
Dart
1970 lines
76 KiB
Dart
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),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
}
|