creek

The AI Image Editor of 2030
commit a7a2ef91001d50bf7cbca0e114b4f5ad8b907ff3
parent 8d56d21e4f205773b57c1a5f848906a4d8b21894
Author: maydayv7 <maydayv7@gmail.com>
Date:   Sat,  6 Dec 2025 05:04:42 +0530

Split canvas page into components

Also fix color picker

Diffstat:
Alib/data/models/canvas_models.dart | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mlib/ui/pages/canvas_page.dart | 1125+++++--------------------------------------------------------------------------
Mlib/ui/pages/canvas_toolbar/magic_draw_overlay.dart | 509++-----------------------------------------------------------------------------
Mlib/ui/pages/canvas_toolbar/text_tools_overlay.dart | 10+++++++---
Mlib/ui/pages/stylesheet_page.dart | 2+-
Alib/ui/painters/canvas_painter.dart | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mlib/ui/styles/variables.dart | 8++++++++
Alib/ui/widgets/canvas/advanced_color_picker.dart | 645+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alib/ui/widgets/canvas/asset_picker_sheet.dart | 272+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alib/ui/widgets/canvas/canvas_bottom_bar.dart | 143+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alib/ui/widgets/canvas/manipulating_box.dart | 350+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
11 files changed, 1640 insertions(+), 1560 deletions(-)

diff --git a/lib/data/models/canvas_models.dart b/lib/data/models/canvas_models.dart @@ -0,0 +1,56 @@ +import 'dart:ui'; + +class DrawingPoint { + final Offset offset; + final Paint paint; + const DrawingPoint({required this.offset, required this.paint}); + + Map<String, dynamic> toMap() => {'dx': offset.dx, 'dy': offset.dy}; + + factory DrawingPoint.fromMap(Map<String, dynamic> map) { + return DrawingPoint( + offset: Offset(map['dx'] ?? 0, map['dy'] ?? 0), + paint: Paint(), + ); + } +} + +class DrawingPath { + final List<DrawingPoint> points; + final Color color; + final double strokeWidth; + final bool isEraser; + + DrawingPath({ + required this.points, + required this.color, + required this.strokeWidth, + required this.isEraser, + }); + + Map<String, dynamic> toMap() { + return { + 'points': points.map((p) => p.toMap()).toList(), + 'color': color.value, + 'strokeWidth': strokeWidth, + 'isEraser': isEraser, + }; + } + + factory DrawingPath.fromMap(Map<String, dynamic> map) { + return DrawingPath( + points: + (map['points'] as List).map((p) => DrawingPoint.fromMap(p)).toList(), + color: Color(map['color']), + strokeWidth: (map['strokeWidth'] as num).toDouble(), + isEraser: map['isEraser'] ?? false, + ); + } +} + +// Helper to snapshot the entire canvas state for undo/redo +class CanvasState { + final List<Map<String, dynamic>> elements; + final List<DrawingPath> paths; + CanvasState(this.elements, this.paths); +} diff --git a/lib/ui/pages/canvas_page.dart b/lib/ui/pages/canvas_page.dart @@ -13,73 +13,23 @@ import 'package:image_picker/image_picker.dart'; import 'package:undo/undo.dart'; import 'package:share_plus/share_plus.dart'; import 'package:image/image.dart' as img; -import 'package:path/path.dart' as p; + import 'package:creekui/data/repos/project_repo.dart'; import 'package:creekui/data/models/file_model.dart'; import 'package:creekui/services/stylesheet_service.dart'; import 'package:creekui/services/file_service.dart'; import 'package:creekui/services/flask_service.dart'; +import 'package:creekui/ui/styles/variables.dart'; +import 'package:creekui/data/models/canvas_models.dart'; +import 'package:creekui/ui/painters/canvas_painter.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 './canvas_toolbar/magic_draw_overlay.dart'; import './canvas_toolbar/text_tools_overlay.dart'; import 'project_file_page.dart'; -// --- MODELS WITH JSON SUPPORT --- - -class DrawingPoint { - final Offset offset; - final Paint paint; - const DrawingPoint({required this.offset, required this.paint}); - - Map<String, dynamic> toMap() => {'dx': offset.dx, 'dy': offset.dy}; - - factory DrawingPoint.fromMap(Map<String, dynamic> map) { - return DrawingPoint( - offset: Offset(map['dx'] ?? 0, map['dy'] ?? 0), - paint: Paint(), - ); - } -} - -class DrawingPath { - final List<DrawingPoint> points; - final Color color; - final double strokeWidth; - final bool isEraser; - - DrawingPath({ - required this.points, - required this.color, - required this.strokeWidth, - required this.isEraser, - }); - - Map<String, dynamic> toMap() { - return { - 'points': points.map((p) => p.toMap()).toList(), - 'color': color.value, - 'strokeWidth': strokeWidth, - 'isEraser': isEraser, - }; - } - - factory DrawingPath.fromMap(Map<String, dynamic> map) { - return DrawingPath( - points: - (map['points'] as List).map((p) => DrawingPoint.fromMap(p)).toList(), - color: Color(map['color']), - strokeWidth: (map['strokeWidth'] as num).toDouble(), - isEraser: map['isEraser'] ?? false, - ); - } -} - -// Helper to snapshot the entire canvas state for undo/redo -class CanvasState { - final List<Map<String, dynamic>> elements; - final List<DrawingPath> paths; - CanvasState(this.elements, this.paths); -} - class CanvasPage extends StatefulWidget { final int projectId; final double width; @@ -103,13 +53,11 @@ class CanvasPage extends StatefulWidget { } class _CanvasPageState extends State<CanvasPage> { - // --- SERVICES --- final FileService _fileService = FileService(); final ChangeStack _changeStack = ChangeStack(); final ImagePicker _picker = ImagePicker(); final ChangeStack _magicDrawChangeStack = ChangeStack(); - // --- STATE --- bool _hasUnsavedChanges = false; List<Map<String, dynamic>> elements = []; List<DrawingPath> _paths = []; // Keeps normal drawing strokes @@ -142,26 +90,26 @@ class _CanvasPageState extends State<CanvasPage> { late Size _canvasSize; List<Color> _brandColors = []; - // --- TOOLS --- + // Tools bool _isMagicDrawActive = false; bool _isTextToolsActive = false; bool _isMagicPanelDisabled = false; bool _isViewMode = false; - // --- EDITING --- + // Editing bool _isEditingText = false; final TextEditingController _textEditingController = TextEditingController(); final FocusNode _textFocusNode = FocusNode(); - // --- DRAWING --- + // Drawing List<DrawingPoint> _currentPoints = []; - Color _selectedColor = const Color(0xFFFF4081); + Color _selectedColor = Variables.defaultBrush; double _strokeWidth = 10.0; bool _isEraser = false; final GlobalKey _drawingKey = GlobalKey(); - // --- VIEWPORT --- + // Viewport final TransformationController _transformationController = TransformationController(); bool _hasInitializedView = false; @@ -175,9 +123,8 @@ class _CanvasPageState extends State<CanvasPage> { if (widget.existingFile != null) { _loadCanvasFromFile(); } else if (widget.initialImage != null) { - final double imageWidth = _canvasSize.width * 0.4; // 40% of canvas width - final double imageHeight = - _canvasSize.height * 0.4; // 40% of canvas height + 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, @@ -192,9 +139,6 @@ class _CanvasPageState extends State<CanvasPage> { }); _hasUnsavedChanges = true; } - - // Note: injectedMedia handling is done inside _loadCanvasFromFile to ensure - // it happens after file content is loaded, avoiding race conditions. } @override @@ -247,27 +191,25 @@ class _CanvasPageState extends State<CanvasPage> { setState(() => _isAnalyzing = true); try { - debugPrint("🧠 [AI] Starting canvas analysis..."); + debugPrint("[AI] Starting canvas analysis..."); File? imageFile = await _captureCanvasToFile(); if (imageFile == null) return; - // Call the service final description = await FlaskService().describeImage( imagePath: imageFile.path, ); - debugPrint("🤖 [AI] Service Response: $description"); + debugPrint("[AI] Service Response: $description"); if (description != null && mounted) { setState(() { _aiDescription = description; - _isDescriptionExpanded = - false; // Reset to collapsed on new description + _isDescriptionExpanded = false; }); } } catch (e) { - debugPrint("❌ [AI] Analysis Failed: $e"); + debugPrint("[AI] Analysis Failed: $e"); } finally { if (mounted) setState(() => _isAnalyzing = false); } @@ -318,8 +260,7 @@ class _CanvasPageState extends State<CanvasPage> { } } - // --- UNDO/REDO --- - // --- MAGIC DRAW UNDO/REDO --- + // Undo/Redo void _recordMagicChange(List<DrawingPath> oldMagicPaths) { final newMagicPaths = List<DrawingPath>.from(_magicPaths); _magicDrawChangeStack.add( @@ -342,7 +283,7 @@ class _CanvasPageState extends State<CanvasPage> { Change( oldState, () { - // REDO + // Redo setState(() { elements = _deepCopyElements(newState.elements); _paths = List.from(newState.paths); @@ -350,7 +291,7 @@ class _CanvasPageState extends State<CanvasPage> { }); }, (val) { - // UNDO + // Undo setState(() { elements = _deepCopyElements(val.elements); _paths = List.from(val.paths); @@ -366,8 +307,7 @@ class _CanvasPageState extends State<CanvasPage> { return CanvasState(_deepCopyElements(elements), List.from(_paths)); } - // --- ACTIONS --- - + // Actions void _toggleTextTools() { setState(() { _isTextToolsActive = !_isTextToolsActive; @@ -490,22 +430,19 @@ class _CanvasPageState extends State<CanvasPage> { Future<void> _ensureBaseImageCaptured() async { if (_tempBaseImage != null) return; - debugPrint("📸 [Magic Draw] Hiding strokes to capture clean base..."); + debugPrint("[Magic Draw] Hiding strokes to capture clean base..."); - // 1. Hide strokes by updating state setState(() => _isCapturingBase = true); + await Future.delayed( + const Duration(milliseconds: 50), + ); // Wait for frame to render - // 2. Wait for frame to render - await Future.delayed(const Duration(milliseconds: 50)); - - // 3. Capture try { _tempBaseImage = await _captureCanvasToFile(); - debugPrint("✅ [Magic Draw] Base image captured."); + debugPrint("[Magic Draw] Base image captured."); } catch (e) { - debugPrint("❌ [Magic Draw] Failed to capture base: $e"); + debugPrint("[Magic Draw] Failed to capture base: $e"); } finally { - // 4. Show strokes again if (mounted) setState(() => _isCapturingBase = false); } } @@ -558,15 +495,15 @@ class _CanvasPageState extends State<CanvasPage> { final Uint8List pngBytes = byteData.buffer.asUint8List(); - // 4. Convert PNG to JPG using 'image' package - // We do this in a compute isolate ideally, but for simplicity here on main thread + // 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 (Quality 90) + // Encode to JPG final Uint8List jpgBytes = img.encodeJpg(decodedImage, quality: 90); // 5. Save to Temporary File @@ -579,7 +516,6 @@ class _CanvasPageState extends State<CanvasPage> { await imgFile.writeAsBytes(jpgBytes); // 6. Trigger System Share/Save Dialog - // This allows the user to "Save to Device", "Share to Instagram", etc. await Share.shareXFiles([ XFile(filePath, mimeType: 'image/jpeg'), ], text: 'Check out my design created with CreekUI!'); @@ -591,17 +527,13 @@ class _CanvasPageState extends State<CanvasPage> { } } - // =========================================================================== - // SAVE & LOAD LOGIC - // =========================================================================== + // Save & Load Logic Future<void> _handleMagicDrawExit() async { - // Use the helper method you already wrote: _confirmDiscardMagicDraw if (_magicPaths.isNotEmpty && !_isInpainting) { final confirm = await _confirmDiscardMagicDraw(); if (confirm) { _saveAndCloseMagicDraw(); } - // If false, do nothing (stay) } else { _saveAndCloseMagicDraw(); } @@ -728,26 +660,26 @@ class _CanvasPageState extends State<CanvasPage> { String fileName = "Canvas ${DateTime.now().toString().split(' ')[0]}"; bool isNewFile = widget.existingFile == null; - // 1. IF NEW FILE: Ask user for name + // 1. New File: Ask user for name if (isNewFile) { final userFileName = await _showNameDialog(); - if (userFileName == null || userFileName.isEmpty) return; // Cancelled + if (userFileName == null || userFileName.isEmpty) return; fileName = userFileName; } // 2. Generate Preview final String? previewPath = await _generatePreviewImage(); - // 3. Serialize Elements AND Paths (Drawing) to JSON + // 3. Serialize Elements and Drawing to JSON final jsonList = _elementsToJson(elements); final pathsJson = _paths.map((p) => p.toMap()).toList(); final saveData = { 'elements': jsonList, 'paths': pathsJson, - 'width': _canvasSize.width, // Saving Width - 'height': _canvasSize.height, // Saving Height - 'preview_path': previewPath, // Saving Preview Path + 'width': _canvasSize.width, + 'height': _canvasSize.height, + 'preview_path': previewPath, }; final jsonString = jsonEncode(saveData); @@ -803,7 +735,6 @@ class _CanvasPageState extends State<CanvasPage> { setState(() { if (decoded is Map && decoded.containsKey('elements')) { - // New format with dimensions elements = _jsonToElements(decoded['elements']); if (decoded['paths'] != null) { _paths = @@ -813,13 +744,12 @@ class _CanvasPageState extends State<CanvasPage> { } else { _paths = []; } - // LOAD CANVAS DIMENSIONS & RESET VIEW INIT if (decoded['width'] != null && decoded['height'] != null) { _canvasSize = Size( (decoded['width'] as num).toDouble(), (decoded['height'] as num).toDouble(), ); - _hasInitializedView = false; // FORCE RE-CENTERING + _hasInitializedView = false; } } else if (decoded is List) { // Legacy support @@ -830,14 +760,12 @@ class _CanvasPageState extends State<CanvasPage> { _hasUnsavedChanges = false; }); - // Handle Injected Media (e.g. from Share) + // Handle Injected Media if (widget.injectedMedia != null) { final oldState = _getCurrentState(); setState(() { - final double imageWidth = - _canvasSize.width * 0.4; // 40% of canvas width - final double imageHeight = - _canvasSize.height * 0.4; // 40% of canvas height + 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, @@ -933,7 +861,7 @@ class _CanvasPageState extends State<CanvasPage> { minChildSize: 0.5, maxChildSize: 0.9, builder: - (_, controller) => _AssetPickerSheet( + (_, controller) => AssetPickerSheet( projectId: widget.projectId, scrollController: controller, onAddAssets: (List<String> paths) { @@ -950,10 +878,8 @@ class _CanvasPageState extends State<CanvasPage> { final oldState = _getCurrentState(); setState(() { for (var path in paths) { - final double imageWidth = - _canvasSize.width * 0.4; // 40% of canvas width - final double imageHeight = - _canvasSize.height * 0.4; // 40% of canvas height + 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, @@ -973,10 +899,7 @@ class _CanvasPageState extends State<CanvasPage> { _recordChange(oldState); } - // =========================================================================== - // UI BUILDER - // =========================================================================== - + // UI Builder @override Widget build(BuildContext context) { Map<String, dynamic>? selectedEl; @@ -995,7 +918,7 @@ class _CanvasPageState extends State<CanvasPage> { _handleBackNavigation(); }, child: Scaffold( - backgroundColor: const Color(0xFFE0E0E0), + backgroundColor: Variables.canvasBackground, appBar: _buildAppBar(), body: LayoutBuilder( builder: (context, constraints) { @@ -1052,7 +975,7 @@ class _CanvasPageState extends State<CanvasPage> { width: double.infinity, height: double.infinity, decoration: BoxDecoration( - color: Colors.white, + color: Variables.background, boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.15), @@ -1064,7 +987,7 @@ class _CanvasPageState extends State<CanvasPage> { ), ...elements.map((e) { final bool isSelected = selectedId == e['id']; - return _ManipulatingBox( + return ManipulatingBox( key: ValueKey(e['id']), id: e['id'], position: e['position'], @@ -1295,7 +1218,7 @@ class _CanvasPageState extends State<CanvasPage> { vertical: 8, ), decoration: BoxDecoration( - color: Colors.black87, + color: Variables.surfaceDark, borderRadius: BorderRadius.circular(30), boxShadow: [ BoxShadow( @@ -1488,8 +1411,7 @@ class _CanvasPageState extends State<CanvasPage> { ); } - // --- DRAWING HELPERS --- - + // Drawing Helpers void _onPanUpdate(DragUpdateDetails details) { setState(() { _currentPoints.add( @@ -1511,7 +1433,7 @@ class _CanvasPageState extends State<CanvasPage> { setState(() { if (_currentPoints.isNotEmpty) { if (_isMagicDrawActive) { - // ADD TO MAGIC PATHS (Temporary mask) + // Temporary mask final oldMagicPaths = List<DrawingPath>.from(_magicPaths); _magicPaths.add( DrawingPath( @@ -1524,7 +1446,7 @@ class _CanvasPageState extends State<CanvasPage> { _recordMagicChange(oldMagicPaths); _currentPoints = []; } else { - // NORMAL DRAWING + // Normal drawing _paths.add( DrawingPath( points: List.from(_currentPoints), @@ -1541,8 +1463,7 @@ class _CanvasPageState extends State<CanvasPage> { }); } - // --- BACKGROUND REMOVAL LOGIC --- - + // Background Removal Logic void _triggerBgRemovalBanner(String elementId, String imagePath) { _bgRemovalBannerTimer?.cancel(); setState(() { @@ -1569,7 +1490,6 @@ class _CanvasPageState extends State<CanvasPage> { }); try { - // Call service to remove background final newPath = await FlaskService().generateAsset( imagePath: _bgRemovalTargetPath!, ); @@ -1580,8 +1500,7 @@ class _CanvasPageState extends State<CanvasPage> { if (index != -1) { final oldState = _getCurrentState(); setState(() { - elements[index]['content'] = - newPath; // Replace with transparent png + elements[index]['content'] = newPath; _showBgRemovalBanner = false; _isRemovingBg = false; _hasUnsavedChanges = true; @@ -1601,8 +1520,7 @@ class _CanvasPageState extends State<CanvasPage> { } Future<void> _processInpainting(String prompt, String modelId) async { - // Lock editing and hide keyboard - FocusScope.of(context).unfocus(); + FocusScope.of(context).unfocus(); // Lock editing and hide keyboard setState(() => _isInpainting = true); _resetInactivityTimer(); @@ -1626,12 +1544,10 @@ class _CanvasPageState extends State<CanvasPage> { String? newImageUrl; - // --- LOGIC SWITCHING BASED ON MODEL ID --- - - // CASE 1: INPAINTING (When hasImageLayers is TRUE) + // Case 1: Inpainting if (hasImageLayers) { if (modelId == 'inpaint_api') { - // Call API Inpainting + // API Inpainting newImageUrl = await FlaskService().inpaintApiImage( imagePath: _tempBaseImage!.path, maskPath: maskFile.path, @@ -1646,7 +1562,7 @@ class _CanvasPageState extends State<CanvasPage> { ); } } - // CASE 2: SKETCH TO IMAGE (When hasImageLayers is FALSE) + // Case 2: Sketch to Image else { if (modelId == 'sketch_fusion') { newImageUrl = await FlaskService().sketchToImage( @@ -1680,7 +1596,7 @@ class _CanvasPageState extends State<CanvasPage> { _isMagicDrawActive = false; }); - // Trigger Banner for Background Removal (Only if it was a generation, not inpainting) + // Trigger Banner for Background Removal (Only for generation, not inpainting) if (!hasImageLayers) { _triggerBgRemovalBanner(id, newImageUrl); } @@ -1703,7 +1619,7 @@ class _CanvasPageState extends State<CanvasPage> { String _addGeneratedImage(String? newImageUrl) { String id = ''; if (newImageUrl != null) { - debugPrint("✅ Adding generated image to canvas: $newImageUrl"); + debugPrint("Adding generated image to canvas: $newImageUrl"); id = 'gen_${DateTime.now().millisecondsSinceEpoch}'; setState(() { elements.add({ @@ -1720,7 +1636,7 @@ class _CanvasPageState extends State<CanvasPage> { return id; } - // Updated to generate mask as: Base Image + Drawing Strokes + // Generate mask: Base Image + Drawing Strokes Future<File?> _generateMaskImageFromPaths( List<DrawingPath> paths, Size size, @@ -1744,7 +1660,7 @@ class _CanvasPageState extends State<CanvasPage> { canvas: canvas, rect: Rect.fromLTWH(0, 0, size.width, size.height), image: baseImage, - fit: BoxFit.cover, // Or contain, depending on your logic + fit: BoxFit.cover, ); } else { // Fallback to black if no base image (shouldn't happen based on logic) @@ -1754,13 +1670,11 @@ class _CanvasPageState extends State<CanvasPage> { ); } - // 2. Draw Strokes ON TOP of the base image + // 2. Draw Strokes on top of base image for (final path in paths) { final paint = Paint() - ..color = - path - .color // Use the drawing color (e.g. Blue) + ..color = path.color ..strokeWidth = path.strokeWidth ..strokeCap = StrokeCap.round ..strokeJoin = StrokeJoin.round @@ -1881,8 +1795,8 @@ class _CanvasPageState extends State<CanvasPage> { ], ), ), - backgroundColor: Colors.white, - foregroundColor: Colors.black, + backgroundColor: Variables.background, + foregroundColor: Variables.textPrimary, elevation: 0, actions: [ SafeArea( @@ -1929,10 +1843,8 @@ class _CanvasPageState extends State<CanvasPage> { final oldState = _getCurrentState(); setState(() { for (int i = 0; i < images.length; i++) { - final double imageWidth = - _canvasSize.width * 0.4; // 40% of canvas width - final double imageHeight = - _canvasSize.height * 0.4; // 40% of canvas height + 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, @@ -1952,900 +1864,3 @@ class _CanvasPageState extends State<CanvasPage> { } } } - -// ============================================================================= -// MANIPULATING BOX WIDGET -// ============================================================================= - -class _ManipulatingBox extends StatefulWidget { - final String id; - final Offset position; - final Size size; - final double rotation; - final String type; - final String content; - final Map<String, dynamic> styleData; - final bool isSelected; - final bool isEditing; - final double viewScale; - final TransformationController transformationController; - final VoidCallback onTap; - final VoidCallback onDoubleTap; - final VoidCallback onDragStart; - final Function(Offset, Size, double) onUpdate; - final Function(Offset, Size, double) onDragEnd; - final TextEditingController? textController; - final FocusNode? focusNode; - - const _ManipulatingBox({ - Key? key, - required this.id, - required this.position, - required this.size, - required this.rotation, - required this.type, - required this.content, - required this.styleData, - required this.isSelected, - required this.isEditing, - required this.viewScale, - required this.transformationController, - required this.onTap, - required this.onDoubleTap, - required this.onDragStart, - required this.onUpdate, - required this.onDragEnd, - this.textController, - this.focusNode, - }) : super(key: key); - - @override - State<_ManipulatingBox> createState() => _ManipulatingBoxState(); -} - -class _ManipulatingBoxState extends State<_ManipulatingBox> { - late Offset _pos; - late Size _size; - late double _rot; - - // Gesture state - double _initialRotation = 0.0; - double _initialScale = 1.0; - bool _isTwoFingerGesture = false; - Offset _previousFocalPoint = Offset.zero; - - @override - void initState() { - super.initState(); - _updateInternalState(); - } - - @override - void didUpdateWidget(_ManipulatingBox oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.position != oldWidget.position || - widget.size != oldWidget.size || - widget.rotation != oldWidget.rotation) { - _updateInternalState(); - } - } - - void _updateInternalState() { - _pos = widget.position; - _size = widget.size; - _rot = widget.rotation; - } - - @override - Widget build(BuildContext context) { - return ValueListenableBuilder( - valueListenable: widget.transformationController, - builder: (context, matrix, child) { - final double zoom = matrix.getMaxScaleOnAxis(); - final double handleScale = (1 / zoom).clamp(0.2, 5.0); - final double edgeThickness = 18 * handleScale; - - return Positioned( - left: _pos.dx, - top: _pos.dy, - child: Transform.rotate( - angle: _rot, - child: Stack( - clipBehavior: Clip.none, - children: [ - GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: widget.onTap, - onDoubleTap: widget.onDoubleTap, - // Handle both single-finger drag and two-finger rotate/zoom using scale gestures - onScaleStart: (details) { - if (widget.isSelected && !widget.isEditing) { - _previousFocalPoint = details.focalPoint; - if (details.pointerCount == 2) { - // Two-finger gesture: rotate and zoom - _isTwoFingerGesture = true; - _initialRotation = _rot; - _initialScale = _size.width * _size.height; - } else { - // Single-finger gesture: prepare for drag - _isTwoFingerGesture = false; - } - widget.onDragStart(); - } - }, - onScaleUpdate: (details) { - if (widget.isSelected && !widget.isEditing) { - if (_isTwoFingerGesture && details.pointerCount == 2) { - // Two-finger: handle rotation and zoom - final newRotation = _initialRotation + details.rotation; - - // Handle scale (zoom) - maintain aspect ratio - final scaleFactor = details.scale; - final newArea = - _initialScale * scaleFactor * scaleFactor; - final aspectRatio = _size.width / _size.height; - final newWidth = math - .sqrt(newArea * aspectRatio) - .clamp(20.0, 5000.0); - final newHeight = newWidth / aspectRatio; - - setState(() { - _rot = newRotation % (2 * math.pi); - _size = Size(newWidth, newHeight); - }); - - widget.onUpdate(_pos, _size, _rot); - } else if (!_isTwoFingerGesture && - details.pointerCount == 1) { - // Single-finger: handle drag using incremental focal point delta - final currentFocalPoint = details.focalPoint; - final delta = currentFocalPoint - _previousFocalPoint; - // Convert to canvas coordinates by dividing by zoom - final zoom = widget.viewScale; - final scaledDelta = delta / zoom; - // Rotate delta to account for element rotation - final rotated = _rotateVector(scaledDelta, -_rot); - setState(() { - _pos += scaledDelta; - _previousFocalPoint = - currentFocalPoint; // Update for next frame - }); - widget.onUpdate(_pos, _size, _rot); - } - } - }, - onScaleEnd: (details) { - if (widget.isSelected && !widget.isEditing) { - _isTwoFingerGesture = false; - widget.onDragEnd(_pos, _size, _rot); - } - }, - child: Container( - width: _size.width, - height: _size.height, - decoration: - widget.isSelected - ? BoxDecoration( - border: Border.all( - color: Color(0xFFB44CFF), - width: 2 * handleScale, - ), - ) - : null, - child: - widget.type == "file_image" - ? Image.file( - File(widget.content), - fit: BoxFit.contain, - ) - : _buildText(), - ), - ), - - // ====================== - // RESIZE EDGES (4 SIDES) - // ====================== - - // RIGHT edge - if (widget.isSelected && !widget.isEditing) - Positioned( - right: -edgeThickness / 2, - 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, - ), - ), - ), - - // LEFT edge - if (widget.isSelected && !widget.isEditing) - Positioned( - left: -edgeThickness / 2, - 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, - ), - ), - ), - - // TOP edge - if (widget.isSelected && !widget.isEditing) - Positioned( - top: -edgeThickness / 2, - 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, - ), - ), - ), - - // BOTTOM edge - if (widget.isSelected && !widget.isEditing) - Positioned( - bottom: -edgeThickness / 2, - 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, - ), - ), - ), - ], - ), - ), - ); - }, - ); - } - - Widget _buildCircleButton({ - required double size, - required IconData icon, - required double iconSize, - required Function(DragUpdateDetails) onDrag, - }) { - return GestureDetector( - behavior: HitTestBehavior.opaque, - onPanStart: (details) => widget.onDragStart(), - onPanUpdate: onDrag, - onPanEnd: (_) => widget.onDragEnd(_pos, _size, _rot), - child: Container( - width: size, - height: size, - decoration: BoxDecoration( - color: Colors.white, - shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.15), - blurRadius: 4, - offset: Offset(0, 2), - ), - ], - ), - child: Center(child: Icon(icon, size: iconSize, color: Colors.black87)), - ), - ); - } - - Widget _buildText() { - final style = TextStyle( - fontSize: (widget.styleData['style_fontSize'] ?? 24.0) as double, - color: Color(widget.styleData['style_color'] ?? Colors.black.value), - fontFamily: 'GeneralSans', - ); - - if (widget.isEditing) { - return Center( - child: IntrinsicWidth( - child: TextField( - controller: widget.textController, - focusNode: widget.focusNode, - autofocus: true, - maxLines: null, - textAlign: TextAlign.center, - style: style, - decoration: const InputDecoration( - border: InputBorder.none, - isDense: true, - contentPadding: EdgeInsets.zero, - ), - onChanged: (text) { - final span = TextSpan(text: text, style: style); - final tp = TextPainter( - text: span, - textDirection: TextDirection.ltr, - ); - tp.layout(maxWidth: 10000); - setState(() { - _size = Size(tp.width + 40, tp.height + 40); - }); - widget.onUpdate(_pos, _size, _rot); - }, - ), - ), - ); - } - return Center( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Text(widget.content, textAlign: TextAlign.center, style: style), - ), - ); - } - - Widget _buildHandle({ - required double touchSize, - required double visualSize, - required IconData icon, - required Color color, - required Function(Offset) onDrag, - }) { - return GestureDetector( - behavior: HitTestBehavior.opaque, - onPanStart: (_) => widget.onDragStart(), - onPanUpdate: (details) => onDrag(details.delta), - onPanEnd: (_) => widget.onDragEnd(_pos, _size, _rot), - child: Container( - width: touchSize, - height: touchSize, - alignment: Alignment.center, - color: Colors.transparent, - child: Container( - width: visualSize, - height: visualSize, - decoration: BoxDecoration( - color: color, - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), - boxShadow: [ - BoxShadow(color: Colors.black.withOpacity(0.26), blurRadius: 4), - ], - ), - child: Icon(icon, size: visualSize * 0.6, color: Colors.white), - ), - ), - ); - } - - Offset _rotateVector(Offset vector, double angle) { - final cosA = math.cos(angle); - final sinA = math.sin(angle); - return Offset( - vector.dx * cosA - vector.dy * sinA, - vector.dx * sinA + vector.dy * cosA, - ); - } -} - -// --- PAINTER --- -class CanvasPainter extends CustomPainter { - final List<DrawingPath> paths; - final List<DrawingPath> magicPaths; // ADDED: Magic paths for temporary mask - final List<DrawingPoint> currentPoints; - final Color currentColor; - final double currentWidth; - final bool isEraser; - CanvasPainter({ - required this.paths, - this.magicPaths = const [], // ADDED - required this.currentPoints, - required this.currentColor, - required this.currentWidth, - required this.isEraser, - }); - - @override - void paint(Canvas canvas, Size size) { - canvas.saveLayer(Rect.fromLTWH(0, 0, size.width, size.height), Paint()); - - // Draw normal 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 - if (currentPoints.isNotEmpty) { - final paint = - Paint() - ..color = isEraser ? Colors.transparent : 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++) - p.lineTo(currentPoints[i].offset.dx, currentPoints[i].offset.dy); - canvas.drawPath(p, paint); - } - canvas.restore(); - } - - void _drawPath(Canvas canvas, DrawingPath path) { - final paint = - Paint() - ..color = path.isEraser ? Colors.transparent : path.color - ..blendMode = path.isEraser ? BlendMode.clear : BlendMode.srcOver - ..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); - } - } - - @override - bool shouldRepaint(covariant CanvasPainter oldDelegate) => true; -} - -class CanvasBottomBar extends StatelessWidget { - final String? activeItem; - final VoidCallback onMagicDraw; - final VoidCallback onMedia; - final VoidCallback onStylesheet; - final VoidCallback onTools; - final VoidCallback onText; - final VoidCallback onSelect; - final VoidCallback onPlugins; - const CanvasBottomBar({ - super.key, - this.activeItem, - required this.onMagicDraw, - required this.onMedia, - required this.onStylesheet, - required this.onTools, - required this.onText, - required this.onSelect, - required this.onPlugins, - }); - - @override - Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - color: Colors.white, - border: Border(top: BorderSide(color: Colors.grey[200]!)), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 10, - offset: const Offset(0, -5), - ), - ], - ), - child: SafeArea( - top: false, // We don't need top SafeArea as it's a bottom bar - child: Container( - padding: const EdgeInsets.symmetric(vertical: 10), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - children: [ - _BottomBarItem( - label: "Magic Draw", - iconPath: "assets/icons/magic_draw.svg", - onTap: onMagicDraw, - isActive: activeItem == "Magic Draw", - ), - const SizedBox(width: 24), - _BottomBarItem( - label: "Media", - iconPath: "assets/icons/media.svg", - onTap: onMedia, - ), - const SizedBox(width: 24), - _BottomBarItem( - label: "Stylesheet", - iconPath: "assets/icons/stylesheet.svg", - onTap: onStylesheet, - ), - const SizedBox(width: 24), - _BottomBarItem( - label: "Tools", - iconPath: "assets/icons/tools.svg", - onTap: onTools, - ), - const SizedBox(width: 24), - _BottomBarItem( - label: "Text", - iconPath: "assets/icons/text.svg", - onTap: onText, - isActive: activeItem == "Text", - ), - const SizedBox(width: 24), - _BottomBarItem( - label: "Select", - iconPath: "assets/icons/select.svg", - onTap: onSelect, - ), - const SizedBox(width: 24), - _BottomBarItem( - label: "Plugins", - iconPath: "assets/icons/plugins.svg", - onTap: onPlugins, - ), - ], - ), - ), - ), - ), - ); - } -} - -class _BottomBarItem extends StatelessWidget { - final String label; - final String iconPath; - final VoidCallback onTap; - final bool isActive; - const _BottomBarItem({ - required this.label, - required this.iconPath, - required this.onTap, - this.isActive = false, - }); - @override - Widget build(BuildContext context) { - return InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SvgPicture.asset( - iconPath, - width: 24, - colorFilter: ColorFilter.mode( - isActive ? const Color(0xFF27272A) : const Color(0xFF9F9FA9), - BlendMode.srcIn, - ), - ), - const SizedBox(height: 6), - Text( - label, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w500, - color: - isActive - ? const Color(0xFF27272A) - : const Color(0xFF9F9FA9), - ), - ), - ], - ), - ), - ); - } -} - -class _AssetPickerSheet extends StatefulWidget { - final int projectId; - final ScrollController scrollController; - final Function(List<String>) onAddAssets; // Accepts List - - const _AssetPickerSheet({ - required this.projectId, - required this.scrollController, - required this.onAddAssets, - }); - - @override - State<_AssetPickerSheet> createState() => _AssetPickerSheetState(); -} - -class _AssetPickerSheetState extends State<_AssetPickerSheet> { - List<String> _assets = []; - bool _isLoading = true; - Set<String> _selectedPaths = {}; // Supports multi-select - - @override - void initState() { - super.initState(); - _loadAssets(); - } - - Future<void> _loadAssets() async { - try { - final project = await ProjectRepo().getProjectById(widget.projectId); - if (mounted) { - setState(() { - _assets = project?.assetsPath ?? []; - _isLoading = false; - }); - } - } catch (e) { - debugPrint("Error loading assets: $e"); - if (mounted) setState(() => _isLoading = false); - } - } - - Future<File?> _resolveFile(String path) async { - final file = File(path); - if (await file.exists()) return file; - try { - final filename = p.basename(path); - final dir = await getApplicationDocumentsDirectory(); - final fixedPath = '${dir.path}/generated_images/$filename'; - final fixedFile = File(fixedPath); - if (await fixedFile.exists()) return fixedFile; - } catch (e) { - debugPrint("Error resolving file: $e"); - } - return null; - } - - @override - Widget build(BuildContext context) { - return Container( - decoration: const BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), - padding: const EdgeInsets.fromLTRB(20, 12, 20, 0), - child: Column( - children: [ - // Handle - Container( - width: 40, - height: 4, - margin: const EdgeInsets.only(bottom: 20), - decoration: BoxDecoration( - color: Colors.grey[300], - borderRadius: BorderRadius.circular(2), - ), - ), - - // Search Bar - Container( - height: 40, - margin: const EdgeInsets.only(bottom: 16), - decoration: BoxDecoration( - color: Colors.grey[100], - borderRadius: BorderRadius.circular(8), - ), - child: Row( - children: [ - const SizedBox(width: 12), - const Icon(Icons.search, color: Colors.grey), - const SizedBox(width: 8), - const Text( - "Search Stylesheet", - style: TextStyle( - fontFamily: 'GeneralSans', - color: Colors.grey, - ), - ), - ], - ), - ), - - // Category Tabs - Container( - margin: const EdgeInsets.only(bottom: 16), - child: Row( - children: [ - _buildFilterChip("Assets", true), - const SizedBox(width: 12), - _buildFilterChip("Backgrounds & Texture", false), - ], - ), - ), - - // Grid - Expanded( - child: - _isLoading - ? const Center(child: CircularProgressIndicator()) - : _assets.isEmpty - ? Center( - child: Text( - "No assets found in stylesheet", - style: TextStyle(color: Colors.grey[500]), - ), - ) - : Stack( - children: [ - GridView.builder( - controller: widget.scrollController, - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - crossAxisSpacing: 12, - mainAxisSpacing: 12, - childAspectRatio: 1.0, - ), - itemCount: _assets.length, - itemBuilder: (context, index) { - final assetPath = _assets[index]; - final isSelected = _selectedPaths.contains( - assetPath, - ); - - return FutureBuilder<File?>( - future: _resolveFile(assetPath), - builder: (context, snapshot) { - final file = snapshot.data; - return _buildAssetTile( - child: - file != null - ? Image.file(file, fit: BoxFit.cover) - : const Icon( - Icons.broken_image, - color: Colors.grey, - ), - isSelected: isSelected, - onTap: () { - if (file != null) { - setState(() { - if (isSelected) { - _selectedPaths.remove(assetPath); - } else { - _selectedPaths.add(assetPath); - } - }); - } - }, - ); - }, - ); - }, - ), - ], - ), - ), - - // Bottom CTA - SafeArea( - top: false, - child: Container( - width: double.infinity, - margin: const EdgeInsets.only(top: 16, bottom: 16), - child: ElevatedButton( - onPressed: () => widget.onAddAssets(_selectedPaths.toList()), - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF27272A), - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(30), - ), - ), - child: const Text( - "Add to File", - style: TextStyle( - fontFamily: 'GeneralSans', - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - ), - ], - ), - ); - } - - Widget _buildFilterChip(String label, bool isSelected) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: isSelected ? const Color(0xFFF4F4F5) : Colors.transparent, - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: isSelected ? Colors.transparent : Colors.grey[300]!, - ), - ), - child: Text( - label, - style: TextStyle( - fontFamily: 'GeneralSans', - fontSize: 14, - fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, - color: isSelected ? Colors.black : Colors.grey[600], - ), - ), - ); - } - - Widget _buildAssetTile({ - required Widget child, - required VoidCallback onTap, - bool isSelected = false, - }) { - return GestureDetector( - onTap: onTap, - child: Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: isSelected ? Colors.blue : Colors.grey[200]!, - width: isSelected ? 2 : 1, - ), - ), - clipBehavior: Clip.antiAlias, - child: Stack( - fit: StackFit.expand, - children: [ - child, - if (isSelected) - Container( - color: Colors.blue.withOpacity(0.1), - child: const Center( - child: Icon(Icons.check_circle, color: Colors.blue), - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/ui/pages/canvas_toolbar/magic_draw_overlay.dart b/lib/ui/pages/canvas_toolbar/magic_draw_overlay.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart'; -import 'package:flutter_colorpicker/flutter_colorpicker.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import '../../widgets/canvas/advanced_color_picker.dart'; +import '../../styles/variables.dart'; -// --- HELPER CLASS FOR DROPDOWN --- +// Dropdown class AIModelOption { final String id; final String name; @@ -69,7 +70,7 @@ class _MagicDrawToolsState extends State<MagicDrawTools> { bool _showModelMenu = false; late AIModelOption _selectedModel; - // --- 1. LIST FOR INPAINTING (When Image Exists) --- + // 1. List for inpainting (When Image Exists) final List<AIModelOption> _inpaintingModels = [ AIModelOption( id: 'inpaint_standard', @@ -79,7 +80,7 @@ class _MagicDrawToolsState extends State<MagicDrawTools> { AIModelOption(id: 'inpaint_api', name: 'FLUX LoRa Fill', badge: null), ]; - // --- 2. LIST FOR SKETCH TO IMAGE (When No Image Exists) --- + // 2. List for sketch to image (When No Image Exists) final List<AIModelOption> _sketchModels = [ AIModelOption(id: 'sketch_advanced', name: 'Nano Banana', badge: 'Premium'), AIModelOption(id: 'sketch_creative', name: 'FLUX Dev', badge: 'Fast'), @@ -263,7 +264,7 @@ class _MagicDrawToolsState extends State<MagicDrawTools> { children: [ Icon( Icons.star_half, - color: isSelected ? const Color(0xFFD8705D) : Colors.grey, + color: isSelected ? Variables.accentMagic : Colors.grey, size: 18, ), const SizedBox(width: 8), @@ -325,7 +326,7 @@ class _MagicDrawToolsState extends State<MagicDrawTools> { ), child: const Icon( Icons.star_half, - color: Color(0xFFD8705D), + color: Variables.accentMagic, size: 20, ), ), @@ -551,7 +552,7 @@ class _MagicDrawToolsState extends State<MagicDrawTools> { isScrollControlled: true, backgroundColor: Colors.transparent, builder: (context) { - return _AdvancedColorPickerSheet( + return AdvancedColorPickerSheet( initialColor: widget.selectedColor, recentColors: _recentColors, onColorChanged: widget.onColorChanged, @@ -581,497 +582,3 @@ class _TaperedSliderPainter extends CustomPainter { @override bool shouldRepaint(covariant CustomPainter oldDelegate) => false; } - -class _AdvancedColorPickerSheet extends StatefulWidget { - final Color initialColor; - final ValueChanged<Color> onColorChanged; - final List<Color> recentColors; - final List<Color> brandColors; - - const _AdvancedColorPickerSheet({ - required this.initialColor, - required this.onColorChanged, - required this.recentColors, - required this.brandColors, - }); - - @override - State<_AdvancedColorPickerSheet> createState() => - _AdvancedColorPickerSheetState(); -} - -class _AdvancedColorPickerSheetState extends State<_AdvancedColorPickerSheet> - with SingleTickerProviderStateMixin { - late TabController _tabController; - late Color _currentColor; - late List<Color> _brandPalette; - - final TextEditingController _rController = TextEditingController(); - final TextEditingController _gController = TextEditingController(); - final TextEditingController _bController = TextEditingController(); - - @override - void initState() { - super.initState(); - _currentColor = widget.initialColor; - _tabController = TabController(length: 3, vsync: this); - _brandPalette = widget.brandColors; - _updateControllers(); - } - - void _updateControllers() { - _rController.text = _currentColor.red.toString(); - _gController.text = _currentColor.green.toString(); - _bController.text = _currentColor.blue.toString(); - } - - @override - void dispose() { - _tabController.dispose(); - _rController.dispose(); - _gController.dispose(); - _bController.dispose(); - super.dispose(); - } - - void _updateColor(Color color) { - setState(() { - _currentColor = color; - widget.recentColors.removeWhere((c) => c.value == color.value); - widget.recentColors.insert(0, color); - if (widget.recentColors.length > 5) { - widget.recentColors.removeLast(); - } - _updateControllers(); - }); - widget.onColorChanged(color); - } - - @override - Widget build(BuildContext context) { - return Container( - height: MediaQuery.of(context).size.height * 0.85, - decoration: const BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const SizedBox(height: 12), - Center( - child: Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: Colors.grey[300], - borderRadius: BorderRadius.circular(2), - ), - ), - ), - const SizedBox(height: 16), - TabBar( - controller: _tabController, - labelColor: Colors.black, - unselectedLabelColor: Colors.grey, - indicatorColor: Colors.black, - indicatorSize: TabBarIndicatorSize.label, - labelStyle: const TextStyle( - fontWeight: FontWeight.w600, - fontSize: 14, - ), - tabs: const [ - Tab(text: 'Grid'), - Tab(text: 'Spectrum'), - Tab(text: 'Sliders'), - ], - ), - const SizedBox(height: 16), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 6, - ), - decoration: BoxDecoration( - border: Border.all(color: Colors.grey.shade300), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: const [ - Text( - "Hex", - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.bold, - ), - ), - SizedBox(width: 4), - Icon( - Icons.keyboard_arrow_down, - size: 16, - color: Colors.grey, - ), - ], - ), - ), - const SizedBox(width: 8), - Container( - width: 32, - height: 32, - decoration: BoxDecoration( - color: _currentColor, - borderRadius: BorderRadius.circular(6), - border: Border.all(color: Colors.grey.shade200), - ), - ), - const SizedBox(width: 8), - Expanded( - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 8, - ), - decoration: BoxDecoration( - color: Colors.grey.shade50, - borderRadius: BorderRadius.circular(8), - ), - child: Text( - "#${_currentColor.value.toRadixString(16).toUpperCase().substring(2)}", - style: const TextStyle( - fontWeight: FontWeight.w600, - fontSize: 14, - ), - overflow: TextOverflow.ellipsis, - ), - ), - ), - const SizedBox(width: 8), - const Icon(Icons.colorize, size: 20, color: Colors.black54), - ], - ), - ), - const SizedBox(height: 16), - const Divider(height: 1), - Expanded( - child: TabBarView( - controller: _tabController, - physics: const NeverScrollableScrollPhysics(), - children: [ - _buildGridTab(), - _buildSpectrumTab(), - _buildSlidersTab(), - ], - ), - ), - _buildSharedFooter(), - ], - ), - ); - } - - List<Color> _generateColorGrid() { - List<Color> colors = []; - for (int i = 0; i < 9; i++) { - double lightness = 1.0 - (i / 8); - colors.add(HSLColor.fromAHSL(1.0, 0.0, 0.0, lightness).toColor()); - } - final int hueSteps = 9; - final int shadeSteps = 7; - for (int shade = 0; shade < shadeSteps; shade++) { - for (int hueStep = 0; hueStep < hueSteps; hueStep++) { - double hue = (hueStep / hueSteps) * 360; - double saturation = 0.5 + (shade / shadeSteps) * 0.5; - double lightness = 0.8 - (shade / shadeSteps) * 0.5; - colors.add( - HSLColor.fromAHSL(1.0, hue, saturation, lightness).toColor(), - ); - } - } - return colors; - } - - // --- TAB 1: GRID --- - Widget _buildGridTab() { - final gridColors = _generateColorGrid(); - - return Column( - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), - child: GridView.builder( - physics: const BouncingScrollPhysics(), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 9, - crossAxisSpacing: 0, - mainAxisSpacing: 0, - childAspectRatio: 1.0, - ), - itemCount: gridColors.length, - itemBuilder: (context, index) { - final color = gridColors[index]; - final isSelected = _currentColor.value == color.value; - - return GestureDetector( - onTap: () => _updateColor(color), - child: Stack( - alignment: Alignment.center, - children: [ - Container( - decoration: BoxDecoration( - color: color, - border: Border.all( - color: Colors.black.withOpacity(0.05), - width: 0.5, - ), - ), - ), - if (isSelected) - Container( - width: 24, - height: 24, - decoration: BoxDecoration( - color: color, - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 3), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.2), - blurRadius: 4, - spreadRadius: 1, - ), - ], - ), - ), - ], - ), - ); - }, - ), - ), - ), - ], - ); - } - - // --- TAB 2: SPECTRUM --- - Widget _buildSpectrumTab() { - return Padding( - padding: const EdgeInsets.all(20), - child: LayoutBuilder( - builder: (context, constraints) { - if (constraints.maxHeight < 150) { - return const Center(child: Text("Rotate device")); - } - return SizedBox( - width: constraints.maxWidth, - height: constraints.maxHeight, - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: ColorPicker( - pickerColor: _currentColor, - onColorChanged: _updateColor, - enableAlpha: false, - labelTypes: const [], - displayThumbColor: true, - paletteType: PaletteType.hsvWithHue, - pickerAreaHeightPercent: 0.8, - hexInputBar: false, - ), - ), - ); - }, - ), - ); - } - - // --- TAB 3: SLIDERS --- - Widget _buildSlidersTab() { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24), - child: SingleChildScrollView( - child: Column( - children: [ - _buildSingleRGBSlider("Red", Colors.red, _currentColor.red, (v) { - _updateColor(_currentColor.withRed(v.toInt())); - }), - const SizedBox(height: 24), - _buildSingleRGBSlider("Green", Colors.green, _currentColor.green, ( - v, - ) { - _updateColor(_currentColor.withGreen(v.toInt())); - }), - const SizedBox(height: 24), - _buildSingleRGBSlider("Blue", Colors.blue, _currentColor.blue, (v) { - _updateColor(_currentColor.withBlue(v.toInt())); - }), - ], - ), - ), - ); - } - - Widget _buildSingleRGBSlider( - String label, - Color activeColor, - int value, - ValueChanged<double> onChanged, - ) { - return Row( - children: [ - Expanded( - child: SizedBox( - height: 30, - child: SliderTheme( - data: SliderThemeData( - trackHeight: 6, - activeTrackColor: activeColor, - inactiveTrackColor: activeColor.withOpacity(0.15), - thumbColor: Colors.white, - thumbShape: const RoundSliderThumbShape( - enabledThumbRadius: 14, - elevation: 4, - ), - overlayShape: const RoundSliderOverlayShape(overlayRadius: 24), - trackShape: const RoundedRectSliderTrackShape(), - ), - child: Slider( - value: value.toDouble(), - min: 0, - max: 255, - onChanged: onChanged, - ), - ), - ), - ), - const SizedBox(width: 12), - Container( - width: 50, - height: 36, - decoration: BoxDecoration( - border: Border.all(color: Colors.grey.shade300), - borderRadius: BorderRadius.circular(8), - color: Colors.white, - ), - alignment: Alignment.center, - child: Text( - value.toString(), - style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14), - ), - ), - ], - ); - } - - // --- FOOTER --- - Widget _buildSharedFooter() { - return SafeArea( - top: false, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), - decoration: BoxDecoration( - color: Colors.white, - border: Border(top: BorderSide(color: Colors.grey.shade100)), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - "Recently used", - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: Colors.grey, - ), - ), - const SizedBox(height: 10), - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: - widget.recentColors - .map((c) => _buildColorCircle(c)) - .toList(), - ), - ), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text( - "Brand Palette", - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: Colors.grey, - ), - ), - Text( - "Edit", - style: TextStyle( - fontSize: 12, - color: Colors.blue[700], - fontWeight: FontWeight.bold, - ), - ), - ], - ), - const SizedBox(height: 10), - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - _buildAddButton(), - const SizedBox(width: 12), - ..._brandPalette.map((c) => _buildColorCircle(c)).toList(), - ], - ), - ), - ], - ), - ), - ); - } - - Widget _buildColorCircle(Color color) { - return GestureDetector( - onTap: () => _updateColor(color), - child: Container( - width: 36, - height: 36, - margin: const EdgeInsets.only(right: 12), - decoration: BoxDecoration( - color: color, - shape: BoxShape.circle, - border: Border.all(color: Colors.grey.shade200, width: 1), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - ), - ); - } - - Widget _buildAddButton() { - return Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: Colors.grey[100], - shape: BoxShape.circle, - border: Border.all(color: Colors.grey.shade300), - ), - child: const Icon(Icons.add, size: 20, color: Colors.black54), - ); - } -} diff --git a/lib/ui/pages/canvas_toolbar/text_tools_overlay.dart b/lib/ui/pages/canvas_toolbar/text_tools_overlay.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_colorpicker/flutter_colorpicker.dart'; +import '../../styles/variables.dart'; class TextToolsOverlay extends StatelessWidget { final bool isActive; @@ -53,7 +54,10 @@ class TextToolsOverlay extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ IconButton( - icon: const Icon(Icons.check_circle, color: Colors.black87), + icon: const Icon( + Icons.check_circle, + color: Variables.textPrimary, + ), onPressed: onClose, tooltip: "Done", ), @@ -63,7 +67,7 @@ class TextToolsOverlay extends StatelessWidget { IconButton( icon: const Icon( Icons.add_circle_outline, - color: Colors.black87, + color: Variables.textPrimary, ), onPressed: onAddText, tooltip: "Add Text", @@ -87,7 +91,7 @@ class TextToolsOverlay extends StatelessWidget { thumbShape: const RoundSliderThumbShape( enabledThumbRadius: 6, ), - activeTrackColor: Colors.black87, + activeTrackColor: Variables.textPrimary, inactiveTrackColor: Colors.grey[200], thumbColor: Colors.black, overlayShape: SliderComponentShape.noOverlay, diff --git a/lib/ui/pages/stylesheet_page.dart b/lib/ui/pages/stylesheet_page.dart @@ -686,7 +686,7 @@ class _StylesheetPageState extends State<StylesheetPage> { }, ), ), - const SizedBox(height: 16), + const SizedBox(height: 24), ], ); } diff --git a/lib/ui/painters/canvas_painter.dart b/lib/ui/painters/canvas_painter.dart @@ -0,0 +1,80 @@ +import 'dart:ui' as ui; +import 'package:flutter/material.dart'; +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; + + CanvasPainter({ + required this.paths, + this.magicPaths = const [], + required this.currentPoints, + required this.currentColor, + required this.currentWidth, + required this.isEraser, + }); + + @override + void paint(Canvas canvas, Size size) { + canvas.saveLayer(Rect.fromLTWH(0, 0, size.width, size.height), Paint()); + + // Draw normal 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 + if (currentPoints.isNotEmpty) { + final paint = + Paint() + ..color = isEraser ? Colors.transparent : 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++) { + p.lineTo(currentPoints[i].offset.dx, currentPoints[i].offset.dy); + } + canvas.drawPath(p, paint); + } + canvas.restore(); + } + + void _drawPath(Canvas canvas, DrawingPath path) { + final paint = + Paint() + ..color = path.isEraser ? Colors.transparent : path.color + ..blendMode = path.isEraser ? BlendMode.clear : BlendMode.srcOver + ..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); + } + } + + @override + bool shouldRepaint(covariant CanvasPainter oldDelegate) => true; +} diff --git a/lib/ui/styles/variables.dart b/lib/ui/styles/variables.dart @@ -16,6 +16,14 @@ class Variables { static const Color backgroundDark = Color(0xFF18181B); static const Color borderDark = Color(0xFF3F3F46); + // Canvas Specific + static const Color canvasBackground = Color(0xFFE0E0E0); + static const Color selectionBorder = Color(0xFFB44CFF); + static const Color accentMagic = Color(0xFFD8705D); + static const Color defaultBrush = Color(0xFFFF4081); + static const Color iconActive = Color(0xFF27272A); + static const Color iconInactive = Color(0xFF9F9FA9); + // Dimensions static const double fontSizeHeader = 20.0; static const double lineHeightHeader = 24.0; diff --git a/lib/ui/widgets/canvas/advanced_color_picker.dart b/lib/ui/widgets/canvas/advanced_color_picker.dart @@ -0,0 +1,645 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_colorpicker/flutter_colorpicker.dart'; +import 'package:creekui/ui/styles/variables.dart'; + +class AdvancedColorPickerSheet extends StatefulWidget { + final Color initialColor; + final ValueChanged<Color> onColorChanged; + final List<Color> recentColors; + final List<Color> brandColors; + + const AdvancedColorPickerSheet({ + super.key, + required this.initialColor, + required this.onColorChanged, + required this.recentColors, + required this.brandColors, + }); + + @override + State<AdvancedColorPickerSheet> createState() => + _AdvancedColorPickerSheetState(); +} + +class _AdvancedColorPickerSheetState extends State<AdvancedColorPickerSheet> + with SingleTickerProviderStateMixin { + late TabController _tabController; + late Color _currentColor; + late HSVColor _currentHsv; + late List<Color> _brandPalette; + + final TextEditingController _rController = TextEditingController(); + final TextEditingController _gController = TextEditingController(); + final TextEditingController _bController = TextEditingController(); + + @override + void initState() { + super.initState(); + _currentColor = widget.initialColor; + _currentHsv = HSVColor.fromColor(widget.initialColor); + _tabController = TabController(length: 3, vsync: this); + _brandPalette = widget.brandColors; + _updateControllers(); + } + + void _updateControllers() { + _rController.text = _currentColor.red.toString(); + _gController.text = _currentColor.green.toString(); + _bController.text = _currentColor.blue.toString(); + } + + @override + void dispose() { + _tabController.dispose(); + _rController.dispose(); + _gController.dispose(); + _bController.dispose(); + super.dispose(); + } + + // Updates the active color and canvas immediately (Live Preview) + void _handleColorChange(Color color) { + setState(() { + _currentColor = color; + _currentHsv = HSVColor.fromColor(color); + _updateControllers(); + }); + widget.onColorChanged(color); + } + + // Handler for Spectrum Tab to avoid HSV->RGB->HSV conversion loss + void _handleHsvChange(HSVColor hsv) { + setState(() { + _currentHsv = hsv; + _currentColor = hsv.toColor(); + _updateControllers(); + }); + widget.onColorChanged(_currentColor); + } + + // Saves the current color to the "Recently Used" list + void _saveToRecent() { + setState(() { + widget.recentColors.removeWhere((c) => c.value == _currentColor.value); + widget.recentColors.insert(0, _currentColor); + if (widget.recentColors.length > 5) { + widget.recentColors.removeLast(); + } + }); + } + + @override + Widget build(BuildContext context) { + return Container( + height: MediaQuery.of(context).size.height * 0.85, + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 12), + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 16), + TabBar( + controller: _tabController, + labelColor: Colors.black, + unselectedLabelColor: Colors.grey, + indicatorColor: Colors.black, + indicatorSize: TabBarIndicatorSize.label, + labelStyle: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + ), + tabs: const [ + Tab(text: 'Grid'), + Tab(text: 'Spectrum'), + Tab(text: 'Sliders'), + ], + ), + const SizedBox(height: 16), + // Header: Hex Code & Preview + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 6, + ), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: const [ + Text( + "Hex", + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + ), + ), + SizedBox(width: 4), + Icon( + Icons.keyboard_arrow_down, + size: 16, + color: Colors.grey, + ), + ], + ), + ), + const SizedBox(width: 8), + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: _currentColor, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: Colors.grey.shade200), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + "#${_currentColor.value.toRadixString(16).toUpperCase().substring(2)}", + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ), + const SizedBox(width: 8), + const Icon(Icons.colorize, size: 20, color: Colors.black54), + ], + ), + ), + const SizedBox(height: 16), + const Divider(height: 1), + // Tab Views + Expanded( + child: TabBarView( + controller: _tabController, + physics: const NeverScrollableScrollPhysics(), + children: [ + _buildGridTab(), + _buildSpectrumTab(), + _buildSlidersTab(), + ], + ), + ), + _buildSharedFooter(), + ], + ), + ); + } + + // Tab 1: Grid + List<Color> _generateColorGrid() { + List<Color> colors = []; + for (int i = 0; i < 9; i++) { + double lightness = 1.0 - (i / 8); + colors.add(HSLColor.fromAHSL(1.0, 0.0, 0.0, lightness).toColor()); + } + final int hueSteps = 9; + final int shadeSteps = 7; + for (int shade = 0; shade < shadeSteps; shade++) { + for (int hueStep = 0; hueStep < hueSteps; hueStep++) { + double hue = (hueStep / hueSteps) * 360; + double saturation = 0.5 + (shade / shadeSteps) * 0.5; + double lightness = 0.8 - (shade / shadeSteps) * 0.5; + colors.add( + HSLColor.fromAHSL(1.0, hue, saturation, lightness).toColor(), + ); + } + } + return colors; + } + + Widget _buildGridTab() { + final gridColors = _generateColorGrid(); + + return Column( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: GridView.builder( + physics: const BouncingScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 9, + crossAxisSpacing: 0, + mainAxisSpacing: 0, + childAspectRatio: 1.0, + ), + itemCount: gridColors.length, + itemBuilder: (context, index) { + final color = gridColors[index]; + final isSelected = _currentColor.value == color.value; + return GestureDetector( + onTap: () { + _handleColorChange(color); + _saveToRecent(); // Save immediately on tap + }, + child: Stack( + alignment: Alignment.center, + children: [ + Container( + decoration: BoxDecoration( + color: color, + border: Border.all( + color: Colors.black.withOpacity(0.05), + width: 0.5, + ), + ), + ), + if (isSelected) + Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 3), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.2), + blurRadius: 4, + spreadRadius: 1, + ), + ], + ), + ), + ], + ), + ); + }, + ), + ), + ), + ], + ); + } + + // Tab 2: Spectrum + Widget _buildSpectrumTab() { + return Padding( + padding: const EdgeInsets.all(20), + child: Column( + children: [ + // Saturation / Value Box + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: LayoutBuilder( + builder: (context, constraints) { + return GestureDetector( + onPanUpdate: (details) { + RenderBox box = context.findRenderObject() as RenderBox; + Offset localOffset = box.globalToLocal( + details.globalPosition, + ); + + double saturation = (localOffset.dx / + constraints.maxWidth) + .clamp(0.0, 1.0); + double value = + 1.0 - + (localOffset.dy / constraints.maxHeight).clamp( + 0.0, + 1.0, + ); + + _handleHsvChange( + _currentHsv.withSaturation(saturation).withValue(value), + ); + }, + onPanEnd: (_) => _saveToRecent(), + child: Stack( + children: [ + // Base Hue Color + Container( + color: + HSVColor.fromAHSV( + 1.0, + _currentHsv.hue, + 1.0, + 1.0, + ).toColor(), + ), + // Gradient: White -> Transparent (Saturation) + Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [Colors.white, Colors.transparent], + begin: Alignment.centerLeft, + end: Alignment.centerRight, + ), + ), + ), + // Gradient: Transparent -> Black (Value/Brightness) + Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [Colors.transparent, Colors.black], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + ), + // Selector Circle + Positioned( + left: + _currentHsv.saturation * constraints.maxWidth - + 10, + top: + (1 - _currentHsv.value) * constraints.maxHeight - + 10, + child: Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: _currentColor, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + boxShadow: const [ + BoxShadow(color: Colors.black26, blurRadius: 4), + ], + ), + ), + ), + ], + ), + ); + }, + ), + ), + ), + const SizedBox(height: 24), + // Hue Slider + SizedBox( + height: 40, + child: Stack( + alignment: Alignment.center, + children: [ + // Rainbow Background + Container( + height: 12, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + gradient: const LinearGradient( + colors: [ + Color(0xFFFF0000), + Color(0xFFFFFF00), + Color(0xFF00FF00), + Color(0xFF00FFFF), + Color(0xFF0000FF), + Color(0xFFFF00FF), + Color(0xFFFF0000), + ], + ), + ), + ), + SliderTheme( + data: SliderThemeData( + trackHeight: 12, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 14, + elevation: 4, + ), + overlayColor: Colors.transparent, + thumbColor: Colors.white, + activeTrackColor: Colors.transparent, + inactiveTrackColor: Colors.transparent, + ), + child: Slider( + value: _currentHsv.hue, + min: 0.0, + max: 360.0, + onChanged: (newHue) { + _handleHsvChange(_currentHsv.withHue(newHue)); + }, + onChangeEnd: (_) => _saveToRecent(), + ), + ), + ], + ), + ), + const SizedBox(height: 10), + ], + ), + ); + } + + // Tab 3: Sliders + Widget _buildSlidersTab() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24), + child: SingleChildScrollView( + child: Column( + children: [ + _buildSingleRGBSlider("Red", Colors.red, _currentColor.red, (v) { + _handleColorChange(_currentColor.withRed(v.toInt())); + }), + const SizedBox(height: 24), + _buildSingleRGBSlider("Green", Colors.green, _currentColor.green, ( + v, + ) { + _handleColorChange(_currentColor.withGreen(v.toInt())); + }), + const SizedBox(height: 24), + _buildSingleRGBSlider("Blue", Colors.blue, _currentColor.blue, (v) { + _handleColorChange(_currentColor.withBlue(v.toInt())); + }), + ], + ), + ), + ); + } + + Widget _buildSingleRGBSlider( + String label, + Color activeColor, + int value, + ValueChanged<double> onChanged, + ) { + return Row( + children: [ + Expanded( + child: SizedBox( + height: 30, + child: SliderTheme( + data: SliderThemeData( + trackHeight: 6, + activeTrackColor: activeColor, + inactiveTrackColor: activeColor.withOpacity(0.15), + thumbColor: Colors.white, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 14, + elevation: 4, + ), + overlayShape: const RoundSliderOverlayShape(overlayRadius: 24), + trackShape: const RoundedRectSliderTrackShape(), + ), + child: Slider( + value: value.toDouble(), + min: 0, + max: 255, + onChanged: onChanged, + onChangeEnd: (_) => _saveToRecent(), + ), + ), + ), + ), + const SizedBox(width: 12), + Container( + width: 50, + height: 36, + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(8), + color: Colors.white, + ), + alignment: Alignment.center, + child: Text( + value.toString(), + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14), + ), + ), + ], + ); + } + + // Footer + Widget _buildSharedFooter() { + return SafeArea( + top: false, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + decoration: BoxDecoration( + color: Colors.white, + border: Border(top: BorderSide(color: Colors.grey.shade100)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "Recently used", + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.grey, + ), + ), + const SizedBox(height: 10), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: + widget.recentColors + .map((c) => _buildColorCircle(c)) + .toList(), + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + "Brand Palette", + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.grey, + ), + ), + Text( + "Edit", + style: TextStyle( + fontSize: 12, + color: Colors.blue[700], + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 10), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _buildAddButton(), + const SizedBox(width: 12), + ..._brandPalette.map((c) => _buildColorCircle(c)).toList(), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildColorCircle(Color color) { + return GestureDetector( + onTap: () { + _handleColorChange(color); + _saveToRecent(); + }, + child: Container( + width: 36, + height: 36, + margin: const EdgeInsets.only(right: 12), + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all(color: Colors.grey.shade200, width: 1), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + ), + ); + } + + Widget _buildAddButton() { + return Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: Colors.grey[100], + shape: BoxShape.circle, + border: Border.all(color: Colors.grey.shade300), + ), + child: const Icon(Icons.add, size: 20, color: Colors.black54), + ); + } +} diff --git a/lib/ui/widgets/canvas/asset_picker_sheet.dart b/lib/ui/widgets/canvas/asset_picker_sheet.dart @@ -0,0 +1,272 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart' as p; +import 'package:creekui/data/repos/project_repo.dart'; +import 'package:creekui/ui/styles/variables.dart'; + +class AssetPickerSheet extends StatefulWidget { + final int projectId; + final ScrollController scrollController; + final Function(List<String>) onAddAssets; + + const AssetPickerSheet({ + super.key, + required this.projectId, + required this.scrollController, + required this.onAddAssets, + }); + + @override + State<AssetPickerSheet> createState() => _AssetPickerSheetState(); +} + +class _AssetPickerSheetState extends State<AssetPickerSheet> { + List<String> _assets = []; + bool _isLoading = true; + Set<String> _selectedPaths = {}; + + @override + void initState() { + super.initState(); + _loadAssets(); + } + + Future<void> _loadAssets() async { + try { + final project = await ProjectRepo().getProjectById(widget.projectId); + if (mounted) { + setState(() { + _assets = project?.assetsPath ?? []; + _isLoading = false; + }); + } + } catch (e) { + debugPrint("Error loading assets: $e"); + if (mounted) setState(() => _isLoading = false); + } + } + + Future<File?> _resolveFile(String path) async { + final file = File(path); + if (await file.exists()) return file; + try { + final filename = p.basename(path); + final dir = await getApplicationDocumentsDirectory(); + final fixedPath = '${dir.path}/generated_images/$filename'; + final fixedFile = File(fixedPath); + if (await fixedFile.exists()) return fixedFile; + } catch (e) { + debugPrint("Error resolving file: $e"); + } + return null; + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration( + color: Variables.background, + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + padding: const EdgeInsets.fromLTRB(20, 12, 20, 0), + child: Column( + children: [ + // Handle + Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + color: Variables.borderSubtle, + borderRadius: BorderRadius.circular(2), + ), + ), + + // Search Bar + Container( + height: 40, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Variables.surfaceSubtle, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const SizedBox(width: 12), + const Icon(Icons.search, color: Variables.textSecondary), + const SizedBox(width: 8), + const Text( + "Search Stylesheet", + style: TextStyle( + fontFamily: 'GeneralSans', + color: Variables.textSecondary, + ), + ), + ], + ), + ), + + // Category Tabs + Container( + margin: const EdgeInsets.only(bottom: 16), + child: Row( + children: [ + _buildFilterChip("Assets", true), + const SizedBox(width: 12), + _buildFilterChip("Backgrounds & Texture", false), + ], + ), + ), + + // Grid + Expanded( + child: + _isLoading + ? const Center(child: CircularProgressIndicator()) + : _assets.isEmpty + ? Center( + child: Text( + "No assets found in stylesheet", + style: TextStyle(color: Variables.textSecondary), + ), + ) + : Stack( + children: [ + GridView.builder( + controller: widget.scrollController, + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + childAspectRatio: 1.0, + ), + itemCount: _assets.length, + itemBuilder: (context, index) { + final assetPath = _assets[index]; + final isSelected = _selectedPaths.contains( + assetPath, + ); + + return FutureBuilder<File?>( + future: _resolveFile(assetPath), + builder: (context, snapshot) { + final file = snapshot.data; + return _buildAssetTile( + child: + file != null + ? Image.file(file, fit: BoxFit.cover) + : const Icon( + Icons.broken_image, + color: Colors.grey, + ), + isSelected: isSelected, + onTap: () { + if (file != null) { + setState(() { + if (isSelected) { + _selectedPaths.remove(assetPath); + } else { + _selectedPaths.add(assetPath); + } + }); + } + }, + ); + }, + ); + }, + ), + ], + ), + ), + + // Bottom CTA + SafeArea( + top: false, + child: Container( + width: double.infinity, + margin: const EdgeInsets.only(top: 16, bottom: 16), + child: ElevatedButton( + onPressed: () => widget.onAddAssets(_selectedPaths.toList()), + style: ElevatedButton.styleFrom( + backgroundColor: Variables.surfaceDark, + foregroundColor: Variables.textWhite, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(30), + ), + ), + child: const Text( + "Add to File", + style: TextStyle( + fontFamily: 'GeneralSans', + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildFilterChip(String label, bool isSelected) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: isSelected ? Variables.surfaceSubtle : Colors.transparent, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: isSelected ? Colors.transparent : Variables.borderSubtle, + ), + ), + child: Text( + label, + style: TextStyle( + fontFamily: 'GeneralSans', + fontSize: 14, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, + color: isSelected ? Variables.textPrimary : Variables.textSecondary, + ), + ), + ); + } + + Widget _buildAssetTile({ + required Widget child, + required VoidCallback onTap, + bool isSelected = false, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected ? Colors.blue : Variables.borderSubtle, + width: isSelected ? 2 : 1, + ), + ), + clipBehavior: Clip.antiAlias, + child: Stack( + fit: StackFit.expand, + children: [ + child, + if (isSelected) + Container( + color: Colors.blue.withOpacity(0.1), + child: const Center( + child: Icon(Icons.check_circle, color: Colors.blue), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/ui/widgets/canvas/canvas_bottom_bar.dart b/lib/ui/widgets/canvas/canvas_bottom_bar.dart @@ -0,0 +1,143 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:creekui/ui/styles/variables.dart'; + +class CanvasBottomBar extends StatelessWidget { + final String? activeItem; + final VoidCallback onMagicDraw; + final VoidCallback onMedia; + final VoidCallback onStylesheet; + final VoidCallback onTools; + final VoidCallback onText; + final VoidCallback onSelect; + final VoidCallback onPlugins; + const CanvasBottomBar({ + super.key, + this.activeItem, + required this.onMagicDraw, + required this.onMedia, + required this.onStylesheet, + required this.onTools, + required this.onText, + required this.onSelect, + required this.onPlugins, + }); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Variables.background, + border: Border(top: BorderSide(color: Variables.borderSubtle)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, -5), + ), + ], + ), + child: SafeArea( + top: false, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 10), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + _BottomBarItem( + label: "Magic Draw", + iconPath: "assets/icons/magic_draw.svg", + onTap: onMagicDraw, + isActive: activeItem == "Magic Draw", + ), + const SizedBox(width: 24), + _BottomBarItem( + label: "Media", + iconPath: "assets/icons/media.svg", + onTap: onMedia, + ), + const SizedBox(width: 24), + _BottomBarItem( + label: "Stylesheet", + iconPath: "assets/icons/stylesheet.svg", + onTap: onStylesheet, + ), + const SizedBox(width: 24), + _BottomBarItem( + label: "Tools", + iconPath: "assets/icons/tools.svg", + onTap: onTools, + ), + const SizedBox(width: 24), + _BottomBarItem( + label: "Text", + iconPath: "assets/icons/text.svg", + onTap: onText, + isActive: activeItem == "Text", + ), + const SizedBox(width: 24), + _BottomBarItem( + label: "Select", + iconPath: "assets/icons/select.svg", + onTap: onSelect, + ), + const SizedBox(width: 24), + _BottomBarItem( + label: "Plugins", + iconPath: "assets/icons/plugins.svg", + onTap: onPlugins, + ), + ], + ), + ), + ), + ), + ); + } +} + +class _BottomBarItem extends StatelessWidget { + final String label; + final String iconPath; + final VoidCallback onTap; + final bool isActive; + const _BottomBarItem({ + required this.label, + required this.iconPath, + required this.onTap, + this.isActive = false, + }); + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SvgPicture.asset( + iconPath, + width: 24, + colorFilter: ColorFilter.mode( + isActive ? Variables.iconActive : Variables.iconInactive, + BlendMode.srcIn, + ), + ), + const SizedBox(height: 6), + Text( + label, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: isActive ? Variables.iconActive : Variables.iconInactive, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/ui/widgets/canvas/manipulating_box.dart b/lib/ui/widgets/canvas/manipulating_box.dart @@ -0,0 +1,350 @@ +import 'dart:io'; +import 'dart:math' as math; +import 'package:flutter/material.dart'; +import 'package:creekui/ui/styles/variables.dart'; + +class ManipulatingBox extends StatefulWidget { + final String id; + final Offset position; + final Size size; + final double rotation; + final String type; + final String content; + final Map<String, dynamic> styleData; + final bool isSelected; + final bool isEditing; + final double viewScale; + final TransformationController transformationController; + final VoidCallback onTap; + final VoidCallback onDoubleTap; + final VoidCallback onDragStart; + final Function(Offset, Size, double) onUpdate; + final Function(Offset, Size, double) onDragEnd; + final TextEditingController? textController; + final FocusNode? focusNode; + + const ManipulatingBox({ + Key? key, + required this.id, + required this.position, + required this.size, + required this.rotation, + required this.type, + required this.content, + required this.styleData, + required this.isSelected, + required this.isEditing, + required this.viewScale, + required this.transformationController, + required this.onTap, + required this.onDoubleTap, + required this.onDragStart, + required this.onUpdate, + required this.onDragEnd, + this.textController, + this.focusNode, + }) : super(key: key); + + @override + State<ManipulatingBox> createState() => _ManipulatingBoxState(); +} + +class _ManipulatingBoxState extends State<ManipulatingBox> { + late Offset _pos; + late Size _size; + late double _rot; + + // Gesture state + double _initialRotation = 0.0; + double _initialScale = 1.0; + bool _isTwoFingerGesture = false; + Offset _previousFocalPoint = Offset.zero; + + @override + void initState() { + super.initState(); + _updateInternalState(); + } + + @override + void didUpdateWidget(ManipulatingBox oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.position != oldWidget.position || + widget.size != oldWidget.size || + widget.rotation != oldWidget.rotation) { + _updateInternalState(); + } + } + + void _updateInternalState() { + _pos = widget.position; + _size = widget.size; + _rot = widget.rotation; + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: widget.transformationController, + builder: (context, matrix, child) { + final double zoom = matrix.getMaxScaleOnAxis(); + final double handleScale = (1 / zoom).clamp(0.2, 5.0); + final double edgeThickness = 18 * handleScale; + + return Positioned( + left: _pos.dx, + top: _pos.dy, + child: Transform.rotate( + angle: _rot, + child: Stack( + clipBehavior: Clip.none, + children: [ + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onTap, + onDoubleTap: widget.onDoubleTap, + // Handle both single-finger drag and two-finger rotate/zoom using scale gestures + onScaleStart: (details) { + if (widget.isSelected && !widget.isEditing) { + _previousFocalPoint = details.focalPoint; + if (details.pointerCount == 2) { + // Two-finger gesture: rotate and zoom + _isTwoFingerGesture = true; + _initialRotation = _rot; + _initialScale = _size.width * _size.height; + } else { + // Single-finger gesture: prepare for drag + _isTwoFingerGesture = false; + } + widget.onDragStart(); + } + }, + onScaleUpdate: (details) { + if (widget.isSelected && !widget.isEditing) { + if (_isTwoFingerGesture && details.pointerCount == 2) { + // Two-finger: handle rotation and zoom + final newRotation = _initialRotation + details.rotation; + + // Handle scale (zoom) - maintain aspect ratio + final scaleFactor = details.scale; + final newArea = + _initialScale * scaleFactor * scaleFactor; + final aspectRatio = _size.width / _size.height; + final newWidth = math + .sqrt(newArea * aspectRatio) + .clamp(20.0, 5000.0); + final newHeight = newWidth / aspectRatio; + + setState(() { + _rot = newRotation % (2 * math.pi); + _size = Size(newWidth, newHeight); + }); + + widget.onUpdate(_pos, _size, _rot); + } else if (!_isTwoFingerGesture && + details.pointerCount == 1) { + // Single-finger: handle drag using incremental focal point delta + final currentFocalPoint = details.focalPoint; + final delta = currentFocalPoint - _previousFocalPoint; + // Convert to canvas coordinates by dividing by zoom + final zoom = widget.viewScale; + final scaledDelta = delta / zoom; + // Rotate delta to account for element rotation + final rotated = _rotateVector(scaledDelta, -_rot); + setState(() { + _pos += scaledDelta; + _previousFocalPoint = + currentFocalPoint; // Update for next frame + }); + widget.onUpdate(_pos, _size, _rot); + } + } + }, + onScaleEnd: (details) { + if (widget.isSelected && !widget.isEditing) { + _isTwoFingerGesture = false; + widget.onDragEnd(_pos, _size, _rot); + } + }, + child: Container( + width: _size.width, + height: _size.height, + decoration: + widget.isSelected + ? BoxDecoration( + border: Border.all( + color: Variables.selectionBorder, + width: 2 * handleScale, + ), + ) + : null, + child: + widget.type == "file_image" + ? Image.file( + File(widget.content), + fit: BoxFit.contain, + ) + : _buildText(), + ), + ), + + // RESIZE Edges + + // Right edge + if (widget.isSelected && !widget.isEditing) + Positioned( + right: -edgeThickness / 2, + 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, + ), + ), + ), + + // Left edge + if (widget.isSelected && !widget.isEditing) + Positioned( + left: -edgeThickness / 2, + 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, + ), + ), + ), + + // Top edge + if (widget.isSelected && !widget.isEditing) + Positioned( + top: -edgeThickness / 2, + 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, + ), + ), + ), + + // Bottom edge + if (widget.isSelected && !widget.isEditing) + Positioned( + bottom: -edgeThickness / 2, + 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, + ), + ), + ), + ], + ), + ), + ); + }, + ); + } + + Widget _buildText() { + final style = TextStyle( + fontSize: (widget.styleData['style_fontSize'] ?? 24.0) as double, + color: Color( + widget.styleData['style_color'] ?? Variables.textPrimary.value, + ), + fontFamily: 'GeneralSans', + ); + + if (widget.isEditing) { + return Center( + child: IntrinsicWidth( + child: TextField( + controller: widget.textController, + focusNode: widget.focusNode, + autofocus: true, + maxLines: null, + textAlign: TextAlign.center, + style: style, + decoration: const InputDecoration( + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + ), + onChanged: (text) { + final span = TextSpan(text: text, style: style); + final tp = TextPainter( + text: span, + textDirection: TextDirection.ltr, + ); + tp.layout(maxWidth: 10000); + setState(() { + _size = Size(tp.width + 40, tp.height + 40); + }); + widget.onUpdate(_pos, _size, _rot); + }, + ), + ), + ); + } + return Center( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text(widget.content, textAlign: TextAlign.center, style: style), + ), + ); + } + + Offset _rotateVector(Offset vector, double angle) { + final cosA = math.cos(angle); + final sinA = math.sin(angle); + return Offset( + vector.dx * cosA - vector.dy * sinA, + vector.dx * sinA + vector.dy * cosA, + ); + } +}