creek
The AI Image Editor of 2030git clone https://github.com/maydayv7/creek
lib/ui/pages/canvas_page.dart (62124B)
1import 'dart:io';
2import 'dart:convert';
3import 'dart:async';
4import 'dart:math' as math;
5import 'dart:ui' as ui;
6import 'dart:typed_data';
7import 'package:flutter/material.dart';
8import 'package:flutter/foundation.dart';
9import 'package:flutter/rendering.dart';
10import 'package:path_provider/path_provider.dart';
11import 'package:flutter_svg/flutter_svg.dart';
12import 'package:image_picker/image_picker.dart';
13import 'package:undo/undo.dart';
14import 'package:share_plus/share_plus.dart';
15import 'package:image/image.dart' as img;
16
17import 'package:creekui/data/repos/project_repo.dart';
18import 'package:creekui/data/models/file_model.dart';
19import 'package:creekui/services/stylesheet_service.dart';
20import 'package:creekui/services/file_service.dart';
21import 'package:creekui/services/flask_service.dart';
22import 'package:creekui/ui/styles/variables.dart';
23import 'package:creekui/data/models/canvas_models.dart';
24import 'package:creekui/ui/painters/canvas_painter.dart';
25import 'package:creekui/utils/image_utils.dart';
26
27import 'package:creekui/ui/widgets/canvas/manipulating_box.dart';
28import 'package:creekui/ui/widgets/canvas/canvas_bottom_bar.dart';
29import 'package:creekui/ui/widgets/canvas/asset_picker_sheet.dart';
30import 'package:creekui/ui/widgets/canvas/layers_panel.dart';
31import 'package:creekui/ui/widgets/app_bar.dart';
32import 'package:creekui/ui/widgets/dialog.dart';
33import 'package:creekui/ui/widgets/text_field.dart';
34import './canvas_toolbar/magic_draw_overlay.dart';
35import './canvas_toolbar/text_tools_overlay.dart';
36import 'project_file_page.dart';
37
38class CanvasPage extends StatefulWidget {
39 final int projectId;
40 final double width;
41 final double height;
42 final File? initialImage;
43 final FileModel? existingFile;
44 final File? injectedMedia;
45
46 const CanvasPage({
47 super.key,
48 required this.projectId,
49 required this.width,
50 required this.height,
51 this.initialImage,
52 this.existingFile,
53 this.injectedMedia,
54 });
55
56 @override
57 State<CanvasPage> createState() => _CanvasPageState();
58}
59
60class _CanvasPageState extends State<CanvasPage> {
61 final FileService _fileService = FileService();
62 final ChangeStack _changeStack = ChangeStack();
63 final ImagePicker _picker = ImagePicker();
64
65 // Stack for magic draw actions
66 final ChangeStack _magicDrawChangeStack = ChangeStack();
67
68 bool _hasUnsavedChanges = false;
69 bool _canPop = false;
70 String? _currentFileId;
71
72 // Layer state
73 // Bottom-most layer is at index 0
74 List<CanvasLayer> _layers = [];
75 String? _activeLayerId;
76 bool _showLayersPanel = false;
77
78 // Magic Draw & Flashing
79 String? _magicDrawLayerId;
80 String? _flashingLayerId;
81 Timer? _flashTimer;
82
83 // Snapshots for undo grouping
84 CanvasState? _gestureStartSnapshot;
85
86 // AI & Inpainting State
87 Timer? _inactivityTimer;
88 DateTime _lastAnalysisTime = DateTime.now();
89 final GlobalKey _canvasGlobalKey = GlobalKey();
90 String? _aiDescription;
91 bool _isAnalyzing = false;
92 bool _isDescriptionExpanded = false;
93 File? _tempBaseImage;
94 bool _isInpainting = false;
95 bool _isCapturingBase = false; // Hide strokes during capture
96
97 // Background Removal Banner State
98 bool _showBgRemovalBanner = false;
99 String? _bgRemovalTargetId;
100 String? _bgRemovalTargetPath;
101 Timer? _bgRemovalBannerTimer;
102 bool _isRemovingBg = false;
103
104 late Size _canvasSize;
105 List<Color> _brandColors = [];
106
107 // Tools
108 bool _isMagicDrawActive = false;
109 bool _isTextToolsActive = false;
110 bool _isMagicPanelDisabled = false;
111 bool _isViewMode = false;
112
113 // Text Editing
114 bool _isEditingText = false;
115 final TextEditingController _textEditingController = TextEditingController();
116 final FocusNode _textFocusNode = FocusNode();
117
118 // Drawing
119 List<DrawingPoint> _currentPoints = [];
120 Color _selectedColor = Variables.defaultBrush;
121 double _strokeWidth = 10.0;
122 bool _isEraser = false;
123 final GlobalKey _drawingKey = GlobalKey();
124
125 // Viewport
126 final TransformationController _transformationController =
127 TransformationController();
128 bool _hasInitializedView = false;
129
130 @override
131 void initState() {
132 super.initState();
133 _canvasSize = Size(widget.width, widget.height);
134 _currentFileId = widget.existingFile?.id;
135
136 _fetchBrandColors();
137
138 if (widget.existingFile != null) {
139 _loadCanvasFromFile();
140 } else {
141 _initializeNewCanvas();
142 }
143 }
144
145 void _initializeNewCanvas() {
146 // Add initial image layer if present
147 if (widget.initialImage != null) {
148 final double imageWidth = _canvasSize.width * 0.4;
149 final double imageHeight = _canvasSize.height * 0.4;
150 final Offset centeredPosition = Offset(
151 (_canvasSize.width - imageWidth) / 2,
152 (_canvasSize.height - imageHeight) / 2,
153 );
154
155 final bgId = 'bg_${DateTime.now().millisecondsSinceEpoch}';
156 _layers.add(
157 ImageLayer(
158 id: bgId,
159 data: {
160 'id': bgId,
161 'type': 'file_image',
162 'content': widget.initialImage!.path,
163 'position': centeredPosition,
164 'size': Size(imageWidth, imageHeight),
165 'rotation': 0.0,
166 },
167 ),
168 );
169 _activeLayerId = bgId;
170 }
171
172 if (widget.injectedMedia != null) {
173 _addAssetsToCanvas([widget.injectedMedia!.path]);
174 }
175 _hasUnsavedChanges = true;
176 }
177
178 @override
179 void dispose() {
180 _inactivityTimer?.cancel();
181 _bgRemovalBannerTimer?.cancel();
182 _flashTimer?.cancel();
183 _textEditingController.dispose();
184 _textFocusNode.dispose();
185 _transformationController.dispose();
186 super.dispose();
187 }
188
189 // Handle inactivity timer logic
190 void _resetInactivityTimer() {
191 _inactivityTimer?.cancel();
192 // Only analyze if changes happened
193 _inactivityTimer = Timer(const Duration(seconds: 3), () {
194 if (_hasUnsavedChanges) _analyzeCanvas();
195 });
196 }
197
198 // LAYER MANAGEMENT
199 void _handleLayerReorder(int oldUiIndex, int newUiIndex) {
200 setState(() {
201 // The UI shows layers Top -> Bottom (reversed order of stack)
202 List<CanvasLayer> uiList = _layers.reversed.toList();
203
204 if (oldUiIndex < newUiIndex) {
205 newUiIndex -= 1;
206 }
207 final CanvasLayer item = uiList.removeAt(oldUiIndex);
208 uiList.insert(newUiIndex, item);
209
210 // Restore stack order (Bottom -> Top)
211 _layers = uiList.reversed.toList();
212 _hasUnsavedChanges = true;
213 });
214 }
215
216 void _handleLayerDelete(String id) {
217 final oldState = _getCurrentState();
218 setState(() {
219 _layers.removeWhere((l) => l.id == id);
220
221 if (_activeLayerId == id) {
222 // If active layer is deleted, select the new top-most layer
223 _activeLayerId = _layers.isNotEmpty ? _layers.last.id : null;
224 }
225
226 _hasUnsavedChanges = true;
227 });
228 _recordChange(oldState);
229 }
230
231 void _handleLayerVisibility(String id) {
232 final index = _layers.indexWhere((l) => l.id == id);
233 if (index != -1) {
234 setState(() {
235 _layers[index].isVisible = !_layers[index].isVisible;
236 });
237 }
238 }
239
240 void _setActiveLayer(String id) {
241 setState(() {
242 _activeLayerId = id;
243 _flashLayer(id); // Visual feedback
244
245 final layer = _layers.firstWhere((l) => l.id == id);
246
247 // Auto-enable text tools if selecting a text layer
248 if (layer is ImageLayer && layer.data['type'] == 'text') {
249 _isTextToolsActive = true;
250 } else {
251 _isTextToolsActive = false;
252 _exitEditMode();
253 }
254 });
255 }
256
257 void _flashLayer(String id) {
258 _flashTimer?.cancel();
259 setState(() => _flashingLayerId = id);
260 _flashTimer = Timer(const Duration(milliseconds: 600), () {
261 if (mounted) setState(() => _flashingLayerId = null);
262 });
263 }
264
265 // DRAWING LOGIC
266 void _startMagicDraw() {
267 // Capture the base canvas (everything currently visible)
268 _ensureBaseImageCaptured().then((_) {
269 setState(() {
270 _isMagicDrawActive = true;
271 _isTextToolsActive = false;
272 _exitEditMode();
273
274 // Add an initial sketch layer
275 _addNewMagicLayer();
276 _magicDrawChangeStack.clear();
277 });
278 });
279 }
280
281 void _addNewMagicLayer() {
282 final id = 'magic_${DateTime.now().millisecondsSinceEpoch}';
283 final magicLayer = SketchLayer(id: id, isMagicDraw: true, paths: []);
284 setState(() {
285 _layers.add(magicLayer);
286 _activeLayerId = id;
287 _magicDrawLayerId = id;
288 });
289 }
290
291 Future<void> _handleMagicDrawExit() async {
292 // Check if there is any content in any magic draw layer
293 bool hasDrawing = _layers.any(
294 (l) => l is SketchLayer && l.isMagicDraw && l.paths.isNotEmpty,
295 );
296
297 if (hasDrawing && !_isInpainting) {
298 final confirm = await _confirmDiscardMagicDraw();
299 if (confirm) {
300 _cleanupMagicDraw();
301 }
302 } else {
303 _cleanupMagicDraw();
304 }
305 }
306
307 Future<bool> _confirmDiscardMagicDraw() async {
308 return await ShowDialog.show<bool>(
309 context,
310 title: "Discard Magic Draw?",
311 description:
312 "Leaving Magic Draw will remove your sketches. Continue?",
313 primaryButtonText: "Discard",
314 isDestructive: true,
315 onPrimaryPressed: () => Navigator.pop(context, true),
316 secondaryButtonText: "Stay",
317 onSecondaryPressed: () => Navigator.pop(context, false),
318 ) ??
319 false;
320 }
321
322 void _cleanupMagicDraw() {
323 setState(() {
324 // Remove all magic draw layers
325 _layers.removeWhere((l) => l is SketchLayer && l.isMagicDraw);
326 _isMagicDrawActive = false;
327 _activeLayerId = _layers.isNotEmpty ? _layers.last.id : null;
328 _magicDrawChangeStack.clear();
329 _tempBaseImage = null;
330 });
331 }
332
333 void _onPanUpdate(DragUpdateDetails details) {
334 if (!_isMagicDrawActive) return;
335
336 setState(() {
337 _currentPoints.add(
338 DrawingPoint(offset: details.localPosition, paint: Paint()),
339 );
340 });
341
342 // Throttle AI analysis
343 if (DateTime.now().difference(_lastAnalysisTime).inMilliseconds > 2500) {
344 _lastAnalysisTime = DateTime.now();
345 _analyzeCanvas();
346 }
347 }
348
349 Future<void> _onPanEnd(DragEndDetails details) async {
350 if (!_isMagicDrawActive) return;
351
352 setState(() {
353 if (_currentPoints.isNotEmpty) {
354 CanvasLayer? targetLayer;
355
356 // 1. Try active layer
357 if (_activeLayerId != null) {
358 final index = _layers.indexWhere((l) => l.id == _activeLayerId);
359 if (index != -1 &&
360 _layers[index] is SketchLayer &&
361 (_layers[index] as SketchLayer).isMagicDraw) {
362 targetLayer = _layers[index];
363 }
364 }
365
366 // 2. If not valid, find any magic layer or create one
367 if (targetLayer == null) {
368 try {
369 targetLayer = _layers.lastWhere(
370 (l) => l is SketchLayer && (l as SketchLayer).isMagicDraw,
371 );
372 _activeLayerId = targetLayer.id;
373 } catch (_) {
374 _addNewMagicLayer();
375 targetLayer = _layers.last;
376 }
377 }
378
379 if (targetLayer is SketchLayer) {
380 final oldPaths = List<DrawingPath>.from(targetLayer.paths);
381 targetLayer.paths.add(
382 DrawingPath(
383 points: List.from(_currentPoints),
384 color: _selectedColor,
385 strokeWidth: _strokeWidth,
386 isEraser: _isEraser,
387 ),
388 );
389 _recordMagicChange(oldPaths, targetLayer.id);
390 }
391 _currentPoints = [];
392 }
393 });
394 }
395
396 // GENERATION & INPAINTING
397 Future<void> _processInpainting(String prompt, String modelId) async {
398 FocusScope.of(context).unfocus();
399 setState(() => _isInpainting = true);
400 _resetInactivityTimer();
401
402 try {
403 // Check if there are image layers (which dictates the context)
404 bool hasImageLayers = _layers.any((l) => l is ImageLayer);
405
406 if (_tempBaseImage == null) {
407 _tempBaseImage = await _captureCanvasToFile();
408 }
409 if (_tempBaseImage == null) return;
410
411 // Consolidate paths from all active magic layers for mask
412 List<DrawingPath> combinedPaths = [];
413 for (var layer in _layers) {
414 if (layer is SketchLayer && layer.isMagicDraw) {
415 combinedPaths.addAll(layer.paths);
416 }
417 }
418
419 File? maskFile = await _generateMaskImageFromPaths(
420 combinedPaths,
421 _canvasSize,
422 _tempBaseImage,
423 );
424 if (maskFile == null) throw Exception("Failed to generate mask");
425
426 String? newImageUrl;
427
428 // Case 1: Inpainting
429 if (hasImageLayers) {
430 // API Inpainting
431 if (modelId == 'inpaint_api') {
432 newImageUrl = await FlaskService().inpaintApiImage(
433 imagePath: _tempBaseImage!.path,
434 maskPath: maskFile.path,
435 prompt: prompt,
436 );
437 } else {
438 // Local inpainting
439 newImageUrl = await FlaskService().inpaintImage(
440 imagePath: _tempBaseImage!.path,
441 maskPath: maskFile.path,
442 prompt: prompt,
443 );
444 }
445 } else
446 // Case 2: Sketch to Image
447 {
448 if (modelId == 'sketch_fusion') {
449 newImageUrl = await FlaskService().sketchToImage(
450 projectId: widget.projectId,
451 sketchPath: _tempBaseImage!.path,
452 userPrompt: prompt,
453 imageDescription: _aiDescription,
454 );
455 } else {
456 newImageUrl = await FlaskService().sketchToImage(
457 projectId: widget.projectId,
458 sketchPath: _tempBaseImage!.path,
459 userPrompt: prompt,
460 imageDescription: _aiDescription,
461 );
462 }
463 }
464
465 if (newImageUrl != null) {
466 _addGeneratedImage(newImageUrl);
467 if (!hasImageLayers && _layers.isNotEmpty) {
468 _triggerBgRemovalBanner(_layers.last.id, newImageUrl);
469 }
470 }
471 } catch (e) {
472 debugPrint("Gen Error: $e");
473 ScaffoldMessenger.of(
474 context,
475 ).showSnackBar(const SnackBar(content: Text("Generation failed")));
476 } finally {
477 setState(() {
478 _isInpainting = false;
479 _layers.removeWhere((l) => l is SketchLayer && l.isMagicDraw);
480 _isMagicDrawActive = false;
481 _tempBaseImage = null;
482 // Select newly generated image
483 if (_layers.isNotEmpty) _activeLayerId = _layers.last.id;
484 });
485 }
486 }
487
488 void _addGeneratedImage(String newImageUrl) {
489 final id = 'gen_${DateTime.now().millisecondsSinceEpoch}';
490 setState(() {
491 _layers.add(
492 ImageLayer(
493 id: id,
494 data: {
495 'id': id,
496 'type': 'file_image',
497 'content': newImageUrl,
498 'position': const Offset(0, 0),
499 'size': _canvasSize,
500 'rotation': 0.0,
501 },
502 ),
503 );
504 });
505 }
506
507 // UI BUILDER
508 @override
509 Widget build(BuildContext context) {
510 Map<String, dynamic>? selectedEl;
511 if (_activeLayerId != null) {
512 try {
513 final layer = _layers.firstWhere((l) => l.id == _activeLayerId);
514 if (layer is ImageLayer) selectedEl = layer.data;
515 } catch (_) {}
516 }
517
518 final bool isTextSelected =
519 selectedEl != null && selectedEl['type'] == 'text';
520 final bool showTextOverlay = _isTextToolsActive || isTextSelected;
521
522 return PopScope(
523 canPop: _canPop,
524 onPopInvoked: (didPop) {
525 if (didPop) return;
526 _handleBackNavigation();
527 },
528 child: Scaffold(
529 backgroundColor: Variables.canvasBackground,
530 appBar: _buildAppBar(),
531 body: LayoutBuilder(
532 builder: (context, constraints) {
533 if (!_hasInitializedView) {
534 _hasInitializedView = true;
535 final double scaleX =
536 (constraints.maxWidth - 40) / _canvasSize.width;
537 final double scaleY =
538 (constraints.maxHeight - 40) / _canvasSize.height;
539 final double initialScale = math
540 .min(scaleX, scaleY)
541 .clamp(0.01, 1.0);
542 final double transX =
543 (constraints.maxWidth - (_canvasSize.width * initialScale)) /
544 2;
545 final double transY =
546 (constraints.maxHeight -
547 (_canvasSize.height * initialScale)) /
548 2;
549
550 _transformationController.value =
551 Matrix4.identity()
552 ..translate(transX, transY)
553 ..scale(initialScale);
554 }
555
556 return Stack(
557 children: [
558 GestureDetector(
559 onTap: () {
560 // Tapping background clears selection (unless inside magic draw)
561 if (!_isMagicDrawActive || _isMagicPanelDisabled) {
562 _exitEditMode();
563 setState(() => _activeLayerId = null);
564 }
565 },
566 behavior: HitTestBehavior.translucent,
567 child: InteractiveViewer(
568 transformationController: _transformationController,
569 constrained: false,
570 boundaryMargin: const EdgeInsets.all(double.infinity),
571 minScale: 0.01,
572 maxScale: 10.0,
573 scaleEnabled: !_isMagicDrawActive || _isMagicPanelDisabled,
574 panEnabled: !_isMagicDrawActive || _isMagicPanelDisabled,
575 child: RepaintBoundary(
576 key: _canvasGlobalKey,
577 child: SizedBox(
578 width: _canvasSize.width,
579 height: _canvasSize.height,
580 child: Stack(
581 children: [
582 Container(
583 width: double.infinity,
584 height: double.infinity,
585 decoration: BoxDecoration(
586 color: Variables.background,
587 boxShadow: [
588 BoxShadow(
589 color: Colors.black.withOpacity(0.15),
590 blurRadius: 40,
591 offset: const Offset(0, 10),
592 ),
593 ],
594 ),
595 ),
596
597 // Render Layers
598 ..._layers.map((layer) {
599 if (!layer.isVisible)
600 return const SizedBox.shrink();
601
602 bool isFlashing = layer.id == _flashingLayerId;
603
604 if (layer is ImageLayer) {
605 final e = layer.data;
606 final bool isSelected =
607 _activeLayerId == layer.id;
608
609 return ManipulatingBox(
610 key: ValueKey(layer.id),
611 id: e['id'],
612 position: e['position'],
613 size: e['size'],
614 rotation: e['rotation'],
615 type: e['type'],
616 content: e['content'],
617 styleData: e,
618 isSelected: isSelected && !_isMagicDrawActive,
619 isEditing: isSelected && _isEditingText,
620 isFlashing: isFlashing,
621 viewScale:
622 _transformationController.value
623 .getMaxScaleOnAxis(),
624 transformationController:
625 _transformationController,
626 onTap: () {
627 if (!_isMagicDrawActive)
628 _setActiveLayer(layer.id);
629 },
630 onDoubleTap: () {
631 if (e['type'] == 'text') _enterEditMode(e);
632 },
633 onDragStart: _handleGestureStart,
634 onUpdate:
635 (newPos, newSize, newRot) =>
636 _handleElementUpdate(
637 layer.id,
638 newPos,
639 newSize,
640 newRot,
641 ),
642 onDragEnd: (newPos, newSize, newRot) {
643 _handleElementUpdate(
644 layer.id,
645 newPos,
646 newSize,
647 newRot,
648 );
649 _handleGestureEnd();
650 },
651 textController:
652 isSelected
653 ? _textEditingController
654 : null,
655 focusNode: isSelected ? _textFocusNode : null,
656 );
657 } else if (layer is SketchLayer) {
658 return IgnorePointer(
659 ignoring: true,
660 child: RepaintBoundary(
661 child: CustomPaint(
662 size: Size.infinite,
663 painter: CanvasPainter(
664 paths: layer.paths,
665 currentPoints: [],
666 currentColor: Colors.transparent,
667 currentWidth: 0,
668 isEraser: false,
669 overrideColor:
670 isFlashing
671 ? Colors.greenAccent
672 : null,
673 ),
674 ),
675 ),
676 );
677 }
678 return const SizedBox.shrink();
679 }).toList(),
680
681 // Active Drawing Surface
682 IgnorePointer(
683 ignoring:
684 !(_isMagicDrawActive &&
685 !_isMagicPanelDisabled),
686 child: GestureDetector(
687 onPanStart: (d) {},
688 onPanUpdate: _onPanUpdate,
689 onPanEnd: _onPanEnd,
690 child: CustomPaint(
691 size: Size.infinite,
692 painter: CanvasPainter(
693 paths: [],
694 currentPoints:
695 _isCapturingBase ? [] : _currentPoints,
696 currentColor:
697 _isEraser
698 ? Colors.transparent
699 : _selectedColor,
700 currentWidth: _strokeWidth,
701 isEraser: _isEraser,
702 ),
703 ),
704 ),
705 ),
706 ],
707 ),
708 ),
709 ),
710 ),
711 ),
712
713 // AI Description Overlay
714 if (_aiDescription != null && _isMagicDrawActive)
715 Positioned(
716 top: 10,
717 left: 16,
718 right: 16,
719 child: GestureDetector(
720 onTap: () {
721 setState(() {
722 _isDescriptionExpanded = !_isDescriptionExpanded;
723 });
724 },
725 child: AnimatedContainer(
726 duration: const Duration(milliseconds: 200),
727 padding: const EdgeInsets.symmetric(
728 horizontal: 16,
729 vertical: 10,
730 ),
731 decoration: BoxDecoration(
732 color: Colors.white.withOpacity(0.95),
733 borderRadius: BorderRadius.circular(12),
734 boxShadow: [
735 BoxShadow(
736 color: Colors.black.withOpacity(0.1),
737 blurRadius: 10,
738 offset: const Offset(0, 4),
739 ),
740 ],
741 ),
742 child: Row(
743 crossAxisAlignment: CrossAxisAlignment.start,
744 children: [
745 Padding(
746 padding: const EdgeInsets.only(top: 2.0),
747 child: Icon(
748 Icons.auto_awesome,
749 size: 20,
750 color: Colors.indigo.shade400,
751 ),
752 ),
753 const SizedBox(width: 12),
754 Expanded(
755 child: Text(
756 _aiDescription!,
757 style: const TextStyle(
758 fontSize: 14,
759 fontWeight: FontWeight.w500,
760 color: Colors.black87,
761 ),
762 maxLines: _isDescriptionExpanded ? null : 1,
763 overflow:
764 _isDescriptionExpanded
765 ? TextOverflow.visible
766 : TextOverflow.ellipsis,
767 ),
768 ),
769 const SizedBox(width: 8),
770 Icon(
771 _isDescriptionExpanded
772 ? Icons.keyboard_arrow_up
773 : Icons.keyboard_arrow_down,
774 size: 20,
775 color: Colors.grey[600],
776 ),
777 ],
778 ),
779 ),
780 ),
781 ),
782
783 MagicDrawTools(
784 isActive: _isMagicDrawActive,
785 selectedColor: _selectedColor,
786 strokeWidth: _strokeWidth,
787 isEraser: _isEraser,
788 brandColors: _brandColors,
789 onClose: _handleMagicDrawExit,
790 onColorChanged: (c) => setState(() => _selectedColor = c),
791 onWidthChanged: (w) => setState(() => _strokeWidth = w),
792 onEraserToggle: (e) => setState(() => _isEraser = e),
793 onPromptSubmit: _processInpainting,
794 isProcessing: _isInpainting,
795 onMagicPanelActivityToggle:
796 (d) => setState(() => _isMagicPanelDisabled = d),
797 isMagicPanelDisabled: _isMagicPanelDisabled,
798 onViewModeToggle: (e) => setState(() => _isViewMode = e),
799 hasImageLayers: _layers.any((l) => l is ImageLayer),
800 ),
801
802 TextToolsOverlay(
803 isActive: showTextOverlay && !_isMagicDrawActive,
804 isTextSelected: isTextSelected,
805 currentColor: Color(
806 (selectedEl?['style_color'] ?? Colors.black.value),
807 ),
808 currentFontSize:
809 (selectedEl?['style_fontSize'] ?? 24.0) as double,
810 onClose: () {
811 _exitEditMode();
812 setState(() => _activeLayerId = null);
813 },
814 onAddText: _addTextElement,
815 onColorChanged:
816 (c) =>
817 _updateSelectedTextProperty('style_color', c.value),
818 onFontSizeChanged:
819 (s) => _updateSelectedTextProperty('style_fontSize', s),
820 ),
821
822 // Background Removal Banner
823 if (_showBgRemovalBanner)
824 Positioned(
825 top: SafeArea(child: Container()).minimum.top + 10,
826 left: 0,
827 right: 0,
828 child: Center(
829 child: Container(
830 padding: const EdgeInsets.symmetric(
831 horizontal: 16,
832 vertical: 8,
833 ),
834 decoration: BoxDecoration(
835 color: Variables.surfaceDark,
836 borderRadius: BorderRadius.circular(30),
837 ),
838 child: Row(
839 mainAxisSize: MainAxisSize.min,
840 children: [
841 if (_isRemovingBg) ...[
842 const SizedBox(
843 width: 16,
844 height: 16,
845 child: CircularProgressIndicator(
846 strokeWidth: 2,
847 color: Colors.white,
848 ),
849 ),
850 const SizedBox(width: 8),
851 const Text(
852 "Removing...",
853 style: TextStyle(
854 color: Colors.white,
855 fontSize: 14,
856 ),
857 ),
858 ] else ...[
859 const Text(
860 "Remove background?",
861 style: TextStyle(
862 color: Colors.white,
863 fontSize: 14,
864 ),
865 ),
866 const SizedBox(width: 8),
867 InkWell(
868 onTap: _confirmRemoveBackground,
869 child: const Icon(
870 Icons.check,
871 color: Colors.white,
872 ),
873 ),
874 const SizedBox(width: 8),
875 InkWell(
876 onTap:
877 () => setState(
878 () => _showBgRemovalBanner = false,
879 ),
880 child: const Icon(
881 Icons.close,
882 color: Colors.white54,
883 ),
884 ),
885 ],
886 ],
887 ),
888 ),
889 ),
890 ),
891
892 // Layers Panel
893 if (!(_isMagicDrawActive && _isDescriptionExpanded))
894 Positioned(
895 right: 16,
896 // Prevent overlap with description overlay
897 top:
898 SafeArea(child: Container()).minimum.top +
899 ((_isMagicDrawActive && _aiDescription != null)
900 ? 80
901 : 10),
902 child: LayersPanel(
903 layers: _layers,
904 activeLayerId: _activeLayerId,
905 isOpen: _showLayersPanel,
906 onToggle:
907 () => setState(
908 () => _showLayersPanel = !_showLayersPanel,
909 ),
910 onReorder: _handleLayerReorder,
911 onDelete: _handleLayerDelete,
912 onToggleVisibility: _handleLayerVisibility,
913 onLayerTap: _setActiveLayer,
914 onAddLayer: _isMagicDrawActive ? _addNewMagicLayer : null,
915 ),
916 ),
917
918 Positioned(
919 bottom: 0,
920 left: 0,
921 right: 0,
922 child: CanvasBottomBar(
923 activeItem:
924 _isMagicDrawActive
925 ? "Magic Draw"
926 : (_isTextToolsActive ? "Text" : null),
927 onMagicDraw: () async {
928 if (_isMagicDrawActive) {
929 _handleMagicDrawExit();
930 } else {
931 _startMagicDraw();
932 }
933 },
934 onMedia: () async {
935 if (_isMagicDrawActive) {
936 if (!await _confirmDiscardMagicDraw()) return;
937 _cleanupMagicDraw();
938 }
939 _pickImageFromGallery();
940 },
941 onStylesheet: () async {
942 if (_isMagicDrawActive) {
943 if (!await _confirmDiscardMagicDraw()) return;
944 _cleanupMagicDraw();
945 }
946 _openStylesheet();
947 },
948 onTools: () async {
949 if (_isMagicDrawActive) {
950 if (!await _confirmDiscardMagicDraw()) return;
951 _cleanupMagicDraw();
952 }
953 _showComingSoon('Tools');
954 },
955 onText: () async {
956 if (_isMagicDrawActive) {
957 if (!await _confirmDiscardMagicDraw()) return;
958 _cleanupMagicDraw();
959 }
960 _toggleTextTools();
961 },
962 onSelect: () async {
963 if (_isMagicDrawActive) {
964 if (!await _confirmDiscardMagicDraw()) return;
965 _cleanupMagicDraw();
966 }
967 _showComingSoon('Select');
968 },
969 onPlugins: () async {
970 if (_isMagicDrawActive) {
971 if (!await _confirmDiscardMagicDraw()) return;
972 _cleanupMagicDraw();
973 }
974 _showComingSoon('Plugins');
975 },
976 ),
977 ),
978 ],
979 );
980 },
981 ),
982 ),
983 );
984 }
985
986 // Helpers
987 Future<void> _ensureBaseImageCaptured() async {
988 if (_tempBaseImage != null) return;
989 setState(() => _isCapturingBase = true);
990 await Future.delayed(const Duration(milliseconds: 50));
991 try {
992 _tempBaseImage = await _captureCanvasToFile();
993 } catch (e) {
994 debugPrint("Failed to capture base: $e");
995 } finally {
996 if (mounted) setState(() => _isCapturingBase = false);
997 }
998 }
999
1000 Future<File?> _captureCanvasToFile() async {
1001 try {
1002 RenderRepaintBoundary? boundary =
1003 _canvasGlobalKey.currentContext?.findRenderObject()
1004 as RenderRepaintBoundary?;
1005 if (boundary == null) return null;
1006 ui.Image image = await boundary.toImage(pixelRatio: 1.0);
1007 ByteData? byteData = await image.toByteData(
1008 format: ui.ImageByteFormat.png,
1009 );
1010 if (byteData == null) return null;
1011 final tempDir = await getTemporaryDirectory();
1012 final tempFile = File(
1013 '${tempDir.path}/canvas_capture_${DateTime.now().millisecondsSinceEpoch}.png',
1014 );
1015 await tempFile.writeAsBytes(byteData.buffer.asUint8List());
1016 return tempFile;
1017 } catch (e) {
1018 return null;
1019 }
1020 }
1021
1022 Future<void> _analyzeCanvas() async {
1023 if (_isAnalyzing || _isInpainting) return;
1024 setState(() => _isAnalyzing = true);
1025 try {
1026 File? imageFile = await _captureCanvasToFile();
1027 if (imageFile == null) return;
1028 final description = await FlaskService().describeImage(
1029 imagePath: imageFile.path,
1030 );
1031 if (description != null && mounted) {
1032 setState(() {
1033 _aiDescription = description;
1034 _isDescriptionExpanded = false;
1035 });
1036 }
1037 } catch (e) {
1038 debugPrint("AI Error: $e");
1039 } finally {
1040 if (mounted) setState(() => _isAnalyzing = false);
1041 }
1042 }
1043
1044 Future<void> _fetchBrandColors() async {
1045 try {
1046 final int? pId = widget.projectId;
1047 if (pId == null) return;
1048 final project = await ProjectRepo().getProjectById(pId);
1049 if (project != null && project.globalStylesheet != null) {
1050 final styleData = StylesheetService().parse(project.globalStylesheet);
1051 if (mounted && styleData.colors.isNotEmpty)
1052 setState(() => _brandColors = styleData.colors);
1053 }
1054 } catch (e) {
1055 debugPrint("Error loading brand colors: $e");
1056 }
1057 }
1058
1059 void _recordMagicChange(List<DrawingPath> oldPaths, String layerId) {
1060 final newPaths = List<DrawingPath>.from(
1061 (_layers.firstWhere((l) => l.id == layerId) as SketchLayer).paths,
1062 );
1063 _magicDrawChangeStack.add(
1064 Change(
1065 oldPaths,
1066 () {
1067 // Redo
1068 final index = _layers.indexWhere((l) => l.id == layerId);
1069 if (index != -1) {
1070 setState(
1071 () =>
1072 _layers[index] = SketchLayer(
1073 id: _layers[index].id,
1074 isMagicDraw: true,
1075 paths: newPaths,
1076 isVisible: _layers[index].isVisible,
1077 ),
1078 );
1079 }
1080 },
1081 (val) {
1082 // Undo
1083 final index = _layers.indexWhere((l) => l.id == layerId);
1084 if (index != -1) {
1085 setState(
1086 () =>
1087 _layers[index] = SketchLayer(
1088 id: _layers[index].id,
1089 isMagicDraw: true,
1090 paths: List.from(val),
1091 isVisible: _layers[index].isVisible,
1092 ),
1093 );
1094 }
1095 },
1096 ),
1097 );
1098 }
1099
1100 void _recordChange(CanvasState oldState) {
1101 _resetInactivityTimer();
1102 final newState = CanvasState(_deepCopyLayers(_layers));
1103 _changeStack.add(
1104 Change(
1105 oldState,
1106 () => setState(() {
1107 _layers = _deepCopyLayers(newState.layers);
1108 _hasUnsavedChanges = true;
1109 }),
1110 (val) => setState(() {
1111 _layers = _deepCopyLayers(val.layers);
1112 _hasUnsavedChanges = true;
1113 }),
1114 ),
1115 );
1116 setState(() => _hasUnsavedChanges = true);
1117 }
1118
1119 CanvasState _getCurrentState() => CanvasState(_deepCopyLayers(_layers));
1120
1121 List<CanvasLayer> _deepCopyLayers(List<CanvasLayer> layers) {
1122 return layers.map((l) {
1123 if (l is SketchLayer) {
1124 return SketchLayer(
1125 id: l.id,
1126 paths: List<DrawingPath>.from(l.paths),
1127 isMagicDraw: l.isMagicDraw,
1128 isVisible: l.isVisible,
1129 );
1130 } else if (l is ImageLayer) {
1131 return ImageLayer(
1132 id: l.id,
1133 data: Map<String, dynamic>.from(l.data),
1134 isVisible: l.isVisible,
1135 );
1136 }
1137 return l;
1138 }).toList();
1139 }
1140
1141 void _toggleTextTools() {
1142 setState(() {
1143 _isTextToolsActive = !_isTextToolsActive;
1144 _isMagicDrawActive = false;
1145 if (!_isTextToolsActive) {
1146 _exitEditMode();
1147 _activeLayerId = null;
1148 }
1149 });
1150 }
1151
1152 void _addTextElement() {
1153 final oldState = _getCurrentState();
1154 final id = 'text_${DateTime.now().millisecondsSinceEpoch}';
1155 final initialPos = Offset(
1156 _canvasSize.width / 2 - 150,
1157 _canvasSize.height / 2 - 50,
1158 );
1159 final newElementData = {
1160 'id': id,
1161 'type': 'text',
1162 'content': 'Double tap to edit',
1163 'position': initialPos,
1164 'size': Size(300, 60),
1165 'rotation': 0.0,
1166 'style_color': Colors.black.value,
1167 'style_fontSize': 30.0,
1168 };
1169 setState(() {
1170 _layers.add(ImageLayer(id: id, data: newElementData));
1171 _activeLayerId = id;
1172 _isTextToolsActive = true;
1173 });
1174 _recordChange(oldState);
1175 _enterEditMode(newElementData);
1176 }
1177
1178 void _enterEditMode(Map<String, dynamic> element) {
1179 setState(() {
1180 _activeLayerId = element['id'];
1181 _isEditingText = true;
1182 _textEditingController.text = element['content'];
1183 _textEditingController.selection = TextSelection.fromPosition(
1184 TextPosition(offset: _textEditingController.text.length),
1185 );
1186 });
1187 WidgetsBinding.instance.addPostFrameCallback((_) {
1188 if (_textFocusNode.canRequestFocus) _textFocusNode.requestFocus();
1189 });
1190 }
1191
1192 void _exitEditMode() {
1193 if (_isEditingText && _activeLayerId != null) {
1194 final index = _layers.indexWhere((l) => l.id == _activeLayerId);
1195 if (index != -1 && _layers[index] is ImageLayer) {
1196 final layer = _layers[index] as ImageLayer;
1197 if (layer.data['content'] != _textEditingController.text) {
1198 final oldState = _getCurrentState();
1199 setState(() {
1200 layer.data['content'] = _textEditingController.text;
1201 _isEditingText = false;
1202 });
1203 _recordChange(oldState);
1204 } else {
1205 setState(() => _isEditingText = false);
1206 }
1207 } else {
1208 setState(() => _isEditingText = false);
1209 }
1210 _textFocusNode.unfocus();
1211 }
1212 }
1213
1214 void _updateSelectedTextProperty(String key, dynamic value) {
1215 if (_activeLayerId == null) return;
1216 final index = _layers.indexWhere((l) => l.id == _activeLayerId);
1217 if (index != -1 && _layers[index] is ImageLayer) {
1218 final oldState = _getCurrentState();
1219 setState(() => (_layers[index] as ImageLayer).data[key] = value);
1220 _recordChange(oldState);
1221 }
1222 }
1223
1224 void _deleteSelectedElement() {
1225 if (_activeLayerId != null) {
1226 _handleLayerDelete(_activeLayerId!);
1227 setState(() {
1228 _isEditingText = false;
1229 _isTextToolsActive = false;
1230 });
1231 _textFocusNode.unfocus();
1232 }
1233 }
1234
1235 void _handleElementUpdate(
1236 String id,
1237 Offset newPos,
1238 Size newSize,
1239 double newRot,
1240 ) {
1241 final index = _layers.indexWhere((l) => l.id == id);
1242 if (index != -1 && _layers[index] is ImageLayer) {
1243 setState(() {
1244 (_layers[index] as ImageLayer).data['position'] = newPos;
1245 (_layers[index] as ImageLayer).data['size'] = newSize;
1246 (_layers[index] as ImageLayer).data['rotation'] = newRot;
1247 _hasUnsavedChanges = true;
1248 });
1249 }
1250 }
1251
1252 void _handleGestureEnd() {
1253 if (_gestureStartSnapshot != null) {
1254 _recordChange(_gestureStartSnapshot!);
1255 _gestureStartSnapshot = null;
1256 }
1257 }
1258
1259 void _handleGestureStart() {
1260 if (_isMagicDrawActive) return;
1261 _gestureStartSnapshot = _getCurrentState();
1262 }
1263
1264 void _triggerBgRemovalBanner(String elementId, String imagePath) {
1265 _bgRemovalBannerTimer?.cancel();
1266 setState(() {
1267 _showBgRemovalBanner = true;
1268 _bgRemovalTargetId = elementId;
1269 _bgRemovalTargetPath = imagePath;
1270 });
1271
1272 // Automatically hide after 10 seconds
1273 _bgRemovalBannerTimer = Timer(const Duration(seconds: 10), () {
1274 if (mounted) setState(() => _showBgRemovalBanner = false);
1275 });
1276 }
1277
1278 Future<void> _confirmRemoveBackground() async {
1279 if (_bgRemovalTargetId == null || _bgRemovalTargetPath == null) return;
1280 _bgRemovalBannerTimer?.cancel();
1281 setState(() => _isRemovingBg = true);
1282 try {
1283 final newPath = await FlaskService().generateAsset(
1284 imagePath: _bgRemovalTargetPath!,
1285 );
1286 if (newPath != null && mounted) {
1287 final index = _layers.indexWhere((e) => e.id == _bgRemovalTargetId);
1288 if (index != -1 && _layers[index] is ImageLayer) {
1289 final oldState = _getCurrentState();
1290 setState(() {
1291 (_layers[index] as ImageLayer).data['content'] = newPath;
1292 _showBgRemovalBanner = false;
1293 _isRemovingBg = false;
1294 _hasUnsavedChanges = true;
1295 });
1296 _recordChange(oldState);
1297 }
1298 }
1299 } catch (e) {
1300 debugPrint("BG Removal Failed: $e");
1301 } finally {
1302 if (mounted)
1303 setState(() {
1304 _isRemovingBg = false;
1305 _showBgRemovalBanner = false;
1306 });
1307 }
1308 }
1309
1310 // Generate mask: Base Image + Drawing Strokes
1311 Future<File?> _generateMaskImageFromPaths(
1312 List<DrawingPath> paths,
1313 Size size,
1314 File? baseImageFile,
1315 ) async {
1316 try {
1317 final recorder = ui.PictureRecorder();
1318 final canvas = Canvas(
1319 recorder,
1320 Rect.fromLTWH(0, 0, size.width, size.height),
1321 );
1322
1323 // 1. Draw Base Image First (if available)
1324 if (baseImageFile != null) {
1325 final data = await baseImageFile.readAsBytes();
1326 final codec = await ui.instantiateImageCodec(data);
1327 final frameInfo = await codec.getNextFrame();
1328 paintImage(
1329 canvas: canvas,
1330 rect: Rect.fromLTWH(0, 0, size.width, size.height),
1331 image: frameInfo.image,
1332 fit: BoxFit.cover,
1333 );
1334 } else {
1335 // Fallback to black if no base image (shouldn't happen based on logic)
1336 canvas.drawRect(
1337 Rect.fromLTWH(0, 0, size.width, size.height),
1338 Paint()..color = Colors.black,
1339 );
1340 }
1341
1342 // 2. Draw Strokes on top of base image
1343 for (final path in paths) {
1344 final paint =
1345 Paint()
1346 ..color = path.color
1347 ..strokeWidth = path.strokeWidth
1348 ..strokeCap = StrokeCap.round
1349 ..strokeJoin = StrokeJoin.round
1350 ..style = PaintingStyle.stroke;
1351 if (path.points.length > 1) {
1352 final Path p = Path();
1353 p.moveTo(path.points.first.offset.dx, path.points.first.offset.dy);
1354 for (int i = 1; i < path.points.length; i++)
1355 p.lineTo(path.points[i].offset.dx, path.points[i].offset.dy);
1356 canvas.drawPath(p, paint);
1357 } else if (path.points.isNotEmpty) {
1358 canvas.drawPoints(ui.PointMode.points, [
1359 path.points.first.offset,
1360 ], paint);
1361 }
1362 }
1363 final picture = recorder.endRecording();
1364 final img = await picture.toImage(
1365 size.width.toInt(),
1366 size.height.toInt(),
1367 );
1368 final byteData = await img.toByteData(format: ui.ImageByteFormat.png);
1369 if (byteData == null) return null;
1370 final tempDir = await getTemporaryDirectory();
1371 final file = File(
1372 '${tempDir.path}/mask_${DateTime.now().millisecondsSinceEpoch}.png',
1373 );
1374 await file.writeAsBytes(byteData.buffer.asUint8List());
1375 return file;
1376 } catch (e) {
1377 return null;
1378 }
1379 }
1380
1381 void _showComingSoon(String feature) => ScaffoldMessenger.of(
1382 context,
1383 ).showSnackBar(SnackBar(content: Text('$feature Coming soon')));
1384 void _openSettings() => _showComingSoon('Settings');
1385
1386 void _openStylesheet() {
1387 showModalBottomSheet(
1388 context: context,
1389 isScrollControlled: true,
1390 backgroundColor: Colors.transparent,
1391 builder:
1392 (context) => DraggableScrollableSheet(
1393 initialChildSize: 0.7,
1394 minChildSize: 0.5,
1395 maxChildSize: 0.9,
1396 builder:
1397 (_, controller) => AssetPickerSheet(
1398 projectId: widget.projectId,
1399 scrollController: controller,
1400 onAddAssets: _addAssetsToCanvas,
1401 ),
1402 ),
1403 );
1404 }
1405
1406 void _addAssetsToCanvas(List<String> paths) {
1407 if (paths.isEmpty) return;
1408 final oldState = _getCurrentState();
1409 setState(() {
1410 for (var path in paths) {
1411 final double imageWidth = _canvasSize.width * 0.4;
1412 final double imageHeight = _canvasSize.height * 0.4;
1413 final Offset centeredPosition = Offset(
1414 (_canvasSize.width - imageWidth) / 2,
1415 (_canvasSize.height - imageHeight) / 2,
1416 );
1417 final id =
1418 'asset_${DateTime.now().millisecondsSinceEpoch}_${math.Random().nextInt(1000)}';
1419 _layers.add(
1420 ImageLayer(
1421 id: id,
1422 data: {
1423 'id': id,
1424 'type': 'file_image',
1425 'content': path,
1426 'position': centeredPosition,
1427 'size': Size(imageWidth, imageHeight),
1428 'rotation': 0.0,
1429 },
1430 ),
1431 );
1432 _activeLayerId = id;
1433 }
1434 _hasUnsavedChanges = true;
1435 });
1436 _recordChange(oldState);
1437 }
1438
1439 Future<void> _handleBackNavigation() async {
1440 if (_isMagicDrawActive) {
1441 _handleMagicDrawExit();
1442 return;
1443 }
1444 // If no changes, allow pop
1445 if (!_hasUnsavedChanges && _currentFileId != null) {
1446 setState(() => _canPop = true);
1447 WidgetsBinding.instance.addPostFrameCallback((_) {
1448 if (mounted) Navigator.pop(context);
1449 });
1450 return;
1451 }
1452 ShowDialog.show(
1453 context,
1454 title: "Save Changes?",
1455 description: "Do you want to save your canvas before leaving?",
1456 primaryButtonText: "Save",
1457 onPrimaryPressed: () async {
1458 Navigator.pop(context);
1459 await _saveCanvas();
1460 setState(() => _canPop = true);
1461 WidgetsBinding.instance.addPostFrameCallback((_) {
1462 if (mounted) Navigator.pop(context);
1463 });
1464 },
1465 secondaryButtonText: "Discard",
1466 onSecondaryPressed: () {
1467 Navigator.pop(context);
1468 setState(() => _canPop = true);
1469 WidgetsBinding.instance.addPostFrameCallback((_) {
1470 if (mounted) Navigator.pop(context);
1471 });
1472 },
1473 );
1474 }
1475
1476 Future<String?> _showNameDialog() async {
1477 final nameController = TextEditingController(text: "Untitled Canvas");
1478 return await showDialog<String>(
1479 context: context,
1480 builder:
1481 (ctx) => Dialog(
1482 backgroundColor: Colors.white,
1483 shape: RoundedRectangleBorder(
1484 borderRadius: BorderRadius.circular(Variables.radiusLarge),
1485 ),
1486 child: ShowDialog(
1487 title: "Save Canvas",
1488 content: CommonTextField(
1489 hintText: "Enter a name for your file",
1490 controller: nameController,
1491 autoFocus: true,
1492 ),
1493 primaryButtonText: "Save",
1494 onPrimaryPressed:
1495 () => Navigator.pop(ctx, nameController.text.trim()),
1496 secondaryButtonText: "Cancel",
1497 onSecondaryPressed: () => Navigator.pop(ctx),
1498 ),
1499 ),
1500 );
1501 }
1502
1503 Future<String?> _generatePreviewImage() async {
1504 try {
1505 final boundary =
1506 _canvasGlobalKey.currentContext?.findRenderObject()
1507 as RenderRepaintBoundary?;
1508 if (boundary == null) return null;
1509 final ui.Image image = await boundary.toImage(pixelRatio: 1.0);
1510 final ByteData? byteData = await image.toByteData(
1511 format: ui.ImageByteFormat.png,
1512 );
1513 if (byteData == null) return null;
1514 final directory = await getApplicationDocumentsDirectory();
1515 final previewDir = Directory('${directory.path}/previews');
1516 if (!await previewDir.exists()) await previewDir.create(recursive: true);
1517 final String filePath =
1518 '${previewDir.path}/preview_${DateTime.now().millisecondsSinceEpoch}.png';
1519 await File(filePath).writeAsBytes(byteData.buffer.asUint8List());
1520 return filePath;
1521 } catch (e) {
1522 return null;
1523 }
1524 }
1525
1526 Future<void> _saveCanvas() async {
1527 try {
1528 String fileName = "Canvas ${DateTime.now().toString().split(' ')[0]}";
1529 // If new file, ask for name
1530 if (_currentFileId == null) {
1531 final userFileName = await _showNameDialog();
1532 if (userFileName == null || userFileName.isEmpty) return;
1533 fileName = userFileName;
1534 }
1535 final String? previewPath = await _generatePreviewImage();
1536 final layersJson =
1537 _layers
1538 .where((l) => !(l is SketchLayer && l.isMagicDraw))
1539 .map((l) => l.toMap())
1540 .toList();
1541 final canvasData = {
1542 'version': 2,
1543 'layers': layersJson,
1544 'width': _canvasSize.width,
1545 'height': _canvasSize.height,
1546 'preview_path': previewPath,
1547 };
1548 // Pass current ID and capture returned ID
1549 final savedId = await _fileService.saveCanvasFile(
1550 projectId: widget.projectId,
1551 canvasData: canvasData,
1552 fileId: _currentFileId,
1553 fileName: _currentFileId == null ? fileName : null,
1554 );
1555 if (mounted) {
1556 setState(() {
1557 _currentFileId = savedId;
1558 _hasUnsavedChanges = false;
1559 });
1560 ScaffoldMessenger.of(context).showSnackBar(
1561 const SnackBar(content: Text("Canvas Saved Successfully")),
1562 );
1563 }
1564 } catch (e) {
1565 debugPrint("Save Error: $e");
1566 if (mounted) {
1567 ScaffoldMessenger.of(context).showSnackBar(
1568 SnackBar(
1569 content: Text("Failed to save: $e"),
1570 backgroundColor: Colors.red,
1571 ),
1572 );
1573 }
1574 }
1575 }
1576
1577 Future<void> _loadCanvasFromFile() async {
1578 try {
1579 final meta = await _fileService.getFileMetadata(
1580 widget.existingFile!.filePath,
1581 );
1582 if (meta.width > 0 && meta.height > 0)
1583 setState(() {
1584 _canvasSize = Size(meta.width, meta.height);
1585 _hasInitializedView = false;
1586 });
1587 final file = File(widget.existingFile!.filePath);
1588 if (await file.exists()) {
1589 final jsonString = await file.readAsString();
1590 final dynamic decoded = jsonDecode(jsonString);
1591 setState(() {
1592 _layers.clear();
1593 if (decoded is Map && decoded.containsKey('layers')) {
1594 for (var item in decoded['layers'])
1595 _layers.add(CanvasLayer.fromMap(item));
1596 } else if (decoded is Map && decoded.containsKey('elements')) {
1597 for (var e in _jsonToElements(decoded['elements']))
1598 _layers.add(ImageLayer(id: e['id'], data: e));
1599 if (decoded['paths'] != null) {
1600 final paths =
1601 (decoded['paths'] as List)
1602 .map((p) => DrawingPath.fromMap(p))
1603 .toList();
1604 if (paths.isNotEmpty)
1605 _layers.add(SketchLayer(id: 'legacy_sketch', paths: paths));
1606 }
1607 }
1608 _hasUnsavedChanges = false;
1609 _activeLayerId = _layers.isNotEmpty ? _layers.last.id : null;
1610 });
1611 if (widget.injectedMedia != null)
1612 _addAssetsToCanvas([widget.injectedMedia!.path]);
1613 }
1614 } catch (e) {
1615 debugPrint("Error loading: $e");
1616 _initializeNewCanvas();
1617 }
1618 }
1619
1620 Future<void> _pickImageFromGallery() async {
1621 final List<XFile> images = await _picker.pickMultiImage();
1622 if (images.isNotEmpty) {
1623 final oldState = _getCurrentState();
1624 setState(() {
1625 for (int i = 0; i < images.length; i++) {
1626 final double imageWidth = _canvasSize.width * 0.4;
1627 final double imageHeight = _canvasSize.height * 0.4;
1628 final Offset centeredPosition = Offset(
1629 (_canvasSize.width - imageWidth) / 2,
1630 (_canvasSize.height - imageHeight) / 2,
1631 );
1632 final id = '${DateTime.now().millisecondsSinceEpoch}_$i';
1633 _layers.add(
1634 ImageLayer(
1635 id: id,
1636 data: {
1637 'id': id,
1638 'type': 'file_image',
1639 'content': images[i].path,
1640 'position': centeredPosition,
1641 'size': Size(imageWidth, imageHeight),
1642 'rotation': 0.0,
1643 },
1644 ),
1645 );
1646 _activeLayerId = id;
1647 }
1648 _hasUnsavedChanges = true;
1649 });
1650 _recordChange(oldState);
1651 }
1652 }
1653
1654 List<Map<String, dynamic>> _jsonToElements(List<dynamic> jsonList) {
1655 return jsonList.map((item) {
1656 final e = Map<String, dynamic>.from(item);
1657 if (e['position'] is Map)
1658 e['position'] = Offset(e['position']['dx'], e['position']['dy']);
1659 if (e['size'] is Map)
1660 e['size'] = Size(e['size']['width'], e['size']['height']);
1661 e['rotation'] = (e['rotation'] as num).toDouble();
1662 return e;
1663 }).toList();
1664 }
1665
1666 Future<void> _exportProject() async {
1667 try {
1668 ScaffoldMessenger.of(
1669 context,
1670 ).showSnackBar(const SnackBar(content: Text("Generating image...")));
1671 final boundary =
1672 _canvasGlobalKey.currentContext?.findRenderObject()
1673 as RenderRepaintBoundary?;
1674 if (boundary == null) return;
1675 final ui.Image image = await boundary.toImage(pixelRatio: 3.0);
1676 final ByteData? byteData = await image.toByteData(
1677 format: ui.ImageByteFormat.png,
1678 );
1679 if (byteData == null) return;
1680 final img.Image? decodedImage = img.decodePng(
1681 byteData.buffer.asUint8List(),
1682 );
1683 if (decodedImage == null) return;
1684 final Uint8List jpgBytes = img.encodeJpg(decodedImage, quality: 90);
1685 final directory = await getTemporaryDirectory();
1686 final String filePath =
1687 '${directory.path}/export_${DateTime.now().millisecondsSinceEpoch}.jpg';
1688 await File(filePath).writeAsBytes(jpgBytes);
1689 await Share.shareXFiles([
1690 XFile(filePath, mimeType: 'image/jpeg'),
1691 ], text: 'Check out my design created with CreekUI!');
1692 } catch (e) {
1693 debugPrint("Export Error: $e");
1694 }
1695 }
1696
1697 PreferredSizeWidget _buildAppBar() {
1698 final bool canUndo =
1699 _isMagicDrawActive
1700 ? _magicDrawChangeStack.canUndo
1701 : _changeStack.canUndo;
1702 final bool canRedo =
1703 _isMagicDrawActive
1704 ? _magicDrawChangeStack.canRedo
1705 : _changeStack.canRedo;
1706
1707 return AppBar(
1708 leadingWidth: 160,
1709 leading: SafeArea(
1710 child: Row(
1711 children: [
1712 IconButton(
1713 icon: SvgPicture.asset(
1714 'assets/icons/arrow-left-s-line.svg',
1715 width: 22,
1716 colorFilter: const ColorFilter.mode(
1717 Colors.black,
1718 BlendMode.srcIn,
1719 ),
1720 ),
1721 onPressed: _handleBackNavigation,
1722 ),
1723 IconButton(
1724 icon: SvgPicture.asset(
1725 'assets/icons/arrow-go-back-line.svg',
1726 width: 22,
1727 colorFilter: ColorFilter.mode(
1728 canUndo ? Colors.black : Colors.grey[400]!,
1729 BlendMode.srcIn,
1730 ),
1731 ),
1732 onPressed:
1733 canUndo
1734 ? () => setState(
1735 () =>
1736 _isMagicDrawActive
1737 ? _magicDrawChangeStack.undo()
1738 : _changeStack.undo(),
1739 )
1740 : null,
1741 ),
1742 IconButton(
1743 icon: SvgPicture.asset(
1744 'assets/icons/arrow-go-forward-line.svg',
1745 width: 22,
1746 colorFilter: ColorFilter.mode(
1747 canRedo ? Colors.black : Colors.grey[400]!,
1748 BlendMode.srcIn,
1749 ),
1750 ),
1751 onPressed:
1752 canRedo
1753 ? () => setState(
1754 () =>
1755 _isMagicDrawActive
1756 ? _magicDrawChangeStack.redo()
1757 : _changeStack.redo(),
1758 )
1759 : null,
1760 ),
1761 ],
1762 ),
1763 ),
1764 actions: [
1765 SafeArea(
1766 child: Row(
1767 children: [
1768 if (_activeLayerId != null)
1769 IconButton(
1770 icon: const Icon(Icons.delete_outline, color: Colors.red),
1771 onPressed: _deleteSelectedElement,
1772 tooltip: "Delete Selected",
1773 ),
1774 IconButton(
1775 icon: SvgPicture.asset(
1776 'assets/icons/save-3-line.svg',
1777 width: 22,
1778 ),
1779 onPressed: _saveCanvas,
1780 ),
1781 IconButton(
1782 icon: SvgPicture.asset(
1783 'assets/icons/upload-2-line.svg',
1784 width: 22,
1785 ),
1786 onPressed: _exportProject,
1787 ),
1788 IconButton(
1789 icon: SvgPicture.asset(
1790 'assets/icons/settings-line.svg',
1791 width: 22,
1792 ),
1793 onPressed: _openSettings,
1794 ),
1795 const SizedBox(width: 8),
1796 ],
1797 ),
1798 ),
1799 ],
1800 );
1801 }
1802}