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:
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,
+ );
+ }
+}