commit e4bd0322d138b3770f2eda0bd1840b8fa984da82
parent 91c3e2d82649101fe46aaad6f2adf114253fabeb
Author: maydayv7 <maydayv7@gmail.com>
Date: Tue, 9 Dec 2025 23:05:17 +0530
Add layers support to canvas
Diffstat:
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({