creek

The AI Image Editor of 2030
commit 86fd9f24c4c15e131a5ccbf57ad5bb3f041cc034
parent b64463b9f4f2767ed81a10ede75d98cd34d87007
Author: ajcoder13 <avnijhalani@gmail.com>
Date:   Mon,  1 Dec 2025 17:07:19 +0530

Save file functionality implemented

Diffstat:
Mlib/ui/pages/canvas_board_page.dart | 757+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Mlib/ui/pages/create_file_page.dart | 9++++-----
Mlib/ui/pages/project_file_page.dart | 284++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
3 files changed, 671 insertions(+), 379 deletions(-)

diff --git a/lib/ui/pages/canvas_board_page.dart b/lib/ui/pages/canvas_board_page.dart @@ -1,7 +1,11 @@ import 'dart:io'; +import 'dart:convert'; import 'dart:math' as math; import 'dart:ui' as ui; import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:image_picker/image_picker.dart'; import 'package:undo/undo.dart'; @@ -10,11 +14,24 @@ import './canvas_toolbar/text_tools_overlay.dart'; import '../../data/repos/project_repo.dart'; import '../../services/stylesheet_service.dart'; -// --- MODELS --- +import '../../services/file_service.dart'; +import '../../data/models/file_model.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 { @@ -22,12 +39,32 @@ class DrawingPath { 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 @@ -38,16 +75,19 @@ class CanvasState { } class CanvasBoardPage extends StatefulWidget { - final String projectId; + final int projectId; final double width; final double height; final File? initialImage; + final FileModel? existingFile; + const CanvasBoardPage({ super.key, required this.projectId, required this.width, required this.height, this.initialImage, + this.existingFile, }); @override @@ -55,12 +95,15 @@ class CanvasBoardPage extends StatefulWidget { } class _CanvasBoardPageState extends State<CanvasBoardPage> { - // --- STATE --- + // --- SERVICES --- + final FileService _fileService = FileService(); final ChangeStack _changeStack = ChangeStack(); final ImagePicker _picker = ImagePicker(); + // --- STATE --- + bool _hasUnsavedChanges = false; List<Map<String, dynamic>> elements = []; - List<DrawingPath> _paths = []; + List<DrawingPath> _paths = []; // Keeps drawing strokes live // Snapshots for undo grouping CanvasState? _gestureStartSnapshot; @@ -94,10 +137,11 @@ class _CanvasBoardPageState extends State<CanvasBoardPage> { void initState() { super.initState(); _canvasSize = Size(widget.width, widget.height); - _fetchBrandColors(); - if (widget.initialImage != null) { + if (widget.existingFile != null) { + _loadCanvasFromFile(); + } else if (widget.initialImage != null) { elements.add({ 'id': 'bg_${DateTime.now().millisecondsSinceEpoch}', 'type': 'file_image', @@ -106,6 +150,7 @@ class _CanvasBoardPageState extends State<CanvasBoardPage> { 'size': Size(widget.width, widget.height), 'rotation': 0.0, }); + _hasUnsavedChanges = true; } } @@ -119,7 +164,7 @@ class _CanvasBoardPageState extends State<CanvasBoardPage> { Future<void> _fetchBrandColors() async { try { - final int? pId = int.tryParse(widget.projectId); + final int? pId = widget.projectId; if (pId == null) return; final project = await ProjectRepo().getProjectById(pId); @@ -138,9 +183,7 @@ class _CanvasBoardPageState extends State<CanvasBoardPage> { // --- UNDO/REDO --- - // Robust undo function: call this AFTER a change is made, passing the OLD state void _recordChange(CanvasState oldState) { - // Capture NEW state final newState = CanvasState( _deepCopyElements(elements), List.from(_paths), @@ -154,6 +197,7 @@ class _CanvasBoardPageState extends State<CanvasBoardPage> { setState(() { elements = _deepCopyElements(newState.elements); _paths = List.from(newState.paths); + _hasUnsavedChanges = true; }); }, (val) { @@ -161,10 +205,12 @@ class _CanvasBoardPageState extends State<CanvasBoardPage> { setState(() { elements = _deepCopyElements(val.elements); _paths = List.from(val.paths); + _hasUnsavedChanges = true; }); }, ), ); + setState(() => _hasUnsavedChanges = true); } CanvasState _getCurrentState() { @@ -187,7 +233,6 @@ class _CanvasBoardPageState extends State<CanvasBoardPage> { void _addTextElement() { final oldState = _getCurrentState(); final id = 'text_${DateTime.now().millisecondsSinceEpoch}'; - final double defaultFontSize = (_canvasSize.width / 25).clamp(24.0, 96.0); final initialPos = Offset( _canvasSize.width / 2 - 150, @@ -297,6 +342,7 @@ class _CanvasBoardPageState extends State<CanvasBoardPage> { elements[index]['position'] = newPos; elements[index]['size'] = newSize; elements[index]['rotation'] = newRotation; + _hasUnsavedChanges = true; }); } @@ -307,7 +353,204 @@ class _CanvasBoardPageState extends State<CanvasBoardPage> { } } - // --- BUILD --- + // =========================================================================== + // SAVE & LOAD LOGIC + // =========================================================================== + + void _handleBackNavigation() { + // If in magic draw mode, just close tool first + if (_isMagicDrawActive) { + _saveAndCloseMagicDraw(); + return; + } + + if (!_hasUnsavedChanges && widget.existingFile != null) { + Navigator.pop(context); + return; + } + + showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text("Save Changes?"), + content: const Text( + "Do you want to save your canvas before leaving?", + ), + actions: [ + TextButton( + onPressed: () { + Navigator.pop(context); // Close dialog + Navigator.pop(context); // Leave page + }, + child: const Text( + "Discard", + style: TextStyle(color: Colors.red), + ), + ), + FilledButton( + onPressed: () async { + Navigator.pop(context); // Close dialog + await _saveCanvas(); // Save + if (mounted) Navigator.pop(context); // Leave page + }, + child: const Text("Save"), + ), + ], + ), + ); + } + + Future<String?> _showNameDialog() async { + TextEditingController nameController = TextEditingController( + text: "Untitled Canvas", + ); + return showDialog<String>( + context: context, + builder: + (ctx) => AlertDialog( + title: const Text("Save Canvas"), + content: TextField( + controller: nameController, + autofocus: true, + decoration: const InputDecoration( + labelText: "Canvas Name", + hintText: "Enter a name for your file", + border: OutlineInputBorder(), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text("Cancel"), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, nameController.text.trim()), + child: const Text("Save"), + ), + ], + ), + ); + } + + Future<void> _saveCanvas() async { + try { + String fileName = "Canvas ${DateTime.now().toString().split(' ')[0]}"; + + // 1. IF NEW FILE: Ask user for name + if (widget.existingFile == null) { + final userFileName = await _showNameDialog(); + if (userFileName == null || userFileName.isEmpty) return; // Cancelled + fileName = userFileName; + } + + // 2. Serialize Elements AND Paths (Drawing) to JSON + final jsonList = _elementsToJson(elements); + final pathsJson = _paths.map((p) => p.toMap()).toList(); + final saveData = {'elements': jsonList, 'paths': pathsJson}; + final jsonString = jsonEncode(saveData); + + final directory = await getTemporaryDirectory(); + final tempFile = File( + '${directory.path}/canvas_temp_${DateTime.now().millisecondsSinceEpoch}.json', + ); + await tempFile.writeAsString(jsonString); + + if (widget.existingFile != null) { + // Overwrite existing file + final existingFile = File(widget.existingFile!.filePath); + await existingFile.writeAsString(jsonString); + await _fileService.openFile(widget.existingFile!.id); + } else { + // Save as new file + await _fileService.saveFile( + tempFile, + widget.projectId, + name: fileName, + description: "Editable Canvas Board", + ); + } + + if (await tempFile.exists()) await tempFile.delete(); + + setState(() => _hasUnsavedChanges = false); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("Canvas Saved Successfully")), + ); + } + } catch (e) { + debugPrint("Save Error: $e"); + } + } + + Future<void> _loadCanvasFromFile() async { + try { + final file = File(widget.existingFile!.filePath); + if (await file.exists()) { + final jsonString = await file.readAsString(); + final dynamic decoded = jsonDecode(jsonString); + + setState(() { + if (decoded is List) { + // Legacy support (older files with just elements) + elements = _jsonToElements(decoded); + _paths = []; + } else { + // New support (Elements + Paths) + elements = _jsonToElements(decoded['elements']); + _paths = + (decoded['paths'] as List) + .map((p) => DrawingPath.fromMap(p)) + .toList(); + } + _hasUnsavedChanges = false; + }); + } + } catch (e) { + debugPrint("Error loading canvas: $e"); + } + } + + List<Map<String, dynamic>> _elementsToJson( + List<Map<String, dynamic>> elements, + ) { + return elements.map((e) { + final copy = Map<String, dynamic>.from(e); + if (e['position'] is Offset) { + copy['position'] = { + 'dx': (e['position'] as Offset).dx, + 'dy': (e['position'] as Offset).dy, + }; + } + if (e['size'] is Size) { + copy['size'] = { + 'width': (e['size'] as Size).width, + 'height': (e['size'] as Size).height, + }; + } + return copy; + }).toList(); + } + + List<Map<String, dynamic>> _jsonToElements(List<dynamic> jsonList) { + return jsonList.map((item) { + final e = Map<String, dynamic>.from(item); + if (e['position'] is Map) { + e['position'] = Offset(e['position']['dx'], e['position']['dy']); + } + if (e['size'] is Map) { + e['size'] = Size(e['size']['width'], e['size']['height']); + } + e['rotation'] = (e['rotation'] as num).toDouble(); + return e; + }).toList(); + } + + // =========================================================================== + // UI BUILDER + // =========================================================================== @override Widget build(BuildContext context) { @@ -320,242 +563,248 @@ class _CanvasBoardPageState extends State<CanvasBoardPage> { selectedEl != null && selectedEl['type'] == 'text'; final bool showTextOverlay = _isTextToolsActive || isTextSelected; - return Scaffold( - backgroundColor: const Color(0xFFE0E0E0), - appBar: _buildAppBar(), - body: LayoutBuilder( - builder: (context, constraints) { - if (!_hasInitializedView) { - _hasInitializedView = true; - final double scaleX = - (constraints.maxWidth - 40) / _canvasSize.width; - final double scaleY = - (constraints.maxHeight - 40) / _canvasSize.height; - final double initialScale = math - .min(scaleX, scaleY) - .clamp(0.01, 1.0); - - final double transX = - (constraints.maxWidth - (_canvasSize.width * initialScale)) / 2; - final double transY = - (constraints.maxHeight - (_canvasSize.height * initialScale)) / - 2; - - _transformationController.value = - Matrix4.identity() - ..translate(transX, transY) - ..scale(initialScale); - } - - return Stack( - children: [ - GestureDetector( - onTap: () { - if (!_isMagicDrawActive) { - _exitEditMode(); - setState(() => selectedId = null); - } - }, - behavior: HitTestBehavior.translucent, - child: InteractiveViewer( - transformationController: _transformationController, - constrained: false, - boundaryMargin: const EdgeInsets.all(double.infinity), - minScale: 0.01, - maxScale: 10.0, - scaleEnabled: !_isMagicDrawActive, - panEnabled: !_isMagicDrawActive, - child: SizedBox( - width: _canvasSize.width, - height: _canvasSize.height, - child: Stack( - children: [ - // Background - Container( - width: double.infinity, - height: double.infinity, - decoration: BoxDecoration( - color: Colors.white, - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.15), - blurRadius: 40, - offset: const Offset(0, 10), - ), - ], + return PopScope( + canPop: false, + onPopInvoked: (didPop) { + if (didPop) return; + _handleBackNavigation(); + }, + child: Scaffold( + backgroundColor: const Color(0xFFE0E0E0), + appBar: _buildAppBar(), + body: LayoutBuilder( + builder: (context, constraints) { + if (!_hasInitializedView) { + _hasInitializedView = true; + final double scaleX = + (constraints.maxWidth - 40) / _canvasSize.width; + final double scaleY = + (constraints.maxHeight - 40) / _canvasSize.height; + final double initialScale = math + .min(scaleX, scaleY) + .clamp(0.01, 1.0); + + final double transX = + (constraints.maxWidth - (_canvasSize.width * initialScale)) / + 2; + final double transY = + (constraints.maxHeight - + (_canvasSize.height * initialScale)) / + 2; + + _transformationController.value = + Matrix4.identity() + ..translate(transX, transY) + ..scale(initialScale); + } + + return Stack( + children: [ + GestureDetector( + onTap: () { + if (!_isMagicDrawActive) { + _exitEditMode(); + setState(() => selectedId = null); + } + }, + behavior: HitTestBehavior.translucent, + child: InteractiveViewer( + transformationController: _transformationController, + constrained: false, + boundaryMargin: const EdgeInsets.all(double.infinity), + minScale: 0.01, + maxScale: 10.0, + scaleEnabled: !_isMagicDrawActive, + panEnabled: !_isMagicDrawActive, + child: SizedBox( + width: _canvasSize.width, + height: _canvasSize.height, + child: Stack( + children: [ + Container( + width: double.infinity, + height: double.infinity, + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.15), + blurRadius: 40, + offset: const Offset(0, 10), + ), + ], + ), ), - ), - - // Elements - ...elements.map((e) { - final bool isSelected = selectedId == e['id']; - return _ManipulatingBox( - key: ValueKey(e['id']), - id: e['id'], - position: e['position'], - size: e['size'], - rotation: e['rotation'], - type: e['type'], - content: e['content'], - styleData: e, - isSelected: isSelected && !_isMagicDrawActive, - isEditing: - isSelected && - _isEditingText && - e['type'] == 'text', - viewScale: - _transformationController.value - .getMaxScaleOnAxis(), - onTap: () { - if (!_isMagicDrawActive) { - if (_isEditingText && selectedId != e['id']) { - _exitEditMode(); + ...elements.map((e) { + final bool isSelected = selectedId == e['id']; + return _ManipulatingBox( + key: ValueKey(e['id']), + id: e['id'], + position: e['position'], + size: e['size'], + rotation: e['rotation'], + type: e['type'], + content: e['content'], + styleData: e, + isSelected: isSelected && !_isMagicDrawActive, + isEditing: + isSelected && + _isEditingText && + e['type'] == 'text', + viewScale: + _transformationController.value + .getMaxScaleOnAxis(), + onTap: () { + if (!_isMagicDrawActive) { + if (_isEditingText && selectedId != e['id']) + _exitEditMode(); + setState(() { + selectedId = e['id']; + if (e['type'] == 'text') + _isTextToolsActive = true; + }); + setState(() { + // Bring to front + elements.remove(e); + elements.add(e); + }); } - setState(() { - selectedId = e['id']; - if (e['type'] == 'text') { - _isTextToolsActive = true; - } - }); - setState(() { - elements.remove(e); - elements.add(e); - }); - } - }, - onDoubleTap: () { - if (e['type'] == 'text') _enterEditMode(e); - }, - onDragStart: _handleGestureStart, - onUpdate: (newPos, newSize, newRot) { - _handleElementUpdate( - e['id'], - newPos, - newSize, - newRot, - ); - }, - onDragEnd: (newPos, newSize, newRot) { - _handleElementUpdate( - e['id'], - newPos, - newSize, - newRot, - ); - _handleGestureEnd(); - }, - textController: - isSelected ? _textEditingController : null, - focusNode: isSelected ? _textFocusNode : null, - transformationController: _transformationController, - ); - }), - - // Drawing Layer - IgnorePointer( - ignoring: !_isMagicDrawActive, - child: RepaintBoundary( - key: _drawingKey, - child: GestureDetector( - onPanStart: (details) { - _gestureStartSnapshot = _getCurrentState(); }, - onPanUpdate: _onPanUpdate, - onPanEnd: (details) { - _onPanEnd(details); - if (_gestureStartSnapshot != null) { - _recordChange(_gestureStartSnapshot!); - _gestureStartSnapshot = null; - } + onDoubleTap: () { + if (e['type'] == 'text') _enterEditMode(e); }, - child: CustomPaint( - size: Size.infinite, - painter: CanvasPainter( - paths: _paths, - currentPoints: _currentPoints, - currentColor: - _isEraser - ? Colors.transparent - : _selectedColor, - currentWidth: _strokeWidth, - isEraser: _isEraser, + onDragStart: _handleGestureStart, + onUpdate: + (newPos, newSize, newRot) => + _handleElementUpdate( + e['id'], + newPos, + newSize, + newRot, + ), + onDragEnd: (newPos, newSize, newRot) { + _handleElementUpdate( + e['id'], + newPos, + newSize, + newRot, + ); + _handleGestureEnd(); + }, + textController: + isSelected ? _textEditingController : null, + focusNode: isSelected ? _textFocusNode : null, + transformationController: + _transformationController, + ); + }), + // Drawing Layer - Always Visible (ignoring touches when not active) + IgnorePointer( + ignoring: !_isMagicDrawActive, + child: RepaintBoundary( + key: _drawingKey, + child: GestureDetector( + onPanStart: (_) { + _gestureStartSnapshot = _getCurrentState(); + }, + onPanUpdate: _onPanUpdate, + onPanEnd: (details) { + _onPanEnd(details); + if (_gestureStartSnapshot != null) { + _recordChange(_gestureStartSnapshot!); + _gestureStartSnapshot = null; + } + }, + child: CustomPaint( + size: Size.infinite, + painter: CanvasPainter( + paths: _paths, + currentPoints: _currentPoints, + currentColor: + _isEraser + ? Colors.transparent + : _selectedColor, + currentWidth: _strokeWidth, + isEraser: _isEraser, + ), ), ), ), ), - ), - ], + ], + ), ), ), ), - ), - MagicDrawTools( - isActive: _isMagicDrawActive, - selectedColor: _selectedColor, - strokeWidth: _strokeWidth, - isEraser: _isEraser, - brandColors: _brandColors, - onClose: _saveAndCloseMagicDraw, - onColorChanged: (c) => setState(() => _selectedColor = c), - onWidthChanged: (w) => setState(() => _strokeWidth = w), - onEraserToggle: (e) => setState(() => _isEraser = e), - ), + MagicDrawTools( + isActive: _isMagicDrawActive, + selectedColor: _selectedColor, + strokeWidth: _strokeWidth, + isEraser: _isEraser, + brandColors: _brandColors, + onClose: _saveAndCloseMagicDraw, + onColorChanged: (c) => setState(() => _selectedColor = c), + onWidthChanged: (w) => setState(() => _strokeWidth = w), + onEraserToggle: (e) => setState(() => _isEraser = e), + ), - TextToolsOverlay( - isActive: showTextOverlay && !_isMagicDrawActive, - isTextSelected: isTextSelected, - currentColor: Color( - selectedEl?['style_color'] ?? Colors.black.value, + TextToolsOverlay( + isActive: showTextOverlay && !_isMagicDrawActive, + isTextSelected: isTextSelected, + currentColor: Color( + selectedEl?['style_color'] ?? Colors.black.value, + ), + currentFontSize: + (selectedEl?['style_fontSize'] ?? 24.0) as double, + onClose: () { + _exitEditMode(); + setState(() { + _isTextToolsActive = false; + selectedId = null; + }); + }, + onAddText: _addTextElement, + onDelete: _deleteSelectedElement, + onColorChanged: + (c) => + _updateSelectedTextProperty('style_color', c.value), + onFontSizeChanged: + (s) => _updateSelectedTextProperty('style_fontSize', s), ), - currentFontSize: - (selectedEl?['style_fontSize'] ?? 24.0) as double, - onClose: () { - _exitEditMode(); - setState(() { - _isTextToolsActive = false; - selectedId = null; - }); - }, - onAddText: _addTextElement, - onDelete: _deleteSelectedElement, - onColorChanged: - (c) => _updateSelectedTextProperty('style_color', c.value), - onFontSizeChanged: - (s) => _updateSelectedTextProperty('style_fontSize', s), - ), - Positioned( - bottom: 0, - left: 0, - right: 0, - child: CanvasBottomBar( - activeItem: - _isMagicDrawActive - ? "Magic Draw" - : (_isTextToolsActive ? "Text" : null), - onMagicDraw: - () => setState(() { - _isMagicDrawActive = !_isMagicDrawActive; - _isTextToolsActive = false; - _exitEditMode(); - }), - onMedia: _pickImageFromGallery, - onStylesheet: () => _showComingSoon('Stylesheet'), - onTools: () => _showComingSoon('Tools'), - onText: _toggleTextTools, - onSelect: () => _showComingSoon('Select'), - onPlugins: () => _showComingSoon('Plugins'), + Positioned( + bottom: 0, + left: 0, + right: 0, + child: CanvasBottomBar( + activeItem: + _isMagicDrawActive + ? "Magic Draw" + : (_isTextToolsActive ? "Text" : null), + onMagicDraw: + () => setState(() { + _isMagicDrawActive = !_isMagicDrawActive; + _isTextToolsActive = false; + _exitEditMode(); + }), + onMedia: _pickImageFromGallery, + onStylesheet: () => _showComingSoon('Stylesheet'), + onTools: () => _showComingSoon('Tools'), + onText: _toggleTextTools, + onSelect: () => _showComingSoon('Select'), + onPlugins: () => _showComingSoon('Plugins'), + ), ), - ), - ], - ); - }, + ], + ); + }, + ), ), ); } - // --- BOILERPLATE HELPERS --- + // --- DRAWING HELPERS --- void _onPanUpdate(DragUpdateDetails details) { setState(() { @@ -577,10 +826,12 @@ class _CanvasBoardPageState extends State<CanvasBoardPage> { ), ); _currentPoints = []; + _hasUnsavedChanges = true; } }); } + // Just toggles state; drawing data stays in _paths and is saved via _saveCanvas Future<void> _saveAndCloseMagicDraw() async { setState(() => _isMagicDrawActive = false); } @@ -595,13 +846,16 @@ class _CanvasBoardPageState extends State<CanvasBoardPage> { icon: SvgPicture.asset( 'assets/icons/arrow-left-s-line.svg', width: 22, - colorFilter: ColorFilter.mode(Colors.black, BlendMode.srcIn), + colorFilter: const ColorFilter.mode( + Colors.black, + BlendMode.srcIn, + ), ), onPressed: () { if (_isMagicDrawActive) { _saveAndCloseMagicDraw(); } else { - Navigator.of(context).maybePop(); + _handleBackNavigation(); } }, ), @@ -643,12 +897,18 @@ class _CanvasBoardPageState extends State<CanvasBoardPage> { SafeArea( child: Row( children: [ + if (selectedId != null) + IconButton( + icon: const Icon(Icons.delete_outline, color: Colors.red), + onPressed: _deleteSelectedElement, + tooltip: "Delete Selected", + ), IconButton( icon: SvgPicture.asset( 'assets/icons/file-image-line.svg', width: 22, ), - onPressed: _pickImageFromGallery, + onPressed: _saveCanvas, ), IconButton( icon: SvgPicture.asset( @@ -687,6 +947,7 @@ class _CanvasBoardPageState extends State<CanvasBoardPage> { 'rotation': 0.0, }); } + _hasUnsavedChanges = true; }); _recordChange(oldState); } @@ -736,10 +997,7 @@ class _ManipulatingBox extends StatefulWidget { final bool isSelected; final bool isEditing; final double viewScale; - - // Passed controller for real-time zoom updates final TransformationController transformationController; - final VoidCallback onTap; final VoidCallback onDoubleTap; final VoidCallback onDragStart; @@ -749,7 +1007,7 @@ class _ManipulatingBox extends StatefulWidget { final FocusNode? focusNode; const _ManipulatingBox({ - super.key, + Key? key, required this.id, required this.position, required this.size, @@ -768,7 +1026,7 @@ class _ManipulatingBox extends StatefulWidget { required this.onDragEnd, this.textController, this.focusNode, - }); + }) : super(key: key); @override State<_ManipulatingBox> createState() => _ManipulatingBoxState(); @@ -827,7 +1085,7 @@ class _ManipulatingBoxState extends State<_ManipulatingBox> { onPanUpdate: (details) { if (widget.isSelected && !widget.isEditing) { // FIX: Do not divide by currentZoom - final delta = details.delta; + final delta = details.delta; final globalDelta = _rotateVector(delta, _rot); setState(() => _pos += globalDelta); widget.onUpdate(_pos, _size, _rot); @@ -844,12 +1102,12 @@ class _ManipulatingBoxState extends State<_ManipulatingBox> { color: Colors.blue, width: 2.0 * handleScale, ) - : widget.type == 'text' - ? Border.all( - color: Colors.grey.withOpacity(0.3), - width: 1.0 * handleScale, - ) - : null, + : (widget.type == 'text' + ? Border.all( + color: Colors.grey.withOpacity(0.3), + width: 1.0 * handleScale, + ) + : null), ), child: widget.type == 'file_image' @@ -860,7 +1118,6 @@ class _ManipulatingBoxState extends State<_ManipulatingBox> { : _buildText(), ), ), - if (widget.isSelected && !widget.isEditing) ...[ Positioned( right: -visualSize / 2, @@ -885,7 +1142,6 @@ class _ManipulatingBoxState extends State<_ManipulatingBox> { }, ), ), - Positioned( right: -visualSize / 2, top: -visualSize / 2, @@ -981,7 +1237,9 @@ class _ManipulatingBoxState extends State<_ManipulatingBox> { color: color, shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 2), - boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 4)], + boxShadow: [ + BoxShadow(color: Colors.black.withOpacity(0.26), blurRadius: 4), + ], ), child: Icon(icon, size: visualSize * 0.6, color: Colors.white), ), @@ -1013,6 +1271,7 @@ class CanvasPainter extends CustomPainter { required this.currentWidth, required this.isEraser, }); + @override void paint(Canvas canvas, Size size) { canvas.saveLayer(Rect.fromLTWH(0, 0, size.width, size.height), Paint()); @@ -1025,12 +1284,12 @@ class CanvasPainter extends CustomPainter { ..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++) { + 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, [ @@ -1049,9 +1308,8 @@ class CanvasPainter extends CustomPainter { ..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++) { + for (int i = 1; i < currentPoints.length; i++) p.lineTo(currentPoints[i].offset.dx, currentPoints[i].offset.dy); - } canvas.drawPath(p, paint); } canvas.restore(); @@ -1081,6 +1339,7 @@ class CanvasBottomBar extends StatelessWidget { required this.onSelect, required this.onPlugins, }); + @override Widget build(BuildContext context) { return Container( diff --git a/lib/ui/pages/create_file_page.dart b/lib/ui/pages/create_file_page.dart @@ -13,9 +13,8 @@ import '../../data/models/project_model.dart'; class CreateFilePage extends StatefulWidget { final File? file; // Made optional for blank canvas creation - final int? projectId; // Optional: If null, user can select a project - - const CreateFilePage({super.key, this.file, this.projectId}); + final int projectId; + const CreateFilePage({super.key, this.file, this.projectId = 0}); @override State<CreateFilePage> createState() => _CreateFilePageState(); @@ -451,8 +450,8 @@ class _CreateFilePageState extends State<CreateFilePage> { MaterialPageRoute( builder: (context) => CanvasBoardPage( - // Use the selected project ID - projectId: _selectedProjectId.toString(), + projectId: widget.projectId, + // Pass the dimensions from the preset width: width.toDouble(), height: height.toDouble(), initialImage: widget.file, diff --git a/lib/ui/pages/project_file_page.dart b/lib/ui/pages/project_file_page.dart @@ -4,8 +4,9 @@ import 'package:image_picker/image_picker.dart'; import 'package:intl/intl.dart'; import '../../data/models/file_model.dart'; import '../../services/file_service.dart'; -import '../widgets/bottom_bar.dart'; // Import BottomBar +import '../widgets/bottom_bar.dart'; import 'create_file_page.dart'; +import 'canvas_board_page.dart'; // [IMPORTANT] Import this to open the canvas class ProjectFilePage extends StatefulWidget { final int projectId; @@ -41,9 +42,47 @@ class _ProjectFilePageState extends State<ProjectFilePage> { } } catch (e) { debugPrint('Error loading files: $e'); - if (mounted) { - setState(() => _isLoading = false); - } + if (mounted) setState(() => _isLoading = false); + } + } + + // --- ACTIONS --- + + void _navigateToCreateFile() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (_) => CreateFilePage( + // Pass the current projectId so the file is saved to THIS project + projectId: widget.projectId, + ), + ), + ).then((_) => _loadData()); + } + + void _openFile(FileModel file) { + // Check if it's a Canvas file (JSON) + if (file.filePath.toLowerCase().endsWith('.json')) { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => CanvasBoardPage( + projectId: widget.projectId, + // Use default dimensions for viewing; the canvas content defines the actual size + width: 1080, + height: 1920, + existingFile: file, // [KEY FIX] Loads the saved canvas + ), + ), + ).then((_) => _loadData()); + } else { + // It's a regular image + // You can add navigation to an Image Viewer page here if you have one + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("Image preview coming soon")), + ); } } @@ -179,17 +218,6 @@ class _ProjectFilePageState extends State<ProjectFilePage> { } } - void _navigateToCreateFile() { - Navigator.push( - context, - MaterialPageRoute( - // Passing no file implies a "Blank Canvas" - // Pass projectId so CreateFilePage knows we are in a specific project context - builder: (_) => CreateFilePage(projectId: widget.projectId), - ), - ).then((_) => _loadData()); - } - @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -208,7 +236,6 @@ class _ProjectFilePageState extends State<ProjectFilePage> { ), ], ), - // Added BottomBar here bottomNavigationBar: BottomBar( currentTab: BottomBarItem.files, projectId: widget.projectId, @@ -235,8 +262,8 @@ class _ProjectFilePageState extends State<ProjectFilePage> { Text( "${_files.length} items", style: TextStyle( - color: theme.colorScheme.onSurface.withValues( - alpha: 0.6, + color: theme.colorScheme.onSurface.withOpacity( + 0.6, ), fontFamily: 'GeneralSans', ), @@ -301,125 +328,132 @@ class _ProjectFilePageState extends State<ProjectFilePage> { Widget _buildFileCard(FileModel file, ThemeData theme, bool isDark) { final dateStr = DateFormat.yMMMd().format(file.lastUpdated); + final isCanvas = file.filePath.toLowerCase().endsWith('.json'); - return Container( - decoration: BoxDecoration( - color: isDark ? Colors.grey[850] : Colors.white, - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: isDark ? Colors.grey[700]! : Colors.grey[300]!, - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.03), - blurRadius: 8, - offset: const Offset(0, 2), + return InkWell( + onTap: () => _openFile(file), // [KEY FIX] Make the card clickable + borderRadius: BorderRadius.circular(16), + child: Container( + decoration: BoxDecoration( + color: isDark ? Colors.grey[850] : Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isDark ? Colors.grey[700]! : Colors.grey[300]!, ), - ], - ), - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: theme.colorScheme.primary.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Icon( - Icons.insert_drive_file, - color: theme.colorScheme.primary, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.03), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Icon based on type + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: (isCanvas + ? Colors.orange + : theme.colorScheme.primary) + .withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + isCanvas ? Icons.brush : Icons.insert_drive_file, + color: isCanvas ? Colors.orange : theme.colorScheme.primary, + ), ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - file.name, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - fontFamily: 'GeneralSans', - ), - ), - const SizedBox(height: 4), - Text( - "Updated $dateStr", - style: TextStyle( - fontSize: 12, - color: theme.colorScheme.onSurface.withValues( - alpha: 0.5, + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + file.name, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + fontFamily: 'GeneralSans', ), - fontFamily: 'GeneralSans', ), - ), - ], - ), - ), - PopupMenuButton<String>( - icon: const Icon(Icons.more_vert), - onSelected: (value) { - if (value == 'delete') { - _deleteFile(file.id); - } else if (value == 'open') { - _fileService.openFile(file.id); - } - }, - itemBuilder: - (context) => [ - const PopupMenuItem(value: 'open', child: Text("Open")), - const PopupMenuItem( - value: 'delete', - child: Text( - "Delete", - style: TextStyle(color: Colors.red), + const SizedBox(height: 4), + Text( + "Updated $dateStr", + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurface.withOpacity(0.5), + fontFamily: 'GeneralSans', ), ), ], - ), - ], - ), - if (file.description != null && file.description!.isNotEmpty) ...[ - const SizedBox(height: 12), - Text( - file.description!, - style: TextStyle( - fontSize: 13, - color: theme.colorScheme.onSurface.withValues(alpha: 0.7), - fontFamily: 'GeneralSans', - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ], - if (file.tags.isNotEmpty) ...[ - const SizedBox(height: 12), - Wrap( - spacing: 8, - children: - file.tags - .map( - (tag) => Chip( - label: Text( - tag, - style: const TextStyle(fontSize: 10), + ), + ), + PopupMenuButton<String>( + icon: const Icon(Icons.more_vert), + onSelected: (value) { + if (value == 'delete') { + _deleteFile(file.id); + } else if (value == 'open') { + _openFile(file); + } + }, + itemBuilder: + (context) => [ + const PopupMenuItem(value: 'open', child: Text("Open")), + const PopupMenuItem( + value: 'delete', + child: Text( + "Delete", + style: TextStyle(color: Colors.red), ), - materialTapTargetSize: - MaterialTapTargetSize.shrinkWrap, - visualDensity: VisualDensity.compact, ), - ) - .toList(), + ], + ), + ], ), + if (file.description != null && file.description!.isNotEmpty) ...[ + const SizedBox(height: 12), + Text( + file.description!, + style: TextStyle( + fontSize: 13, + color: theme.colorScheme.onSurface.withOpacity(0.7), + fontFamily: 'GeneralSans', + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + if (file.tags.isNotEmpty) ...[ + const SizedBox(height: 12), + Wrap( + spacing: 8, + children: + file.tags + .map( + (tag) => Chip( + label: Text( + tag, + style: const TextStyle(fontSize: 10), + ), + materialTapTargetSize: + MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ), + ) + .toList(), + ), + ], ], - ], + ), ), ); }