creek

The AI Image Editor of 2030
commit e4bd0322d138b3770f2eda0bd1840b8fa984da82
parent 91c3e2d82649101fe46aaad6f2adf114253fabeb
Author: maydayv7 <maydayv7@gmail.com>
Date:   Tue,  9 Dec 2025 23:05:17 +0530

Add layers support to canvas

Diffstat:
Mflask/.gitignore | 1+
Mlib/data/models/canvas_models.dart | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mlib/ui/pages/canvas_page.dart | 2172+++++++++++++++++++++++++++++++++++++++----------------------------------------
Mlib/ui/pages/canvas_toolbar/text_tools_overlay.dart | 11-----------
Mlib/ui/painters/canvas_painter.dart | 22+++++++++++-----------
Alib/ui/widgets/canvas/layers_panel.dart | 257+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mlib/ui/widgets/canvas/manipulating_box.dart | 187+++++++++++++++++++++++++++++++++++++++----------------------------------------
Mlib/ui/widgets/file_card.dart | 2+-
8 files changed, 1509 insertions(+), 1232 deletions(-)

diff --git a/flask/.gitignore b/flask/.gitignore @@ -1,4 +1,5 @@ .env +input_data # Models BiRefNet diff --git a/lib/data/models/canvas_models.dart b/lib/data/models/canvas_models.dart @@ -1,7 +1,10 @@ import 'dart:ui'; +import 'package:flutter/material.dart'; enum DragHandle { none, topLeft, topRight, bottomLeft, bottomRight, center } +enum LayerType { sketch, image } + class DrawingPoint { final Offset offset; final Paint paint; @@ -50,9 +53,87 @@ class DrawingPath { } } -// Helper to snapshot the entire canvas state for undo/redo -class CanvasState { - final List<Map<String, dynamic>> elements; +// Layer Models +abstract class CanvasLayer { + final String id; + final LayerType type; + bool isVisible; + + CanvasLayer({required this.id, required this.type, this.isVisible = true}); + + Map<String, dynamic> toMap(); + + static CanvasLayer fromMap(Map<String, dynamic> map) { + if (map['type'] == 'sketch') { + return SketchLayer.fromMap(map); + } else { + return ImageLayer.fromMap(map); + } + } +} + +class SketchLayer extends CanvasLayer { final List<DrawingPath> paths; - CanvasState(this.elements, this.paths); + final bool isMagicDraw; + + SketchLayer({ + required String id, + this.paths = const [], + this.isMagicDraw = false, + bool isVisible = true, + }) : super(id: id, type: LayerType.sketch, isVisible: isVisible); + + @override + Map<String, dynamic> toMap() { + return { + 'id': id, + 'type': 'sketch', + 'paths': paths.map((p) => p.toMap()).toList(), + 'isMagicDraw': isMagicDraw, + 'isVisible': isVisible, + }; + } + + factory SketchLayer.fromMap(Map<String, dynamic> map) { + return SketchLayer( + id: map['id'], + paths: (map['paths'] as List).map((p) => DrawingPath.fromMap(p)).toList(), + isMagicDraw: map['isMagicDraw'] ?? false, + isVisible: map['isVisible'] ?? true, + ); + } +} + +class ImageLayer extends CanvasLayer { + final Map<String, dynamic> data; + + ImageLayer({required String id, required this.data, bool isVisible = true}) + : super(id: id, type: LayerType.image, isVisible: isVisible); + + @override + Map<String, dynamic> toMap() { + return {'id': id, 'type': 'image', 'data': data, 'isVisible': isVisible}; + } + + factory ImageLayer.fromMap(Map<String, dynamic> map) { + final data = Map<String, dynamic>.from(map['data']); + // Restore Offset/Size objects + if (data['position'] is Map) { + data['position'] = Offset(data['position']['dx'], data['position']['dy']); + } + if (data['size'] is Map) { + data['size'] = Size(data['size']['width'], data['size']['height']); + } + + return ImageLayer( + id: map['id'], + data: data, + isVisible: map['isVisible'] ?? true, + ); + } +} + +class CanvasState { + final List<CanvasLayer> layers; + CanvasState(this.layers); } diff --git a/lib/ui/pages/canvas_page.dart b/lib/ui/pages/canvas_page.dart @@ -27,6 +27,7 @@ import 'package:creekui/utils/image_utils.dart'; import 'package:creekui/ui/widgets/canvas/manipulating_box.dart'; import 'package:creekui/ui/widgets/canvas/canvas_bottom_bar.dart'; import 'package:creekui/ui/widgets/canvas/asset_picker_sheet.dart'; +import 'package:creekui/ui/widgets/canvas/layers_panel.dart'; import 'package:creekui/ui/widgets/app_bar.dart'; import 'package:creekui/ui/widgets/dialog.dart'; import 'package:creekui/ui/widgets/text_field.dart'; @@ -60,25 +61,33 @@ class _CanvasPageState extends State<CanvasPage> { final FileService _fileService = FileService(); final ChangeStack _changeStack = ChangeStack(); final ImagePicker _picker = ImagePicker(); + + // Stack for magic draw actions final ChangeStack _magicDrawChangeStack = ChangeStack(); bool _hasUnsavedChanges = false; - List<Map<String, dynamic>> elements = []; - List<DrawingPath> _paths = []; // Keeps normal drawing strokes - List<DrawingPath> _magicPaths = []; // Keeps temporary magic draw mask strokes + + // Layer state + // Bottom-most layer is at index 0 + List<CanvasLayer> _layers = []; + String? _activeLayerId; + bool _showLayersPanel = false; + + // Magic Draw & Flashing + String? _magicDrawLayerId; + String? _flashingLayerId; + Timer? _flashTimer; // Snapshots for undo grouping CanvasState? _gestureStartSnapshot; - // Detection Timer & AI Analysis + // AI & Inpainting State Timer? _inactivityTimer; DateTime _lastAnalysisTime = DateTime.now(); final GlobalKey _canvasGlobalKey = GlobalKey(); String? _aiDescription; bool _isAnalyzing = false; - bool _isDescriptionExpanded = false; // Track expansion state - - // Magic Draw / Inpainting State + bool _isDescriptionExpanded = false; File? _tempBaseImage; bool _isInpainting = false; bool _isCapturingBase = false; // Hide strokes during capture @@ -90,7 +99,6 @@ class _CanvasPageState extends State<CanvasPage> { Timer? _bgRemovalBannerTimer; bool _isRemovingBg = false; - String? selectedId; late Size _canvasSize; List<Color> _brandColors = []; @@ -100,7 +108,7 @@ class _CanvasPageState extends State<CanvasPage> { bool _isMagicPanelDisabled = false; bool _isViewMode = false; - // Editing + // Text Editing bool _isEditingText = false; final TextEditingController _textEditingController = TextEditingController(); final FocusNode _textFocusNode = FocusNode(); @@ -125,29 +133,49 @@ class _CanvasPageState extends State<CanvasPage> { if (widget.existingFile != null) { _loadCanvasFromFile(); - } else if (widget.initialImage != null) { + } else { + _initializeNewCanvas(); + } + } + + void _initializeNewCanvas() { + // Add initial image layer if present + if (widget.initialImage != null) { final double imageWidth = _canvasSize.width * 0.4; final double imageHeight = _canvasSize.height * 0.4; final Offset centeredPosition = Offset( (_canvasSize.width - imageWidth) / 2, (_canvasSize.height - imageHeight) / 2, ); - elements.add({ - 'id': 'bg_${DateTime.now().millisecondsSinceEpoch}', - 'type': 'file_image', - 'content': widget.initialImage!.path, - 'position': centeredPosition, - 'size': Size(imageWidth, imageHeight), - 'rotation': 0.0, - }); - _hasUnsavedChanges = true; + + final bgId = 'bg_${DateTime.now().millisecondsSinceEpoch}'; + _layers.add( + ImageLayer( + id: bgId, + data: { + 'id': bgId, + 'type': 'file_image', + 'content': widget.initialImage!.path, + 'position': centeredPosition, + 'size': Size(imageWidth, imageHeight), + 'rotation': 0.0, + }, + ), + ); + _activeLayerId = bgId; + } + + if (widget.injectedMedia != null) { + _addAssetsToCanvas([widget.injectedMedia!.path]); } + _hasUnsavedChanges = true; } @override void dispose() { _inactivityTimer?.cancel(); _bgRemovalBannerTimer?.cancel(); + _flashTimer?.cancel(); _textEditingController.dispose(); _textFocusNode.dispose(); _transformationController.dispose(); @@ -162,707 +190,324 @@ class _CanvasPageState extends State<CanvasPage> { }); } - Future<bool> _confirmDiscardMagicDraw() async { - if (_magicPaths.isEmpty) return true; - return await ShowDialog.show<bool>( - context, - title: "Discard Magic Draw?", - description: "Leaving Magic Draw will remove your sketch. Continue?", - primaryButtonText: "Discard", - isDestructive: true, - onPrimaryPressed: () => Navigator.pop(context, true), - secondaryButtonText: "Stay", - onSecondaryPressed: () => Navigator.pop(context, false), - ) ?? - false; - } - - Future<void> _analyzeCanvas() async { - if (_isAnalyzing || _isInpainting) return; // Skip if busy - setState(() => _isAnalyzing = true); - - try { - debugPrint("[AI] Starting canvas analysis..."); - - File? imageFile = await _captureCanvasToFile(); - if (imageFile == null) return; - - final description = await FlaskService().describeImage( - imagePath: imageFile.path, - ); - - debugPrint("[AI] Service Response: $description"); + // LAYER MANAGEMENT + void _handleLayerReorder(int oldUiIndex, int newUiIndex) { + setState(() { + // The UI shows layers Top -> Bottom (reversed order of stack) + List<CanvasLayer> uiList = _layers.reversed.toList(); - if (description != null && mounted) { - setState(() { - _aiDescription = description; - _isDescriptionExpanded = false; - }); + if (oldUiIndex < newUiIndex) { + newUiIndex -= 1; } - } catch (e) { - debugPrint("[AI] Analysis Failed: $e"); - } finally { - if (mounted) setState(() => _isAnalyzing = false); - } - } - - Future<File?> _captureCanvasToFile() async { - try { - RenderRepaintBoundary? boundary = - _canvasGlobalKey.currentContext?.findRenderObject() - as RenderRepaintBoundary?; - if (boundary == null) return null; - - ui.Image image = await boundary.toImage(pixelRatio: 1.0); - ByteData? byteData = await image.toByteData( - format: ui.ImageByteFormat.png, - ); - if (byteData == null) return null; - Uint8List pngBytes = byteData.buffer.asUint8List(); + final CanvasLayer item = uiList.removeAt(oldUiIndex); + uiList.insert(newUiIndex, item); - final tempDir = await getTemporaryDirectory(); - final tempFile = File( - '${tempDir.path}/canvas_capture_${DateTime.now().millisecondsSinceEpoch}.png', - ); - await tempFile.writeAsBytes(pngBytes); - return tempFile; - } catch (e) { - debugPrint("Error capturing canvas: $e"); - return null; - } + // Restore stack order (Bottom -> Top) + _layers = uiList.reversed.toList(); + _hasUnsavedChanges = true; + }); } - Future<void> _fetchBrandColors() async { - try { - final int? pId = widget.projectId; - if (pId == null) return; + void _handleLayerDelete(String id) { + final oldState = _getCurrentState(); + setState(() { + _layers.removeWhere((l) => l.id == id); - final project = await ProjectRepo().getProjectById(pId); - if (project != null && project.globalStylesheet != null) { - final styleData = StylesheetService().parse(project.globalStylesheet); - if (mounted && styleData.colors.isNotEmpty) { - setState(() { - _brandColors = styleData.colors; - }); - } + if (_activeLayerId == id) { + // If active layer is deleted, select the new top-most layer + _activeLayerId = _layers.isNotEmpty ? _layers.last.id : null; } - } catch (e) { - debugPrint("Error loading brand colors: $e"); - } - } - - // Undo/Redo - void _recordMagicChange(List<DrawingPath> oldMagicPaths) { - final newMagicPaths = List<DrawingPath>.from(_magicPaths); - _magicDrawChangeStack.add( - Change( - oldMagicPaths, - () => setState(() => _magicPaths = List.from(newMagicPaths)), // Redo - (val) => setState(() => _magicPaths = List.from(val)), // Undo - ), - ); - } - - void _recordChange(CanvasState oldState) { - _resetInactivityTimer(); // Reset timer on undoable actions - final newState = CanvasState( - _deepCopyElements(elements), - List.from(_paths), - ); - _changeStack.add( - Change( - oldState, - () { - // Redo - setState(() { - elements = _deepCopyElements(newState.elements); - _paths = List.from(newState.paths); - _hasUnsavedChanges = true; - }); - }, - (val) { - // Undo - setState(() { - elements = _deepCopyElements(val.elements); - _paths = List.from(val.paths); - _hasUnsavedChanges = true; - }); - }, - ), - ); - setState(() => _hasUnsavedChanges = true); + _hasUnsavedChanges = true; + }); + _recordChange(oldState); } - CanvasState _getCurrentState() { - return CanvasState(_deepCopyElements(elements), List.from(_paths)); + void _handleLayerVisibility(String id) { + final index = _layers.indexWhere((l) => l.id == id); + if (index != -1) { + setState(() { + _layers[index].isVisible = !_layers[index].isVisible; + }); + } } - // Actions - void _toggleTextTools() { + void _setActiveLayer(String id) { setState(() { - _isTextToolsActive = !_isTextToolsActive; - _isMagicDrawActive = false; - if (!_isTextToolsActive) { + _activeLayerId = id; + _flashLayer(id); // Visual feedback + + final layer = _layers.firstWhere((l) => l.id == id); + + // Auto-enable text tools if selecting a text layer + if (layer is ImageLayer && layer.data['type'] == 'text') { + _isTextToolsActive = true; + } else { + _isTextToolsActive = false; _exitEditMode(); - selectedId = null; } }); } - void _addTextElement() { - final oldState = _getCurrentState(); - final id = 'text_${DateTime.now().millisecondsSinceEpoch}'; - final double defaultFontSize = (_canvasSize.width / 25).clamp(24.0, 96.0); - final initialPos = Offset( - _canvasSize.width / 2 - 150, - _canvasSize.height / 2 - 50, - ); + void _flashLayer(String id) { + _flashTimer?.cancel(); + setState(() => _flashingLayerId = id); + _flashTimer = Timer(const Duration(milliseconds: 600), () { + if (mounted) setState(() => _flashingLayerId = null); + }); + } - final newElement = { - 'id': id, - 'type': 'text', - 'content': 'Double tap to edit', - 'position': initialPos, - 'size': Size(300, defaultFontSize * 2), - 'rotation': 0.0, - 'style_color': Colors.black.value, - 'style_fontSize': defaultFontSize, - }; + // DRAWING LOGIC + void _startMagicDraw() { + // Capture the base canvas (everything currently visible) + _ensureBaseImageCaptured().then((_) { + setState(() { + _isMagicDrawActive = true; + _isTextToolsActive = false; + _exitEditMode(); - setState(() { - elements.add(newElement); - // Bring to front - elements.remove(newElement); - elements.add(newElement); - selectedId = id; - _isTextToolsActive = true; + // Add an initial sketch layer + _addNewMagicLayer(); + _magicDrawChangeStack.clear(); + }); }); - - _recordChange(oldState); - _enterEditMode(newElement); } - void _enterEditMode(Map<String, dynamic> element) { + void _addNewMagicLayer() { + final id = 'magic_${DateTime.now().millisecondsSinceEpoch}'; + final magicLayer = SketchLayer(id: id, isMagicDraw: true, paths: []); setState(() { - selectedId = element['id']; - _isEditingText = true; - _textEditingController.text = element['content']; - _textEditingController.selection = TextSelection.fromPosition( - TextPosition(offset: _textEditingController.text.length), - ); - }); - - WidgetsBinding.instance.addPostFrameCallback((_) { - if (_textFocusNode.canRequestFocus) { - _textFocusNode.requestFocus(); - } + _layers.add(magicLayer); + _activeLayerId = id; + _magicDrawLayerId = id; }); } - void _exitEditMode() { - if (_isEditingText && selectedId != null) { - final index = elements.indexWhere((e) => e['id'] == selectedId); - if (index != -1) { - if (elements[index]['content'] != _textEditingController.text) { - final oldState = _getCurrentState(); - setState(() { - elements[index]['content'] = _textEditingController.text; - _isEditingText = false; - }); - _recordChange(oldState); - } else { - setState(() => _isEditingText = false); - } - } else { - setState(() => _isEditingText = false); + Future<void> _handleMagicDrawExit() async { + // Check if there is any content in any magic draw layer + bool hasDrawing = _layers.any( + (l) => l is SketchLayer && l.isMagicDraw && l.paths.isNotEmpty, + ); + + if (hasDrawing && !_isInpainting) { + final confirm = await _confirmDiscardMagicDraw(); + if (confirm) { + _cleanupMagicDraw(); } - _textFocusNode.unfocus(); + } else { + _cleanupMagicDraw(); } } - void _updateSelectedTextProperty(String key, dynamic value) { - if (selectedId == null) return; - final index = elements.indexWhere((e) => e['id'] == selectedId); - if (index == -1) return; + Future<bool> _confirmDiscardMagicDraw() async { + return await ShowDialog.show<bool>( + context, + title: "Discard Magic Draw?", + description: + "Leaving Magic Draw will remove your sketches. Continue?", + primaryButtonText: "Discard", + isDestructive: true, + onPrimaryPressed: () => Navigator.pop(context, true), + secondaryButtonText: "Stay", + onSecondaryPressed: () => Navigator.pop(context, false), + ) ?? + false; + } - final oldState = _getCurrentState(); + void _cleanupMagicDraw() { setState(() { - elements[index][key] = value; + // Remove all magic draw layers + _layers.removeWhere((l) => l is SketchLayer && l.isMagicDraw); + _isMagicDrawActive = false; + _activeLayerId = _layers.isNotEmpty ? _layers.last.id : null; + _magicDrawChangeStack.clear(); + _tempBaseImage = null; }); - _recordChange(oldState); } - void _deleteSelectedElement() { - if (selectedId == null) return; - final oldState = _getCurrentState(); + void _onPanUpdate(DragUpdateDetails details) { + if (!_isMagicDrawActive) return; + setState(() { - elements.removeWhere((e) => e['id'] == selectedId); - selectedId = null; - _isEditingText = false; - _isTextToolsActive = false; + _currentPoints.add( + DrawingPoint(offset: details.localPosition, paint: Paint()), + ); }); - _textFocusNode.unfocus(); - _recordChange(oldState); - } - void _handleGestureStart() { - if (_isMagicDrawActive) { - bool hasImageLayers = elements.any((e) => e['type'] == 'file_image'); - if (hasImageLayers && !_isInpainting && _tempBaseImage == null) { - _ensureBaseImageCaptured(); - } - return; + // Throttle AI analysis + if (DateTime.now().difference(_lastAnalysisTime).inMilliseconds > 2500) { + _lastAnalysisTime = DateTime.now(); + _analyzeCanvas(); } - _gestureStartSnapshot = _getCurrentState(); } - // Captures the base image by temporarily hiding the magic strokes - Future<void> _ensureBaseImageCaptured() async { - if (_tempBaseImage != null) return; + Future<void> _onPanEnd(DragEndDetails details) async { + if (!_isMagicDrawActive) return; - debugPrint("[Magic Draw] Hiding strokes to capture clean base..."); + setState(() { + if (_currentPoints.isNotEmpty) { + CanvasLayer? targetLayer; + + // 1. Try active layer + if (_activeLayerId != null) { + final index = _layers.indexWhere((l) => l.id == _activeLayerId); + if (index != -1 && + _layers[index] is SketchLayer && + (_layers[index] as SketchLayer).isMagicDraw) { + targetLayer = _layers[index]; + } + } - setState(() => _isCapturingBase = true); - await Future.delayed( - const Duration(milliseconds: 50), - ); // Wait for frame to render + // 2. If not valid, find any magic layer or create one + if (targetLayer == null) { + try { + targetLayer = _layers.lastWhere( + (l) => l is SketchLayer && (l as SketchLayer).isMagicDraw, + ); + _activeLayerId = targetLayer.id; + } catch (_) { + _addNewMagicLayer(); + targetLayer = _layers.last; + } + } - try { - _tempBaseImage = await _captureCanvasToFile(); - debugPrint("[Magic Draw] Base image captured."); - } catch (e) { - debugPrint("[Magic Draw] Failed to capture base: $e"); - } finally { - if (mounted) setState(() => _isCapturingBase = false); - } + if (targetLayer is SketchLayer) { + final oldPaths = List<DrawingPath>.from(targetLayer.paths); + targetLayer.paths.add( + DrawingPath( + points: List.from(_currentPoints), + color: _selectedColor, + strokeWidth: _strokeWidth, + isEraser: _isEraser, + ), + ); + _recordMagicChange(oldPaths, targetLayer.id); + } + _currentPoints = []; + } + }); } - void _handleElementUpdate( - String id, - Offset newPos, - Size newSize, - double newRotation, - ) { - _resetInactivityTimer(); // Reset timer while dragging/resizing - final index = elements.indexWhere((e) => e['id'] == id); - if (index == -1) return; + // GENERATION & INPAINTING + Future<void> _processInpainting(String prompt, String modelId) async { + FocusScope.of(context).unfocus(); + setState(() => _isInpainting = true); + _resetInactivityTimer(); - setState(() { - elements[index]['position'] = newPos; - elements[index]['size'] = newSize; - elements[index]['rotation'] = newRotation; - _hasUnsavedChanges = true; - }); - } - - void _handleGestureEnd() { - if (_gestureStartSnapshot != null) { - _recordChange(_gestureStartSnapshot!); - _gestureStartSnapshot = null; - } - } - - Future<void> _exportProject() async { try { - // 1. Show loading or feedback - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text("Generating image..."))); - - // 2. Capture the Full Canvas using the global key - final boundary = - _canvasGlobalKey.currentContext?.findRenderObject() - as RenderRepaintBoundary?; - if (boundary == null) return; - - // 3. Convert to Image (High pixel ratio for quality) - final ui.Image image = await boundary.toImage(pixelRatio: 3.0); - final ByteData? byteData = await image.toByteData( - format: ui.ImageByteFormat.png, - ); - - if (byteData == null) return; - - final Uint8List pngBytes = byteData.buffer.asUint8List(); - - // 4. Convert PNG to JPG - // TODO: Done on main thread for simplicity - final img.Image? decodedImage = img.decodePng(pngBytes); - - if (decodedImage == null) { - throw Exception("Failed to decode image"); - } - - // Encode to JPG - final Uint8List jpgBytes = img.encodeJpg(decodedImage, quality: 90); - - // 5. Save to Temporary File - final directory = await getTemporaryDirectory(); - final String fileName = - "export_${DateTime.now().millisecondsSinceEpoch}.jpg"; - final String filePath = '${directory.path}/$fileName'; - - final File imgFile = File(filePath); - await imgFile.writeAsBytes(jpgBytes); - - // 6. Trigger System Share/Save Dialog - await Share.shareXFiles([ - XFile(filePath, mimeType: 'image/jpeg'), - ], text: 'Check out my design created with CreekUI!'); - } catch (e) { - debugPrint("Export Error: $e"); - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text("Export failed: $e"))); - } - } + // Check if there are image layers (which dictates the context) + bool hasImageLayers = _layers.any((l) => l is ImageLayer); - // Save & Load Logic - Future<void> _handleMagicDrawExit() async { - if (_magicPaths.isNotEmpty && !_isInpainting) { - final confirm = await _confirmDiscardMagicDraw(); - if (confirm) { - _saveAndCloseMagicDraw(); + if (_tempBaseImage == null) { + _tempBaseImage = await _captureCanvasToFile(); } - } else { - _saveAndCloseMagicDraw(); - } - } - - void _handleBackNavigation() { - // If in magic draw mode, just close tool first - if (_isMagicDrawActive) { - _saveAndCloseMagicDraw(); - return; - } - - if (!_hasUnsavedChanges && widget.existingFile != null) { - Navigator.pop(context); - return; - } + if (_tempBaseImage == null) return; - ShowDialog.show( - context, - title: "Save Changes?", - description: "Do you want to save your canvas before leaving?", - primaryButtonText: "Save", - onPrimaryPressed: () async { - Navigator.pop(context); // Close dialog - await _saveCanvas(); // Save - if (mounted && widget.existingFile != null) { - Navigator.pop(context); + // Consolidate paths from all active magic layers for mask + List<DrawingPath> combinedPaths = []; + for (var layer in _layers) { + if (layer is SketchLayer && layer.isMagicDraw) { + combinedPaths.addAll(layer.paths); } - }, - secondaryButtonText: "Discard", - onSecondaryPressed: () { - Navigator.pop(context); // Close dialog - Navigator.pop(context); // Leave page - }, - ); - } - - Future<String?> _showNameDialog() async { - final nameController = TextEditingController(text: "Untitled Canvas"); - return await showDialog<String>( - context: context, - builder: - (ctx) => Dialog( - backgroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(Variables.radiusLarge), - ), - child: ShowDialog( - title: "Save Canvas", - content: CommonTextField( - hintText: "Enter a name for your file", - controller: nameController, - autoFocus: true, - ), - primaryButtonText: "Save", - onPrimaryPressed: - () => Navigator.pop(ctx, nameController.text.trim()), - secondaryButtonText: "Cancel", - onSecondaryPressed: () => Navigator.pop(ctx), - ), - ), - ); - } - - // Helper to generate preview image - Future<String?> _generatePreviewImage() async { - try { - final boundary = - _canvasGlobalKey.currentContext?.findRenderObject() - as RenderRepaintBoundary?; - if (boundary == null) return null; - - // Capture image with lower pixel ratio for preview thumbnail - final ui.Image image = await boundary.toImage(pixelRatio: 1.0); - final ByteData? byteData = await image.toByteData( - format: ui.ImageByteFormat.png, - ); - - if (byteData == null) return null; - final Uint8List pngBytes = byteData.buffer.asUint8List(); - - final directory = await getApplicationDocumentsDirectory(); - final previewDir = Directory('${directory.path}/previews'); - if (!await previewDir.exists()) { - await previewDir.create(recursive: true); } - final String fileName = - "preview_${DateTime.now().millisecondsSinceEpoch}.png"; - final String filePath = '${previewDir.path}/$fileName'; - - final File imgFile = File(filePath); - await imgFile.writeAsBytes(pngBytes); - return filePath; - } catch (e) { - debugPrint("Error generating preview: $e"); - return null; - } - } - - Future<void> _saveCanvas() async { - try { - String fileName = "Canvas ${DateTime.now().toString().split(' ')[0]}"; - bool isNewFile = widget.existingFile == null; - - // 1. New File: Ask user for name - if (isNewFile) { - final userFileName = await _showNameDialog(); - if (userFileName == null || userFileName.isEmpty) return; - fileName = userFileName; - } - - // 2. Generate Preview - final String? previewPath = await _generatePreviewImage(); - - // 3. Serialize Elements and Drawing to JSON - final jsonList = _elementsToJson(elements); - final pathsJson = _paths.map((p) => p.toMap()).toList(); - - final canvasData = { - 'elements': jsonList, - 'paths': pathsJson, - 'width': _canvasSize.width, - 'height': _canvasSize.height, - 'preview_path': previewPath, - }; - - await _fileService.saveCanvasFile( - projectId: widget.projectId, - canvasData: canvasData, - fileId: widget.existingFile?.id, - fileName: widget.existingFile == null ? fileName : null, + File? maskFile = await _generateMaskImageFromPaths( + combinedPaths, + _canvasSize, + _tempBaseImage, ); + if (maskFile == null) throw Exception("Failed to generate mask"); - setState(() => _hasUnsavedChanges = false); + String? newImageUrl; - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text("Canvas Saved Successfully")), - ); - if (isNewFile) { - Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (_) => ProjectFilePage(projectId: widget.projectId), - ), + // Case 1: Inpainting + if (hasImageLayers) { + // API Inpainting + if (modelId == 'inpaint_api') { + newImageUrl = await FlaskService().inpaintApiImage( + imagePath: _tempBaseImage!.path, + maskPath: maskFile.path, + prompt: prompt, + ); + } else { + // Local inpainting + newImageUrl = await FlaskService().inpaintImage( + imagePath: _tempBaseImage!.path, + maskPath: maskFile.path, + prompt: prompt, + ); + } + } else { + // Case 2: Sketch to Image + if (modelId == 'sketch_fusion') { + newImageUrl = await FlaskService().sketchToImage( + projectId: widget.projectId, + sketchPath: _tempBaseImage!.path, + userPrompt: prompt, + imageDescription: _aiDescription, + ); + } else { + newImageUrl = await FlaskService().sketchToImage( + projectId: widget.projectId, + sketchPath: _tempBaseImage!.path, + userPrompt: prompt, + imageDescription: _aiDescription, ); } } - } catch (e) { - debugPrint("Save Error: $e"); - } - } - - Future<void> _loadCanvasFromFile() async { - try { - // File Metadata - final meta = await _fileService.getFileMetadata( - widget.existingFile!.filePath, - ); - if (meta.width > 0 && meta.height > 0) { - setState(() { - _canvasSize = Size(meta.width, meta.height); - _hasInitializedView = false; - }); - } - - final file = File(widget.existingFile!.filePath); - if (await file.exists()) { - final jsonString = await file.readAsString(); - final dynamic decoded = jsonDecode(jsonString); - setState(() { - if (decoded is Map && decoded.containsKey('elements')) { - elements = _jsonToElements(decoded['elements']); - if (decoded['paths'] != null) { - _paths = - (decoded['paths'] as List) - .map((p) => DrawingPath.fromMap(p)) - .toList(); - } else { - _paths = []; - } - } else if (decoded is List) { - // Legacy support - elements = _jsonToElements(decoded); - _paths = []; - _hasInitializedView = false; - } - _hasUnsavedChanges = false; - }); - - // Handle Injected Media - if (widget.injectedMedia != null) { - final oldState = _getCurrentState(); - setState(() { - final double imageWidth = _canvasSize.width * 0.4; - final double imageHeight = _canvasSize.height * 0.4; - final Offset centeredPosition = Offset( - (_canvasSize.width - imageWidth) / 2, - (_canvasSize.height - imageHeight) / 2, - ); - elements.add({ - 'id': 'shared_${DateTime.now().millisecondsSinceEpoch}', - 'type': 'file_image', - 'content': widget.injectedMedia!.path, - 'position': centeredPosition, - 'size': Size(imageWidth, imageHeight), - 'rotation': 0.0, - }); - }); - _recordChange(oldState); + if (newImageUrl != null) { + _addGeneratedImage(newImageUrl); + if (!hasImageLayers && _layers.isNotEmpty) { + _triggerBgRemovalBanner(_layers.last.id, newImageUrl); } } } catch (e) { - debugPrint("Error loading canvas: $e"); + debugPrint("Gen Error: $e"); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text("Generation failed"))); + } finally { + setState(() { + _isInpainting = false; + _layers.removeWhere((l) => l is SketchLayer && l.isMagicDraw); + _isMagicDrawActive = false; + _tempBaseImage = null; + // Select newly generated image + if (_layers.isNotEmpty) _activeLayerId = _layers.last.id; + }); } } - List<Map<String, dynamic>> _deepCopyElements( - List<Map<String, dynamic>> source, - ) { - return source.map((e) { - final copy = Map<String, dynamic>.from(e); - if (e['position'] is Offset) { - copy['position'] = Offset( - (e['position'] as Offset).dx, - (e['position'] as Offset).dy, - ); - } - if (e['size'] is Size) { - copy['size'] = Size( - (e['size'] as Size).width, - (e['size'] as Size).height, - ); - } - return copy; - }).toList(); - } - - List<Map<String, dynamic>> _elementsToJson( - List<Map<String, dynamic>> elements, - ) { - return elements.map((e) { - final copy = Map<String, dynamic>.from(e); - if (e['position'] is Offset) { - copy['position'] = { - 'dx': (e['position'] as Offset).dx, - 'dy': (e['position'] as Offset).dy, - }; - } - if (e['size'] is Size) { - copy['size'] = { - 'width': (e['size'] as Size).width, - 'height': (e['size'] as Size).height, - }; - } - return copy; - }).toList(); - } - - List<Map<String, dynamic>> _jsonToElements(List<dynamic> jsonList) { - return jsonList.map((item) { - final e = Map<String, dynamic>.from(item); - if (e['position'] is Map) { - e['position'] = Offset(e['position']['dx'], e['position']['dy']); - } - if (e['size'] is Map) { - e['size'] = Size(e['size']['width'], e['size']['height']); - } - e['rotation'] = (e['rotation'] as num).toDouble(); - return e; - }).toList(); - } - - void _openLayers() => _showComingSoon('Layers'); - void _openSettings() => _showComingSoon('Settings'); - void _showComingSoon([dynamic feature]) => ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Coming soon'))); - - // Bottom Sheet for Assets - void _openStylesheet() { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: - (context) => DraggableScrollableSheet( - initialChildSize: 0.7, - minChildSize: 0.5, - maxChildSize: 0.9, - builder: - (_, controller) => AssetPickerSheet( - projectId: widget.projectId, - scrollController: controller, - onAddAssets: (List<String> paths) { - _addAssetsToCanvas(paths); - Navigator.pop(context); - }, - ), - ), - ); - } - - void _addAssetsToCanvas(List<String> paths) { - if (paths.isEmpty) return; - final oldState = _getCurrentState(); + void _addGeneratedImage(String newImageUrl) { + final id = 'gen_${DateTime.now().millisecondsSinceEpoch}'; setState(() { - for (var path in paths) { - final double imageWidth = _canvasSize.width * 0.4; - final double imageHeight = _canvasSize.height * 0.4; - final Offset centeredPosition = Offset( - (_canvasSize.width - imageWidth) / 2, - (_canvasSize.height - imageHeight) / 2, - ); - elements.add({ - 'id': - 'asset_${DateTime.now().millisecondsSinceEpoch}_${math.Random().nextInt(1000)}', - 'type': 'file_image', - 'content': path, - 'position': centeredPosition, - 'size': Size(imageWidth, imageHeight), - 'rotation': 0.0, - }); - } - _hasUnsavedChanges = true; + _layers.add( + ImageLayer( + id: id, + data: { + 'id': id, + 'type': 'file_image', + 'content': newImageUrl, + 'position': const Offset(0, 0), + 'size': _canvasSize, + 'rotation': 0.0, + }, + ), + ); }); - _recordChange(oldState); } - // UI Builder + // UI BUILDER @override Widget build(BuildContext context) { Map<String, dynamic>? selectedEl; - try { - selectedEl = elements.firstWhere((e) => e['id'] == selectedId); - } catch (_) {} + if (_activeLayerId != null) { + try { + final layer = _layers.firstWhere((l) => l.id == _activeLayerId); + if (layer is ImageLayer) selectedEl = layer.data; + } catch (_) {} + } final bool isTextSelected = selectedEl != null && selectedEl['type'] == 'text'; @@ -888,7 +533,6 @@ class _CanvasPageState extends State<CanvasPage> { final double initialScale = math .min(scaleX, scaleY) .clamp(0.01, 1.0); - final double transX = (constraints.maxWidth - (_canvasSize.width * initialScale)) / 2; @@ -907,9 +551,10 @@ class _CanvasPageState extends State<CanvasPage> { children: [ GestureDetector( onTap: () { + // Tapping background clears selection (unless inside magic draw) if (!_isMagicDrawActive || _isMagicPanelDisabled) { _exitEditMode(); - setState(() => selectedId = null); + setState(() => _activeLayerId = null); } }, behavior: HitTestBehavior.translucent, @@ -942,99 +587,113 @@ class _CanvasPageState extends State<CanvasPage> { ], ), ), - ...elements.map((e) { - final bool isSelected = selectedId == e['id']; - return ManipulatingBox( - key: ValueKey(e['id']), - id: e['id'], - position: e['position'], - size: e['size'], - rotation: e['rotation'], - type: e['type'], - content: e['content'], - styleData: e, - isSelected: isSelected && !_isMagicDrawActive, - isEditing: - isSelected && - _isEditingText && - e['type'] == 'text', - viewScale: - _transformationController.value - .getMaxScaleOnAxis(), - onTap: () { - if (!_isMagicDrawActive) { - if (_isEditingText && selectedId != e['id']) - _exitEditMode(); - setState(() { - selectedId = e['id']; - if (e['type'] == 'text') - _isTextToolsActive = true; - }); - setState(() { - elements.remove(e); - elements.add(e); - }); - } - }, - onDoubleTap: () { - if (e['type'] == 'text') _enterEditMode(e); - }, - onDragStart: _handleGestureStart, - onUpdate: - (newPos, newSize, newRot) => - _handleElementUpdate( - e['id'], - newPos, - newSize, - newRot, - ), - onDragEnd: (newPos, newSize, newRot) { - _handleElementUpdate( - e['id'], - newPos, - newSize, - newRot, - ); - _handleGestureEnd(); - }, - textController: - isSelected ? _textEditingController : null, - focusNode: isSelected ? _textFocusNode : null, - transformationController: - _transformationController, - ); - }), - // Drawing Layer - IgnorePointer( - ignoring: - !_isMagicDrawActive || _isMagicPanelDisabled, - child: RepaintBoundary( - key: _drawingKey, - child: GestureDetector( - onPanStart: (_) => _handleGestureStart(), - onPanUpdate: _onPanUpdate, - onPanEnd: (details) { - _onPanEnd(details); + + // Render Layers + ..._layers.map((layer) { + if (!layer.isVisible) + return const SizedBox.shrink(); + + bool isFlashing = layer.id == _flashingLayerId; + + if (layer is ImageLayer) { + final e = layer.data; + final bool isSelected = + _activeLayerId == layer.id; + + return ManipulatingBox( + key: ValueKey(layer.id), + id: e['id'], + position: e['position'], + size: e['size'], + rotation: e['rotation'], + type: e['type'], + content: e['content'], + styleData: e, + isSelected: isSelected && !_isMagicDrawActive, + isEditing: isSelected && _isEditingText, + isFlashing: isFlashing, + viewScale: + _transformationController.value + .getMaxScaleOnAxis(), + transformationController: + _transformationController, + onTap: () { + if (!_isMagicDrawActive) + _setActiveLayer(layer.id); + }, + onDoubleTap: () { + if (e['type'] == 'text') _enterEditMode(e); + }, + onDragStart: _handleGestureStart, + onUpdate: + (newPos, newSize, newRot) => + _handleElementUpdate( + layer.id, + newPos, + newSize, + newRot, + ), + onDragEnd: (newPos, newSize, newRot) { + _handleElementUpdate( + layer.id, + newPos, + newSize, + newRot, + ); _handleGestureEnd(); }, - child: CustomPaint( - size: Size.infinite, - painter: CanvasPainter( - paths: _paths, - magicPaths: - _isCapturingBase ? [] : _magicPaths, - currentPoints: - _isCapturingBase - ? [] - : _currentPoints, - currentColor: - _isEraser - ? Colors.transparent - : _selectedColor, - currentWidth: _strokeWidth, - isEraser: _isEraser, + textController: + isSelected + ? _textEditingController + : null, + focusNode: isSelected ? _textFocusNode : null, + ); + } else if (layer is SketchLayer) { + return IgnorePointer( + ignoring: true, + child: RepaintBoundary( + child: CustomPaint( + size: Size.infinite, + painter: CanvasPainter( + paths: layer.paths, + currentPoints: [], + currentColor: Colors.transparent, + currentWidth: 0, + isEraser: false, + overrideColor: + isFlashing + ? Colors.greenAccent + : null, + ), ), ), + ); + } + return const SizedBox.shrink(); + }).toList(), + + // Active Drawing Surface + IgnorePointer( + ignoring: + !(_isMagicDrawActive && + !_isMagicPanelDisabled), + child: GestureDetector( + onPanStart: (d) {}, + onPanUpdate: _onPanUpdate, + onPanEnd: _onPanEnd, + child: CustomPaint( + size: Size.infinite, + painter: CanvasPainter( + paths: [], + currentPoints: + _isCapturingBase ? [] : _currentPoints, + currentColor: + _isEraser + ? Colors.transparent + : _selectedColor, + currentWidth: _strokeWidth, + isEraser: _isEraser, + ), ), ), ), @@ -1045,6 +704,7 @@ class _CanvasPageState extends State<CanvasPage> { ), ), + // AI Description Overlay if (_aiDescription != null && _isMagicDrawActive) Positioned( top: 10, @@ -1124,35 +784,26 @@ class _CanvasPageState extends State<CanvasPage> { onColorChanged: (c) => setState(() => _selectedColor = c), onWidthChanged: (w) => setState(() => _strokeWidth = w), onEraserToggle: (e) => setState(() => _isEraser = e), - onPromptSubmit: - (prompt, serviceId) => - _processInpainting(prompt, serviceId), + onPromptSubmit: _processInpainting, isProcessing: _isInpainting, onMagicPanelActivityToggle: - (disabled) => - setState(() => _isMagicPanelDisabled = disabled), + (d) => setState(() => _isMagicPanelDisabled = d), isMagicPanelDisabled: _isMagicPanelDisabled, - onViewModeToggle: - (enabled) => setState(() => _isViewMode = enabled), - hasImageLayers: elements.any( - (e) => e['type'] == 'file_image', - ), + onViewModeToggle: (e) => setState(() => _isViewMode = e), + hasImageLayers: _layers.any((l) => l is ImageLayer), ), TextToolsOverlay( isActive: showTextOverlay && !_isMagicDrawActive, isTextSelected: isTextSelected, currentColor: Color( - selectedEl?['style_color'] ?? Colors.black.value, + (selectedEl?['style_color'] ?? Colors.black.value), ), currentFontSize: (selectedEl?['style_fontSize'] ?? 24.0) as double, onClose: () { _exitEditMode(); - setState(() { - _isTextToolsActive = false; - selectedId = null; - }); + setState(() => _activeLayerId = null); }, onAddText: _addTextElement, onColorChanged: @@ -1177,13 +828,6 @@ class _CanvasPageState extends State<CanvasPage> { decoration: BoxDecoration( color: Variables.surfaceDark, borderRadius: BorderRadius.circular(30), - boxShadow: [ - BoxShadow( - color: Colors.black26, - blurRadius: 8, - offset: Offset(0, 2), - ), - ], ), child: Row( mainAxisSize: MainAxisSize.min, @@ -1202,7 +846,6 @@ class _CanvasPageState extends State<CanvasPage> { "Removing...", style: TextStyle( color: Colors.white, - fontFamily: 'GeneralSans', fontSize: 14, ), ), @@ -1211,24 +854,15 @@ class _CanvasPageState extends State<CanvasPage> { "Remove background?", style: TextStyle( color: Colors.white, - fontFamily: 'GeneralSans', fontSize: 14, ), ), const SizedBox(width: 8), InkWell( onTap: _confirmRemoveBackground, - child: Container( - padding: const EdgeInsets.all(4), - decoration: const BoxDecoration( - color: Colors.white, - shape: BoxShape.circle, - ), - child: const Icon( - Icons.check, - size: 16, - color: Colors.black, - ), + child: const Icon( + Icons.check, + color: Colors.white, ), ), const SizedBox(width: 8), @@ -1239,7 +873,6 @@ class _CanvasPageState extends State<CanvasPage> { ), child: const Icon( Icons.close, - size: 18, color: Colors.white54, ), ), @@ -1250,6 +883,28 @@ class _CanvasPageState extends State<CanvasPage> { ), ), + // Layers Panel + Positioned( + right: 16, + top: + SafeArea(child: Container()).minimum.top + + (_isMagicDrawActive ? 80 : 10), // Prevent overlap with description overlay + child: LayersPanel( + layers: _layers, + activeLayerId: _activeLayerId, + isOpen: _showLayersPanel, + onToggle: + () => setState( + () => _showLayersPanel = !_showLayersPanel, + ), + onReorder: _handleLayerReorder, + onDelete: _handleLayerDelete, + onToggleVisibility: _handleLayerVisibility, + onLayerTap: _setActiveLayer, + onAddLayer: _isMagicDrawActive ? _addNewMagicLayer : null, + ), + ), + Positioned( bottom: 0, left: 0, @@ -1261,100 +916,50 @@ class _CanvasPageState extends State<CanvasPage> { : (_isTextToolsActive ? "Text" : null), onMagicDraw: () async { if (_isMagicDrawActive) { - final confirm = await _confirmDiscardMagicDraw(); - if (!confirm) return; - setState(() { - _magicPaths.clear(); - _magicDrawChangeStack.clear(); - _tempBaseImage = null; - _isMagicDrawActive = false; - }); - return; + _handleMagicDrawExit(); + } else { + _startMagicDraw(); } - setState(() { - _isMagicDrawActive = true; - _isTextToolsActive = false; - _exitEditMode(); - _magicPaths.clear(); - _magicDrawChangeStack.clear(); - _tempBaseImage = null; - }); }, onMedia: () async { if (_isMagicDrawActive) { - final confirm = await _confirmDiscardMagicDraw(); - if (!confirm) return; - setState(() { - _magicPaths.clear(); - _magicDrawChangeStack.clear(); - _tempBaseImage = null; - _isMagicDrawActive = false; - }); + if (!await _confirmDiscardMagicDraw()) return; + _cleanupMagicDraw(); } _pickImageFromGallery(); }, onStylesheet: () async { if (_isMagicDrawActive) { - final confirm = await _confirmDiscardMagicDraw(); - if (!confirm) return; - setState(() { - _magicPaths.clear(); - _magicDrawChangeStack.clear(); - _tempBaseImage = null; - _isMagicDrawActive = false; - }); + if (!await _confirmDiscardMagicDraw()) return; + _cleanupMagicDraw(); } _openStylesheet(); }, onTools: () async { if (_isMagicDrawActive) { - final confirm = await _confirmDiscardMagicDraw(); - if (!confirm) return; - setState(() { - _magicPaths.clear(); - _magicDrawChangeStack.clear(); - _tempBaseImage = null; - _isMagicDrawActive = false; - }); + if (!await _confirmDiscardMagicDraw()) return; + _cleanupMagicDraw(); } _showComingSoon('Tools'); }, onText: () async { if (_isMagicDrawActive) { - final confirm = await _confirmDiscardMagicDraw(); - if (!confirm) return; - setState(() { - _magicPaths.clear(); - _magicDrawChangeStack.clear(); - _tempBaseImage = null; - _isMagicDrawActive = false; - }); + if (!await _confirmDiscardMagicDraw()) return; + _cleanupMagicDraw(); } _toggleTextTools(); }, onSelect: () async { if (_isMagicDrawActive) { - final confirm = await _confirmDiscardMagicDraw(); - if (!confirm) return; - setState(() { - _magicPaths.clear(); - _magicDrawChangeStack.clear(); - _tempBaseImage = null; - _isMagicDrawActive = false; - }); + if (!await _confirmDiscardMagicDraw()) return; + _cleanupMagicDraw(); } _showComingSoon('Select'); }, onPlugins: () async { if (_isMagicDrawActive) { - final confirm = await _confirmDiscardMagicDraw(); - if (!confirm) return; - setState(() { - _magicPaths.clear(); - _magicDrawChangeStack.clear(); - _tempBaseImage = null; - _isMagicDrawActive = false; - }); + if (!await _confirmDiscardMagicDraw()) return; + _cleanupMagicDraw(); } _showComingSoon('Plugins'); }, @@ -1368,59 +973,284 @@ class _CanvasPageState extends State<CanvasPage> { ); } - // Drawing Helpers - void _onPanUpdate(DragUpdateDetails details) { - setState(() { - _currentPoints.add( - DrawingPoint(offset: details.localPosition, paint: Paint()), - ); - }); + // Helpers + Future<void> _ensureBaseImageCaptured() async { + if (_tempBaseImage != null) return; + setState(() => _isCapturingBase = true); + await Future.delayed(const Duration(milliseconds: 50)); + try { + _tempBaseImage = await _captureCanvasToFile(); + } catch (e) { + debugPrint("Failed to capture base: $e"); + } finally { + if (mounted) setState(() => _isCapturingBase = false); + } + } + + Future<File?> _captureCanvasToFile() async { + try { + RenderRepaintBoundary? boundary = + _canvasGlobalKey.currentContext?.findRenderObject() + as RenderRepaintBoundary?; + if (boundary == null) return null; + ui.Image image = await boundary.toImage(pixelRatio: 1.0); + ByteData? byteData = await image.toByteData( + format: ui.ImageByteFormat.png, + ); + if (byteData == null) return null; + final tempDir = await getTemporaryDirectory(); + final tempFile = File( + '${tempDir.path}/canvas_capture_${DateTime.now().millisecondsSinceEpoch}.png', + ); + await tempFile.writeAsBytes(byteData.buffer.asUint8List()); + return tempFile; + } catch (e) { + return null; + } + } + + Future<void> _analyzeCanvas() async { + if (_isAnalyzing || _isInpainting) return; + setState(() => _isAnalyzing = true); + try { + File? imageFile = await _captureCanvasToFile(); + if (imageFile == null) return; + final description = await FlaskService().describeImage( + imagePath: imageFile.path, + ); + if (description != null && mounted) { + setState(() { + _aiDescription = description; + _isDescriptionExpanded = false; + }); + } + } catch (e) { + debugPrint("AI Error: $e"); + } finally { + if (mounted) setState(() => _isAnalyzing = false); + } + } + + Future<void> _fetchBrandColors() async { + try { + final int? pId = widget.projectId; + if (pId == null) return; + final project = await ProjectRepo().getProjectById(pId); + if (project != null && project.globalStylesheet != null) { + final styleData = StylesheetService().parse(project.globalStylesheet); + if (mounted && styleData.colors.isNotEmpty) + setState(() => _brandColors = styleData.colors); + } + } catch (e) { + debugPrint("Error loading brand colors: $e"); + } + } + + void _recordMagicChange(List<DrawingPath> oldPaths, String layerId) { + final newPaths = List<DrawingPath>.from( + (_layers.firstWhere((l) => l.id == layerId) as SketchLayer).paths, + ); + _magicDrawChangeStack.add( + Change( + oldPaths, + () { + // Redo + final index = _layers.indexWhere((l) => l.id == layerId); + if (index != -1) { + setState( + () => + _layers[index] = SketchLayer( + id: _layers[index].id, + isMagicDraw: true, + paths: newPaths, + isVisible: _layers[index].isVisible, + ), + ); + } + }, + (val) { + // Undo + final index = _layers.indexWhere((l) => l.id == layerId); + if (index != -1) { + setState( + () => + _layers[index] = SketchLayer( + id: _layers[index].id, + isMagicDraw: true, + paths: List.from(val), + isVisible: _layers[index].isVisible, + ), + ); + } + }, + ), + ); + } + + void _recordChange(CanvasState oldState) { + _resetInactivityTimer(); + final newState = CanvasState(_deepCopyLayers(_layers)); + _changeStack.add( + Change( + oldState, + () => setState(() { + _layers = _deepCopyLayers(newState.layers); + _hasUnsavedChanges = true; + }), + (val) => setState(() { + _layers = _deepCopyLayers(val.layers); + _hasUnsavedChanges = true; + }), + ), + ); + setState(() => _hasUnsavedChanges = true); + } + + CanvasState _getCurrentState() => CanvasState(_deepCopyLayers(_layers)); + + List<CanvasLayer> _deepCopyLayers(List<CanvasLayer> layers) { + return layers.map((l) { + if (l is SketchLayer) { + return SketchLayer( + id: l.id, + paths: List<DrawingPath>.from(l.paths), + isMagicDraw: l.isMagicDraw, + isVisible: l.isVisible, + ); + } else if (l is ImageLayer) { + return ImageLayer( + id: l.id, + data: Map<String, dynamic>.from(l.data), + isVisible: l.isVisible, + ); + } + return l; + }).toList(); + } + + void _toggleTextTools() { + setState(() { + _isTextToolsActive = !_isTextToolsActive; + _isMagicDrawActive = false; + if (!_isTextToolsActive) { + _exitEditMode(); + _activeLayerId = null; + } + }); + } + + void _addTextElement() { + final oldState = _getCurrentState(); + final id = 'text_${DateTime.now().millisecondsSinceEpoch}'; + final initialPos = Offset( + _canvasSize.width / 2 - 150, + _canvasSize.height / 2 - 50, + ); + final newElementData = { + 'id': id, + 'type': 'text', + 'content': 'Double tap to edit', + 'position': initialPos, + 'size': Size(300, 60), + 'rotation': 0.0, + 'style_color': Colors.black.value, + 'style_fontSize': 30.0, + }; + setState(() { + _layers.add(ImageLayer(id: id, data: newElementData)); + _activeLayerId = id; + _isTextToolsActive = true; + }); + _recordChange(oldState); + _enterEditMode(newElementData); + } + + void _enterEditMode(Map<String, dynamic> element) { + setState(() { + _activeLayerId = element['id']; + _isEditingText = true; + _textEditingController.text = element['content']; + _textEditingController.selection = TextSelection.fromPosition( + TextPosition(offset: _textEditingController.text.length), + ); + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_textFocusNode.canRequestFocus) _textFocusNode.requestFocus(); + }); + } + + void _exitEditMode() { + if (_isEditingText && _activeLayerId != null) { + final index = _layers.indexWhere((l) => l.id == _activeLayerId); + if (index != -1 && _layers[index] is ImageLayer) { + final layer = _layers[index] as ImageLayer; + if (layer.data['content'] != _textEditingController.text) { + final oldState = _getCurrentState(); + setState(() { + layer.data['content'] = _textEditingController.text; + _isEditingText = false; + }); + _recordChange(oldState); + } else { + setState(() => _isEditingText = false); + } + } else { + setState(() => _isEditingText = false); + } + _textFocusNode.unfocus(); + } + } + + void _updateSelectedTextProperty(String key, dynamic value) { + if (_activeLayerId == null) return; + final index = _layers.indexWhere((l) => l.id == _activeLayerId); + if (index != -1 && _layers[index] is ImageLayer) { + final oldState = _getCurrentState(); + setState(() => (_layers[index] as ImageLayer).data[key] = value); + _recordChange(oldState); + } + } - // Throttle Analysis: Trigger "Describe" every 2.5s while actively drawing - if (_isMagicDrawActive && - !_isAnalyzing && - !_isInpainting && - DateTime.now().difference(_lastAnalysisTime).inMilliseconds > 2500) { - _lastAnalysisTime = DateTime.now(); - _analyzeCanvas(); + void _deleteSelectedElement() { + if (_activeLayerId != null) { + _handleLayerDelete(_activeLayerId!); + setState(() { + _isEditingText = false; + _isTextToolsActive = false; + }); + _textFocusNode.unfocus(); } } - Future<void> _onPanEnd(DragEndDetails details) async { - setState(() { - if (_currentPoints.isNotEmpty) { - if (_isMagicDrawActive) { - // Temporary mask - final oldMagicPaths = List<DrawingPath>.from(_magicPaths); - _magicPaths.add( - DrawingPath( - points: List.from(_currentPoints), - color: _selectedColor, - strokeWidth: _strokeWidth, - isEraser: _isEraser, - ), - ); - _recordMagicChange(oldMagicPaths); - _currentPoints = []; - } else { - // Normal drawing - _paths.add( - DrawingPath( - points: List.from(_currentPoints), - color: _selectedColor, - strokeWidth: _strokeWidth, - isEraser: _isEraser, - ), - ); - _currentPoints = []; - _hasUnsavedChanges = true; - _resetInactivityTimer(); - } - } - }); + void _handleElementUpdate( + String id, + Offset newPos, + Size newSize, + double newRot, + ) { + final index = _layers.indexWhere((l) => l.id == id); + if (index != -1 && _layers[index] is ImageLayer) { + setState(() { + (_layers[index] as ImageLayer).data['position'] = newPos; + (_layers[index] as ImageLayer).data['size'] = newSize; + (_layers[index] as ImageLayer).data['rotation'] = newRot; + _hasUnsavedChanges = true; + }); + } + } + + void _handleGestureEnd() { + if (_gestureStartSnapshot != null) { + _recordChange(_gestureStartSnapshot!); + _gestureStartSnapshot = null; + } + } + + void _handleGestureStart() { + if (_isMagicDrawActive) return; + _gestureStartSnapshot = _getCurrentState(); } - // Background Removal Logic void _triggerBgRemovalBanner(String elementId, String imagePath) { _bgRemovalBannerTimer?.cancel(); setState(() { @@ -1431,33 +1261,24 @@ class _CanvasPageState extends State<CanvasPage> { // Automatically hide after 10 seconds _bgRemovalBannerTimer = Timer(const Duration(seconds: 10), () { - if (mounted) { - setState(() => _showBgRemovalBanner = false); - } + if (mounted) setState(() => _showBgRemovalBanner = false); }); } Future<void> _confirmRemoveBackground() async { if (_bgRemovalTargetId == null || _bgRemovalTargetPath == null) return; - _bgRemovalBannerTimer?.cancel(); - - setState(() { - _isRemovingBg = true; - }); - + setState(() => _isRemovingBg = true); try { final newPath = await FlaskService().generateAsset( imagePath: _bgRemovalTargetPath!, ); - if (newPath != null && mounted) { - // Find the element and update its content - final index = elements.indexWhere((e) => e['id'] == _bgRemovalTargetId); - if (index != -1) { + final index = _layers.indexWhere((e) => e.id == _bgRemovalTargetId); + if (index != -1 && _layers[index] is ImageLayer) { final oldState = _getCurrentState(); setState(() { - elements[index]['content'] = newPath; + (_layers[index] as ImageLayer).data['content'] = newPath; _showBgRemovalBanner = false; _isRemovingBg = false; _hasUnsavedChanges = true; @@ -1476,210 +1297,373 @@ class _CanvasPageState extends State<CanvasPage> { } } - Future<void> _processInpainting(String prompt, String modelId) async { - FocusScope.of(context).unfocus(); // Lock editing and hide keyboard + // Generate mask: Base Image + Drawing Strokes + Future<File?> _generateMaskImageFromPaths( + List<DrawingPath> paths, + Size size, + File? baseImageFile, + ) async { + try { + final recorder = ui.PictureRecorder(); + final canvas = Canvas( + recorder, + Rect.fromLTWH(0, 0, size.width, size.height), + ); - setState(() => _isInpainting = true); - _resetInactivityTimer(); + // 1. Draw Base Image First (if available) + if (baseImageFile != null) { + final data = await baseImageFile.readAsBytes(); + final codec = await ui.instantiateImageCodec(data); + final frameInfo = await codec.getNextFrame(); + paintImage( + canvas: canvas, + rect: Rect.fromLTWH(0, 0, size.width, size.height), + image: frameInfo.image, + fit: BoxFit.cover, + ); + } else { + // Fallback to black if no base image (shouldn't happen based on logic) + canvas.drawRect( + Rect.fromLTWH(0, 0, size.width, size.height), + Paint()..color = Colors.black, + ); + } + + // 2. Draw Strokes on top of base image + for (final path in paths) { + final paint = + Paint() + ..color = path.color + ..strokeWidth = path.strokeWidth + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round + ..style = PaintingStyle.stroke; + if (path.points.length > 1) { + final Path p = Path(); + p.moveTo(path.points.first.offset.dx, path.points.first.offset.dy); + for (int i = 1; i < path.points.length; i++) + p.lineTo(path.points[i].offset.dx, path.points[i].offset.dy); + canvas.drawPath(p, paint); + } else if (path.points.isNotEmpty) { + canvas.drawPoints(ui.PointMode.points, [ + path.points.first.offset, + ], paint); + } + } + final picture = recorder.endRecording(); + final img = await picture.toImage( + size.width.toInt(), + size.height.toInt(), + ); + final byteData = await img.toByteData(format: ui.ImageByteFormat.png); + if (byteData == null) return null; + final tempDir = await getTemporaryDirectory(); + final file = File( + '${tempDir.path}/mask_${DateTime.now().millisecondsSinceEpoch}.png', + ); + await file.writeAsBytes(byteData.buffer.asUint8List()); + return file; + } catch (e) { + return null; + } + } + + void _showComingSoon(String feature) => ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('$feature Coming soon'))); + void _openSettings() => _showComingSoon('Settings'); + + void _openStylesheet() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: + (context) => DraggableScrollableSheet( + initialChildSize: 0.7, + minChildSize: 0.5, + maxChildSize: 0.9, + builder: + (_, controller) => AssetPickerSheet( + projectId: widget.projectId, + scrollController: controller, + onAddAssets: _addAssetsToCanvas, + ), + ), + ); + } + + void _addAssetsToCanvas(List<String> paths) { + if (paths.isEmpty) return; + final oldState = _getCurrentState(); + setState(() { + for (var path in paths) { + final double imageWidth = _canvasSize.width * 0.4; + final double imageHeight = _canvasSize.height * 0.4; + final Offset centeredPosition = Offset( + (_canvasSize.width - imageWidth) / 2, + (_canvasSize.height - imageHeight) / 2, + ); + final id = + 'asset_${DateTime.now().millisecondsSinceEpoch}_${math.Random().nextInt(1000)}'; + _layers.add( + ImageLayer( + id: id, + data: { + 'id': id, + 'type': 'file_image', + 'content': path, + 'position': centeredPosition, + 'size': Size(imageWidth, imageHeight), + 'rotation': 0.0, + }, + ), + ); + _activeLayerId = id; + } + _hasUnsavedChanges = true; + }); + _recordChange(oldState); + } + + Future<void> _handleBackNavigation() async { + if (_isMagicDrawActive) { + _handleMagicDrawExit(); + return; + } + if (!_hasUnsavedChanges && widget.existingFile != null) { + Navigator.pop(context); + return; + } + ShowDialog.show( + context, + title: "Save Changes?", + description: "Do you want to save your canvas before leaving?", + primaryButtonText: "Save", + onPrimaryPressed: () async { + Navigator.pop(context); + await _saveCanvas(); + if (mounted && widget.existingFile != null) Navigator.pop(context); + }, + secondaryButtonText: "Discard", + onSecondaryPressed: () { + Navigator.pop(context); + Navigator.pop(context); + }, + ); + } + + Future<String?> _showNameDialog() async { + final nameController = TextEditingController(text: "Untitled Canvas"); + return await showDialog<String>( + context: context, + builder: + (ctx) => Dialog( + backgroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(Variables.radiusLarge), + ), + child: ShowDialog( + title: "Save Canvas", + content: CommonTextField( + hintText: "Enter a name for your file", + controller: nameController, + autoFocus: true, + ), + primaryButtonText: "Save", + onPrimaryPressed: + () => Navigator.pop(ctx, nameController.text.trim()), + secondaryButtonText: "Cancel", + onSecondaryPressed: () => Navigator.pop(ctx), + ), + ), + ); + } + + Future<String?> _generatePreviewImage() async { + try { + final boundary = + _canvasGlobalKey.currentContext?.findRenderObject() + as RenderRepaintBoundary?; + if (boundary == null) return null; + final ui.Image image = await boundary.toImage(pixelRatio: 1.0); + final ByteData? byteData = await image.toByteData( + format: ui.ImageByteFormat.png, + ); + if (byteData == null) return null; + final directory = await getApplicationDocumentsDirectory(); + final previewDir = Directory('${directory.path}/previews'); + if (!await previewDir.exists()) await previewDir.create(recursive: true); + final String filePath = + '${previewDir.path}/preview_${DateTime.now().millisecondsSinceEpoch}.png'; + await File(filePath).writeAsBytes(byteData.buffer.asUint8List()); + return filePath; + } catch (e) { + return null; + } + } + Future<void> _saveCanvas() async { try { - // Check if there are image layers (which dictates the context) - bool hasImageLayers = elements.any((e) => e['type'] == 'file_image'); - - if (_tempBaseImage == null) { - _tempBaseImage = await _captureCanvasToFile(); + String fileName = "Canvas ${DateTime.now().toString().split(' ')[0]}"; + if (widget.existingFile == null) { + final userFileName = await _showNameDialog(); + if (userFileName == null || userFileName.isEmpty) return; + fileName = userFileName; } - if (_tempBaseImage == null) return; - - File? maskFile = await _generateMaskImageFromPaths( - _magicPaths, - _canvasSize, - _tempBaseImage, + final String? previewPath = await _generatePreviewImage(); + final layersJson = _layers.map((l) => l.toMap()).toList(); + final canvasData = { + 'version': 2, + 'layers': layersJson, + 'width': _canvasSize.width, + 'height': _canvasSize.height, + 'preview_path': previewPath, + }; + await _fileService.saveCanvasFile( + projectId: widget.projectId, + canvasData: canvasData, + fileId: widget.existingFile?.id, + fileName: widget.existingFile == null ? fileName : null, ); - - if (maskFile == null) throw Exception("Failed to generate mask"); - - String? newImageUrl; - - // Case 1: Inpainting - if (hasImageLayers) { - if (modelId == 'inpaint_api') { - // API Inpainting - newImageUrl = await FlaskService().inpaintApiImage( - imagePath: _tempBaseImage!.path, - maskPath: maskFile.path, - prompt: prompt, - ); - } else { - // Local Inpainting - newImageUrl = await FlaskService().inpaintImage( - imagePath: _tempBaseImage!.path, - maskPath: maskFile.path, - prompt: prompt, - ); - } - } - // Case 2: Sketch to Image - else { - if (modelId == 'sketch_fusion') { - newImageUrl = await FlaskService().sketchToImage( - projectId: widget.projectId, - sketchPath: _tempBaseImage!.path, - userPrompt: prompt, - imageDescription: _aiDescription, - ); - } else if (modelId == 'sketch_advanced') { - newImageUrl = await FlaskService().sketchToImageAPI( - projectId: widget.projectId, - sketchPath: _tempBaseImage!.path, - userPrompt: prompt, - option: 1, - imageDescription: _aiDescription, - ); - } else if (modelId == 'sketch_creative') { - newImageUrl = await FlaskService().sketchToImageAPI( - projectId: widget.projectId, - sketchPath: _tempBaseImage!.path, - userPrompt: prompt, - option: 2, - imageDescription: _aiDescription, + setState(() => _hasUnsavedChanges = false); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("Canvas Saved Successfully")), + ); + if (widget.existingFile == null) + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (_) => ProjectFilePage(projectId: widget.projectId), + ), ); - } } + } catch (e) { + debugPrint("Save Error: $e"); + } + } - if (newImageUrl != null) { - final id = _addGeneratedImage(newImageUrl); + Future<void> _loadCanvasFromFile() async { + try { + final meta = await _fileService.getFileMetadata( + widget.existingFile!.filePath, + ); + if (meta.width > 0 && meta.height > 0) setState(() { - _isMagicDrawActive = false; + _canvasSize = Size(meta.width, meta.height); + _hasInitializedView = false; }); - - // Trigger Banner for Background Removal (Only for generation, not inpainting) - if (!hasImageLayers) { - _triggerBgRemovalBanner(id, newImageUrl); - } + final file = File(widget.existingFile!.filePath); + if (await file.exists()) { + final jsonString = await file.readAsString(); + final dynamic decoded = jsonDecode(jsonString); + setState(() { + _layers.clear(); + if (decoded is Map && decoded.containsKey('layers')) { + for (var item in decoded['layers']) + _layers.add(CanvasLayer.fromMap(item)); + } else if (decoded is Map && decoded.containsKey('elements')) { + for (var e in _jsonToElements(decoded['elements'])) + _layers.add(ImageLayer(id: e['id'], data: e)); + if (decoded['paths'] != null) { + final paths = + (decoded['paths'] as List) + .map((p) => DrawingPath.fromMap(p)) + .toList(); + if (paths.isNotEmpty) + _layers.add(SketchLayer(id: 'legacy_sketch', paths: paths)); + } + } + _hasUnsavedChanges = false; + _activeLayerId = _layers.isNotEmpty ? _layers.last.id : null; + }); + if (widget.injectedMedia != null) + _addAssetsToCanvas([widget.injectedMedia!.path]); } } catch (e) { - debugPrint("Generation Error: $e"); - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text("Generation failed"))); - } finally { - setState(() { - _isInpainting = false; - _tempBaseImage = null; - _magicPaths.clear(); - _magicDrawChangeStack.clear(); - }); + debugPrint("Error loading: $e"); + _initializeNewCanvas(); } } - String _addGeneratedImage(String? newImageUrl) { - String id = ''; - if (newImageUrl != null) { - debugPrint("Adding generated image to canvas: $newImageUrl"); - id = 'gen_${DateTime.now().millisecondsSinceEpoch}'; + Future<void> _pickImageFromGallery() async { + final List<XFile> images = await _picker.pickMultiImage(); + if (images.isNotEmpty) { + final oldState = _getCurrentState(); setState(() { - elements.add({ - 'id': id, - 'type': 'file_image', - 'content': newImageUrl, - 'position': const Offset(0, 0), - 'size': _canvasSize, - 'rotation': 0.0, - }); - _magicPaths.clear(); + for (int i = 0; i < images.length; i++) { + final double imageWidth = _canvasSize.width * 0.4; + final double imageHeight = _canvasSize.height * 0.4; + final Offset centeredPosition = Offset( + (_canvasSize.width - imageWidth) / 2, + (_canvasSize.height - imageHeight) / 2, + ); + final id = '${DateTime.now().millisecondsSinceEpoch}_$i'; + _layers.add( + ImageLayer( + id: id, + data: { + 'id': id, + 'type': 'file_image', + 'content': images[i].path, + 'position': centeredPosition, + 'size': Size(imageWidth, imageHeight), + 'rotation': 0.0, + }, + ), + ); + _activeLayerId = id; + } + _hasUnsavedChanges = true; }); + _recordChange(oldState); } - return id; } - // Generate mask: Base Image + Drawing Strokes - Future<File?> _generateMaskImageFromPaths( - List<DrawingPath> paths, - Size size, - File? baseImageFile, - ) async { - try { - final recorder = ui.PictureRecorder(); - final canvas = Canvas( - recorder, - Rect.fromLTWH(0, 0, size.width, size.height), - ); - - // 1. Draw Base Image First (if available) - if (baseImageFile != null) { - final data = await baseImageFile.readAsBytes(); - final codec = await ui.instantiateImageCodec(data); - final frameInfo = await codec.getNextFrame(); - final baseImage = frameInfo.image; - - paintImage( - canvas: canvas, - rect: Rect.fromLTWH(0, 0, size.width, size.height), - image: baseImage, - fit: BoxFit.cover, - ); - } else { - // Fallback to black if no base image (shouldn't happen based on logic) - canvas.drawRect( - Rect.fromLTWH(0, 0, size.width, size.height), - Paint()..color = Colors.black, - ); - } - - // 2. Draw Strokes on top of base image - for (final path in paths) { - final paint = - Paint() - ..color = path.color - ..strokeWidth = path.strokeWidth - ..strokeCap = StrokeCap.round - ..strokeJoin = StrokeJoin.round - ..style = PaintingStyle.stroke; - - if (path.points.length > 1) { - final Path p = Path(); - p.moveTo(path.points.first.offset.dx, path.points.first.offset.dy); - for (int i = 1; i < path.points.length; i++) { - p.lineTo(path.points[i].offset.dx, path.points[i].offset.dy); - } - canvas.drawPath(p, paint); - } else if (path.points.isNotEmpty) { - canvas.drawPoints(ui.PointMode.points, [ - path.points.first.offset, - ], paint); - } - } + List<Map<String, dynamic>> _jsonToElements(List<dynamic> jsonList) { + return jsonList.map((item) { + final e = Map<String, dynamic>.from(item); + if (e['position'] is Map) + e['position'] = Offset(e['position']['dx'], e['position']['dy']); + if (e['size'] is Map) + e['size'] = Size(e['size']['width'], e['size']['height']); + e['rotation'] = (e['rotation'] as num).toDouble(); + return e; + }).toList(); + } - final picture = recorder.endRecording(); - final img = await picture.toImage( - size.width.toInt(), - size.height.toInt(), + Future<void> _exportProject() async { + try { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text("Generating image..."))); + final boundary = + _canvasGlobalKey.currentContext?.findRenderObject() + as RenderRepaintBoundary?; + if (boundary == null) return; + final ui.Image image = await boundary.toImage(pixelRatio: 3.0); + final ByteData? byteData = await image.toByteData( + format: ui.ImageByteFormat.png, ); - final byteData = await img.toByteData(format: ui.ImageByteFormat.png); - if (byteData == null) return null; - - final tempDir = await getTemporaryDirectory(); - final file = File( - '${tempDir.path}/mask_${DateTime.now().millisecondsSinceEpoch}.png', + if (byteData == null) return; + final img.Image? decodedImage = img.decodePng( + byteData.buffer.asUint8List(), ); - await file.writeAsBytes(byteData.buffer.asUint8List()); - return file; + if (decodedImage == null) return; + final Uint8List jpgBytes = img.encodeJpg(decodedImage, quality: 90); + final directory = await getTemporaryDirectory(); + final String filePath = + '${directory.path}/export_${DateTime.now().millisecondsSinceEpoch}.jpg'; + await File(filePath).writeAsBytes(jpgBytes); + await Share.shareXFiles([ + XFile(filePath, mimeType: 'image/jpeg'), + ], text: 'Check out my design created with CreekUI!'); } catch (e) { - debugPrint("Mask Generation Error: $e"); - return null; + debugPrint("Export Error: $e"); } } - Future<void> _saveAndCloseMagicDraw() async { - setState(() { - _isMagicDrawActive = false; - _magicPaths.clear(); - _magicDrawChangeStack.clear(); - _tempBaseImage = null; - }); - } - PreferredSizeWidget _buildAppBar() { final bool canUndo = _isMagicDrawActive @@ -1689,6 +1673,7 @@ class _CanvasPageState extends State<CanvasPage> { _isMagicDrawActive ? _magicDrawChangeStack.canRedo : _changeStack.canRedo; + return AppBar( leadingWidth: 160, leading: SafeArea( @@ -1703,13 +1688,7 @@ class _CanvasPageState extends State<CanvasPage> { BlendMode.srcIn, ), ), - onPressed: () { - if (_isMagicDrawActive) { - _handleMagicDrawExit(); - } else { - _handleBackNavigation(); - } - }, + onPressed: _handleBackNavigation, ), IconButton( icon: SvgPicture.asset( @@ -1756,7 +1735,7 @@ class _CanvasPageState extends State<CanvasPage> { SafeArea( child: Row( children: [ - if (selectedId != null) + if (_activeLayerId != null) IconButton( icon: const Icon(Icons.delete_outline, color: Colors.red), onPressed: _deleteSelectedElement, @@ -1790,31 +1769,4 @@ class _CanvasPageState extends State<CanvasPage> { ], ); } - - Future<void> _pickImageFromGallery() async { - final List<XFile> images = await _picker.pickMultiImage(); - if (images.isNotEmpty) { - final oldState = _getCurrentState(); - setState(() { - for (int i = 0; i < images.length; i++) { - final double imageWidth = _canvasSize.width * 0.4; - final double imageHeight = _canvasSize.height * 0.4; - final Offset centeredPosition = Offset( - (_canvasSize.width - imageWidth) / 2, - (_canvasSize.height - imageHeight) / 2, - ); - elements.add({ - 'id': '${DateTime.now().millisecondsSinceEpoch}_$i', - 'type': 'file_image', - 'content': images[i].path, - 'position': centeredPosition, - 'size': Size(imageWidth, imageHeight), - 'rotation': 0.0, - }); - } - _hasUnsavedChanges = true; - }); - _recordChange(oldState); - } - } } diff --git a/lib/ui/pages/canvas_toolbar/text_tools_overlay.dart b/lib/ui/pages/canvas_toolbar/text_tools_overlay.dart @@ -63,7 +63,6 @@ class TextToolsOverlay extends StatelessWidget { ), Container(width: 1, height: 20, color: Variables.borderSubtle), const SizedBox(width: 8), - IconButton( icon: const Icon( Icons.add_circle_outline, @@ -81,7 +80,6 @@ class TextToolsOverlay extends StatelessWidget { color: Variables.borderSubtle, ), const SizedBox(width: 12), - const Icon( Icons.text_fields, size: 18, @@ -108,9 +106,7 @@ class TextToolsOverlay extends StatelessWidget { ), ), ), - const SizedBox(width: 12), - GestureDetector( onTap: () => _showColorPicker(context), child: Container( @@ -126,13 +122,6 @@ class TextToolsOverlay extends StatelessWidget { ), ), ), - - const SizedBox(width: 12), - Container( - width: 1, - height: 20, - color: Variables.borderSubtle, - ), const SizedBox(width: 8), ], ], diff --git a/lib/ui/painters/canvas_painter.dart b/lib/ui/painters/canvas_painter.dart @@ -4,45 +4,44 @@ import 'package:creekui/data/models/canvas_models.dart'; class CanvasPainter extends CustomPainter { final List<DrawingPath> paths; - final List<DrawingPath> magicPaths; final List<DrawingPoint> currentPoints; final Color currentColor; final double currentWidth; final bool isEraser; + // Visual highlight/flash + final Color? overrideColor; + CanvasPainter({ required this.paths, - this.magicPaths = const [], required this.currentPoints, required this.currentColor, required this.currentWidth, required this.isEraser, + this.overrideColor, }); @override void paint(Canvas canvas, Size size) { canvas.saveLayer(Rect.fromLTWH(0, 0, size.width, size.height), Paint()); - // Draw normal paths + // Draw saved paths for (final path in paths) { _drawPath(canvas, path); } - // Draw magic paths (unless hidden by empty list passed in) - for (final path in magicPaths) { - _drawPath(canvas, path); - } - - // Draw current points + // Draw active stroke if (currentPoints.isNotEmpty) { final paint = Paint() - ..color = isEraser ? Colors.transparent : currentColor + ..color = + isEraser ? Colors.transparent : (overrideColor ?? currentColor) ..blendMode = isEraser ? BlendMode.clear : BlendMode.srcOver ..strokeWidth = currentWidth ..strokeCap = StrokeCap.round ..strokeJoin = StrokeJoin.round ..style = PaintingStyle.stroke; + final Path p = Path(); p.moveTo(currentPoints.first.offset.dx, currentPoints.first.offset.dy); for (int i = 1; i < currentPoints.length; i++) { @@ -56,7 +55,8 @@ class CanvasPainter extends CustomPainter { void _drawPath(Canvas canvas, DrawingPath path) { final paint = Paint() - ..color = path.isEraser ? Colors.transparent : path.color + ..color = + path.isEraser ? Colors.transparent : (overrideColor ?? path.color) ..blendMode = path.isEraser ? BlendMode.clear : BlendMode.srcOver ..strokeWidth = path.strokeWidth ..strokeCap = StrokeCap.round diff --git a/lib/ui/widgets/canvas/layers_panel.dart b/lib/ui/widgets/canvas/layers_panel.dart @@ -0,0 +1,257 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:creekui/data/models/canvas_models.dart'; + +class LayersPanel extends StatefulWidget { + final List<CanvasLayer> layers; + final String? activeLayerId; + final bool isOpen; + final VoidCallback onToggle; + final Function(int oldIndex, int newIndex) onReorder; + final Function(String id) onDelete; + final Function(String id) onToggleVisibility; + final Function(String id) onLayerTap; + final VoidCallback? onAddLayer; + + const LayersPanel({ + Key? key, + required this.layers, + required this.activeLayerId, + required this.isOpen, + required this.onToggle, + required this.onReorder, + required this.onDelete, + required this.onToggleVisibility, + required this.onLayerTap, + this.onAddLayer, + }) : super(key: key); + + @override + State<LayersPanel> createState() => _LayersPanelState(); +} + +class _LayersPanelState extends State<LayersPanel> { + @override + Widget build(BuildContext context) { + // Reverse list for UI so Top Layer is index 0 + final displayLayers = widget.layers.reversed.toList(); + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + width: widget.isOpen ? 260 : 56, + // Use constrained height calculation based on state + height: widget.isOpen ? 400 : 56, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.15), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + clipBehavior: Clip.antiAlias, + child: Material( + color: Colors.transparent, + child: + widget.isOpen + ? _buildPanelContent(displayLayers) + : _buildCollapsedButton(), + ), + ); + } + + Widget _buildCollapsedButton() { + return InkWell( + onTap: widget.onToggle, + borderRadius: BorderRadius.circular(16), + child: const Center( + child: Icon(Icons.layers_outlined, color: Colors.black), + ), + ); + } + + Widget _buildPanelContent(List<CanvasLayer> displayLayers) { + return AnimatedSize( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + alignment: Alignment.topRight, + child: Container( + constraints: const BoxConstraints(maxHeight: 400), + child: Column( + mainAxisSize: MainAxisSize.min, // Shrink to fit content + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + // Add Layer Button + if (widget.onAddLayer != null) ...[ + InkWell( + onTap: widget.onAddLayer, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: Colors.blue.shade50, + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.add, + size: 20, + color: Colors.blue, + ), + ), + ), + ], + + const Spacer(), + + // Layers Icon + InkWell( + onTap: widget.onToggle, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.layers, + size: 20, + color: Colors.black87, + ), + ), + ), + ], + ), + ), + const Divider(height: 1, thickness: 0.5), + + // List + Flexible( + child: ReorderableListView.builder( + shrinkWrap: true, + padding: EdgeInsets.zero, + buildDefaultDragHandles: false, + itemCount: displayLayers.length, + onReorder: widget.onReorder, + itemBuilder: (context, index) { + final layer = displayLayers[index]; + return _buildLayerItem(context, layer, index); + }, + ), + ), + ], + ), + ), + ); + } + + Widget _buildLayerItem(BuildContext context, CanvasLayer layer, int index) { + final bool isActive = layer.id == widget.activeLayerId; + + Widget preview; + String label = ""; + + if (layer is ImageLayer) { + final type = layer.data['type']; + final content = layer.data['content']; + + if (type == 'text') { + preview = const Icon( + Icons.text_fields, + size: 18, + color: Colors.black54, + ); + label = content.toString(); + } else { + preview = Container( + width: 28, + height: 28, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.grey[300]!), + image: DecorationImage( + image: FileImage(File(content)), + fit: BoxFit.cover, + ), + ), + ); + label = "Image"; + } + } else if (layer is SketchLayer) { + preview = const Icon(Icons.brush, size: 18, color: Colors.purpleAccent); + label = layer.isMagicDraw ? "Magic Draw" : "Sketch"; + } else { + preview = const SizedBox(); + } + + return Dismissible( + key: ValueKey(layer.id), + direction: DismissDirection.endToStart, + onDismissed: (_) => widget.onDelete(layer.id), + background: Container( + color: Colors.redAccent, + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 12), + child: const Icon(Icons.delete, color: Colors.white, size: 20), + ), + child: Material( + color: isActive ? Colors.grey.withOpacity(0.1) : Colors.transparent, + child: InkWell( + onTap: () => widget.onLayerTap(layer.id), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4), + child: Row( + children: [ + const SizedBox(width: 12), + preview, + const SizedBox(width: 12), + Expanded( + child: Text( + label, + style: const TextStyle(fontSize: 14), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + + IconButton( + padding: const EdgeInsets.all(4), + constraints: const BoxConstraints(), + icon: Icon( + layer.isVisible ? Icons.visibility : Icons.visibility_off, + size: 18, + color: layer.isVisible ? Colors.black54 : Colors.grey, + ), + onPressed: () => widget.onToggleVisibility(layer.id), + ), + + const SizedBox(width: 4), + + ReorderableDragStartListener( + index: index, + child: Container( + padding: const EdgeInsets.fromLTRB(12, 12, 16, 12), + color: Colors.transparent, + child: const Icon( + Icons.drag_handle, + size: 20, + color: Colors.grey, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/ui/widgets/canvas/manipulating_box.dart b/lib/ui/widgets/canvas/manipulating_box.dart @@ -13,6 +13,7 @@ class ManipulatingBox extends StatefulWidget { final Map<String, dynamic> styleData; final bool isSelected; final bool isEditing; + final bool isFlashing; final double viewScale; final TransformationController transformationController; final VoidCallback onTap; @@ -34,6 +35,7 @@ class ManipulatingBox extends StatefulWidget { required this.styleData, required this.isSelected, required this.isEditing, + this.isFlashing = false, required this.viewScale, required this.transformationController, required this.onTap, @@ -168,15 +170,20 @@ class _ManipulatingBoxState extends State<ManipulatingBox> { child: Container( width: _size.width, height: _size.height, - decoration: - widget.isSelected - ? BoxDecoration( - border: Border.all( - color: Variables.selectionBorder, - width: 2 * handleScale, - ), - ) - : null, + decoration: BoxDecoration( + border: + widget.isFlashing + ? Border.all( + color: Colors.greenAccent, + width: 4 * handleScale, + ) + : (widget.isSelected + ? Border.all( + color: Variables.selectionBorder, + width: 2 * handleScale, + ) + : null), + ), child: widget.type == "file_image" ? Image.file( @@ -187,101 +194,62 @@ class _ManipulatingBoxState extends State<ManipulatingBox> { ), ), - // RESIZE Edges - - // Right edge - if (widget.isSelected && !widget.isEditing) - Positioned( - right: -edgeThickness / 2, + if (widget.isSelected && + !widget.isEditing && + !widget.isFlashing) ...[ + _buildEdgeHandle( top: 0, bottom: 0, - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onPanUpdate: (d) { - setState(() { - _size = Size(_size.width + d.delta.dx, _size.height); - }); - widget.onUpdate(_pos, _size, _rot); - }, - onPanStart: (_) => widget.onDragStart(), - onPanEnd: (_) => widget.onDragEnd(_pos, _size, _rot), - child: Container( - width: edgeThickness, - color: Colors.transparent, - ), - ), + right: -edgeThickness / 2, + width: edgeThickness, + onUpdate: + (d) => setState( + () => + _size = Size( + _size.width + d.delta.dx, + _size.height, + ), + ), ), - - // Left edge - if (widget.isSelected && !widget.isEditing) - Positioned( - left: -edgeThickness / 2, + _buildEdgeHandle( top: 0, bottom: 0, - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onPanUpdate: (d) { - setState(() { - _pos += Offset(d.delta.dx, 0); - _size = Size(_size.width - d.delta.dx, _size.height); - }); - widget.onUpdate(_pos, _size, _rot); - }, - onPanStart: (_) => widget.onDragStart(), - onPanEnd: (_) => widget.onDragEnd(_pos, _size, _rot), - child: Container( - width: edgeThickness, - color: Colors.transparent, - ), - ), + left: -edgeThickness / 2, + width: edgeThickness, + onUpdate: (d) { + setState(() { + _pos += Offset(d.delta.dx, 0); + _size = Size(_size.width - d.delta.dx, _size.height); + }); + }, ), - - // Top edge - if (widget.isSelected && !widget.isEditing) - Positioned( - top: -edgeThickness / 2, + _buildEdgeHandle( left: 0, right: 0, - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onPanUpdate: (d) { - setState(() { - _pos += Offset(0, d.delta.dy); - _size = Size(_size.width, _size.height - d.delta.dy); - }); - widget.onUpdate(_pos, _size, _rot); - }, - onPanStart: (_) => widget.onDragStart(), - onPanEnd: (_) => widget.onDragEnd(_pos, _size, _rot), - child: Container( - height: edgeThickness, - color: Colors.transparent, - ), - ), + top: -edgeThickness / 2, + height: edgeThickness, + onUpdate: (d) { + setState(() { + _pos += Offset(0, d.delta.dy); + _size = Size(_size.width, _size.height - d.delta.dy); + }); + }, ), - - // Bottom edge - if (widget.isSelected && !widget.isEditing) - Positioned( - bottom: -edgeThickness / 2, + _buildEdgeHandle( left: 0, right: 0, - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onPanUpdate: (d) { - setState(() { - _size = Size(_size.width, _size.height + d.delta.dy); - }); - widget.onUpdate(_pos, _size, _rot); - }, - onPanStart: (_) => widget.onDragStart(), - onPanEnd: (_) => widget.onDragEnd(_pos, _size, _rot), - child: Container( - height: edgeThickness, - color: Colors.transparent, - ), - ), + bottom: -edgeThickness / 2, + height: edgeThickness, + onUpdate: + (d) => setState( + () => + _size = Size( + _size.width, + _size.height + d.delta.dy, + ), + ), ), + ], ], ), ), @@ -290,6 +258,37 @@ class _ManipulatingBoxState extends State<ManipulatingBox> { ); } + Widget _buildEdgeHandle({ + double? top, + double? bottom, + double? left, + double? right, + double? width, + double? height, + required Function(DragUpdateDetails) onUpdate, + }) { + return Positioned( + top: top, + bottom: bottom, + left: left, + right: right, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onPanUpdate: (d) { + onUpdate(d); + widget.onUpdate(_pos, _size, _rot); + }, + onPanStart: (_) => widget.onDragStart(), + onPanEnd: (_) => widget.onDragEnd(_pos, _size, _rot), + child: Container( + width: width, + height: height, + color: Colors.transparent, + ), + ), + ); + } + Widget _buildText() { final style = TextStyle( fontSize: (widget.styleData['style_fontSize'] ?? 24.0) as double, @@ -321,9 +320,7 @@ class _ManipulatingBoxState extends State<ManipulatingBox> { textDirection: TextDirection.ltr, ); tp.layout(maxWidth: 10000); - setState(() { - _size = Size(tp.width + 40, tp.height + 40); - }); + setState(() => _size = Size(tp.width + 40, tp.height + 40)); widget.onUpdate(_pos, _size, _rot); }, ), diff --git a/lib/ui/widgets/file_card.dart b/lib/ui/widgets/file_card.dart @@ -11,7 +11,7 @@ class FileCard extends StatelessWidget { final String timeAgo; final VoidCallback onTap; - // Optional actions for the context menu. + // Optional actions for the context menu final Function(String)? onMenuAction; const FileCard({