creek

The AI Image Editor of 2030
commit 10d08e248ef1f67820ad79cc69f79ad6b8ef90ef
parent 9e2b44bba479ef0ffe774f8f5208d7be43f652ec
Author: aditya-samal <samaladitya2004@gmail.com>
Date:   Tue,  2 Dec 2025 04:17:13 +0530

Merge branch 'main' of https://github.com/nilotpal-n7/adobe

Diffstat:
M.env | 3++-
Mlib/services/flask_service.dart | 22+++++++++++++++-------
Mlib/ui/pages/canvas_board_page.dart | 2119+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Mlib/ui/pages/project_file_page.dart | 217+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Mlib/ui/pages/share_to_file_page.dart | 251++++++++++++++++++++++++++++++++++---------------------------------------------
5 files changed, 1473 insertions(+), 1139 deletions(-)

diff --git a/.env b/.env @@ -1 +1 @@ -SERVER_URL = 'http://10.150.40.117:5000' +SERVER_URL = https://locustlike-trieciously-rudolph.ngrok-free.dev +\ No newline at end of file diff --git a/lib/services/flask_service.dart b/lib/services/flask_service.dart @@ -45,8 +45,7 @@ class FlaskService { }) async { debugPrint("🔗 [Pipeline] Starting Sketch-to-Image..."); - // Step 1: Get the description of the sketch - // We use a specific prompt to ensure we get structural details + // 1. Analyze Sketch final String? sketchDescription = await describeImage( imagePath: sketchPath, prompt: '<MORE_DETAILED_CAPTION>', @@ -57,15 +56,24 @@ class FlaskService { return null; } - // Step 2: Construct the Global Prompt - // Strategy: Style + User Intent + Content context + // 2. Construct Prompt & Generate final String globalPrompt = "$stylePrompt. $userPrompt. The image features: $sketchDescription"; - debugPrint("🔗 [Pipeline] Generated Global Prompt: \n$globalPrompt"); + debugPrint("🔗 [Pipeline] Generating base image..."); + + final String? generatedImagePath = await generateAndSaveImage(globalPrompt); - // Step 3: Generate the final image - return generateAndSaveImage(globalPrompt); + if (generatedImagePath == null) { + debugPrint("❌ [Pipeline] Failed: Image generation returned null."); + return null; + } + + // 3. Remove Background (Pipeline Extension) + debugPrint("🔗 [Pipeline] Removing background from generated result..."); + + // This returns the path to the no-background version + return generateAsset(imagePath: generatedImagePath); } // =========================================================================== diff --git a/lib/ui/pages/canvas_board_page.dart b/lib/ui/pages/canvas_board_page.dart @@ -18,6 +18,7 @@ import './canvas_toolbar/text_tools_overlay.dart'; import '../../data/repos/project_repo.dart'; import '../../services/stylesheet_service.dart'; import 'project_file_page.dart'; +import 'package:path/path.dart' as p; import '../../services/file_service.dart'; import '../../data/models/file_model.dart'; @@ -87,7 +88,7 @@ class CanvasBoardPage extends StatefulWidget { final double height; final File? initialImage; final FileModel? existingFile; - final File? injectedMedia; + final File? injectedMedia; const CanvasBoardPage({ super.key, @@ -174,26 +175,9 @@ class _CanvasBoardPageState extends State<CanvasBoardPage> { }); _hasUnsavedChanges = true; } - - // Inject shared image EXACTLY like gallery images - WidgetsBinding.instance.addPostFrameCallback((_) { - if (widget.injectedMedia != null && widget.existingFile != null) { - final oldState = _getCurrentState(); - setState(() { - elements.add({ - 'id': 'shared_${DateTime.now().millisecondsSinceEpoch}', - 'type': 'file_image', - 'content': widget.injectedMedia!.path, - 'position': const Offset(50, 50), - 'size': const Size(150, 150), // EXACT match - 'rotation': 0.0, - }); - _hasUnsavedChanges = true; - }); - _recordChange(oldState); - } - }); - + + // Note: injectedMedia handling is done inside _loadCanvasFromFile to ensure + // it happens after file content is loaded, avoiding race conditions. } @override @@ -548,974 +532,1025 @@ class _CanvasBoardPageState extends State<CanvasBoardPage> { } // =========================================================================== - // UI BUILDER + // SAVE & LOAD LOGIC // =========================================================================== - @override - Widget build(BuildContext context) { - Map<String, dynamic>? selectedEl; + 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 + // Note: The redirect logic is handled in _saveCanvas for new files. + // For existing files, we pop here. + if (mounted && widget.existingFile != null) { + Navigator.pop(context); + } + }, + 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"), + ), + ], + ), + ); + } + + // Helper to generate preview image + Future<String?> _generatePreviewImage() async { try { - selectedEl = elements.firstWhere((e) => e['id'] == selectedId); - } catch (_) {} + final boundary = + _canvasGlobalKey.currentContext?.findRenderObject() + as RenderRepaintBoundary?; + if (boundary == null) return null; - final bool isTextSelected = - selectedEl != null && selectedEl['type'] == 'text'; - final bool showTextOverlay = _isTextToolsActive || isTextSelected; + // Capture image with lower pixel ratio for preview thumbnail + final ui.Image image = await boundary.toImage(pixelRatio: 1.0); + final ByteData? byteData = await image.toByteData( + format: ui.ImageByteFormat.png, + ); - 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); + if (byteData == null) return null; + final Uint8List pngBytes = byteData.buffer.asUint8List(); - final double transX = - (constraints.maxWidth - (_canvasSize.width * initialScale)) / - 2; - final double transY = - (constraints.maxHeight - - (_canvasSize.height * initialScale)) / - 2; + final directory = await getApplicationDocumentsDirectory(); + final previewDir = Directory('${directory.path}/previews'); + if (!await previewDir.exists()) { + await previewDir.create(recursive: true); + } - _transformationController.value = - Matrix4.identity() - ..translate(transX, transY) - ..scale(initialScale); - } + final String fileName = + "preview_${DateTime.now().millisecondsSinceEpoch}.png"; + final String filePath = '${previewDir.path}/$fileName'; - 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: RepaintBoundary( - // WRAPPED CANVAS IN REPAINT BOUNDARY - key: _canvasGlobalKey, - 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.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); - }); - } - }, - 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: (_) => _handleGestureStart(), - onPanUpdate: _onPanUpdate, - onPanEnd: (details) { - _onPanEnd(details); - _handleGestureEnd(); - }, - child: CustomPaint( - size: Size.infinite, - painter: CanvasPainter( - paths: _paths, - // CONDITIONALLY HIDE MAGIC PATHS DURING CAPTURE - magicPaths: _isCapturingBase ? [] : _magicPaths, - // CONDITIONALLY HIDE CURRENT POINTS DURING CAPTURE - currentPoints: _isCapturingBase ? [] : _currentPoints, - currentColor: - _isEraser - ? Colors.transparent - : _selectedColor, - currentWidth: _strokeWidth, - isEraser: _isEraser, - ), - ), - ), - ), - ), - ], - ), - ), - ), - ), - ), + final File imgFile = File(filePath); + await imgFile.writeAsBytes(pngBytes); + return filePath; + } catch (e) { + debugPrint("Error generating preview: $e"); + return null; + } + } - // AI Description Banner - if (_aiDescription != null && !_isMagicDrawActive) - Positioned( - top: 10, - left: 16, - right: 16, - child: GestureDetector( - onTap: () { - setState(() { - _isDescriptionExpanded = !_isDescriptionExpanded; - }); - }, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 10, - ), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.95), - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.1), - blurRadius: 10, - offset: const Offset(0, 4), - ), - ], - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(top: 2.0), - child: Icon( - Icons.auto_awesome, - size: 20, - color: Colors.indigo.shade400, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Text( - _aiDescription!, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: Colors.black87, - ), - maxLines: _isDescriptionExpanded ? null : 1, - overflow: - _isDescriptionExpanded - ? TextOverflow.visible - : TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 8), - Icon( - _isDescriptionExpanded - ? Icons.keyboard_arrow_up - : Icons.keyboard_arrow_down, - size: 20, - color: Colors.grey[600], - ), - ], - ), - ), - ), - ), + Future<void> _saveCanvas() async { + try { + String fileName = "Canvas ${DateTime.now().toString().split(' ')[0]}"; + bool isNewFile = widget.existingFile == null; - 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), - // CONNECTED CALLBACKS: - onPromptSubmit: (prompt) => _processInpainting(prompt), - isProcessing: _isInpainting, - ), + // 1. IF NEW FILE: Ask user for name + if (isNewFile) { + final userFileName = await _showNameDialog(); + if (userFileName == null || userFileName.isEmpty) return; // Cancelled + fileName = userFileName; + } - 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, // REMOVED - onColorChanged: - (c) => - _updateSelectedTextProperty('style_color', c.value), - onFontSizeChanged: - (s) => _updateSelectedTextProperty('style_fontSize', s), - ), + // 2. Generate Preview + final String? previewPath = await _generatePreviewImage(); - Positioned( - bottom: 0, - left: 0, - right: 0, - child: CanvasBottomBar( - activeItem: - _isMagicDrawActive - ? "Magic Draw" - : (_isTextToolsActive ? "Text" : null), - onMagicDraw: - () => setState(() { - _isMagicDrawActive = !_isMagicDrawActive; - _isTextToolsActive = false; - _exitEditMode(); - _magicPaths.clear(); // Clear old magic paths - _tempBaseImage = null; // Force recapture next time - }), - onMedia: _pickImageFromGallery, - onStylesheet: () => _showComingSoon('Stylesheet'), - onTools: () => _showComingSoon('Tools'), - onText: _toggleTextTools, - onSelect: () => _showComingSoon('Select'), - onPlugins: () => _showComingSoon('Plugins'), - ), - ), - ], - ); - }, - ), - ), - ); - } + // 3. Serialize Elements AND Paths (Drawing) to JSON + final jsonList = _elementsToJson(elements); + final pathsJson = _paths.map((p) => p.toMap()).toList(); - // --- DRAWING HELPERS --- + final saveData = { + 'elements': jsonList, + 'paths': pathsJson, + 'width': _canvasSize.width, // Saving Width + 'height': _canvasSize.height, // Saving Height + 'preview_path': previewPath, // Saving Preview Path + }; + final jsonString = jsonEncode(saveData); - void _onPanUpdate(DragUpdateDetails details) { - setState(() { - _currentPoints.add( - DrawingPoint(offset: details.localPosition, paint: Paint()), + 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")), + ); + if (isNewFile) { + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (_) => ProjectFilePage(projectId: widget.projectId), + ), + ); + } + } + } catch (e) { + debugPrint("Save Error: $e"); + } } - Future<void> _onPanEnd(DragEndDetails details) async { - setState(() { - if (_currentPoints.isNotEmpty) { - if (_isMagicDrawActive) { - // ADD TO MAGIC PATHS (Temporary mask) - _magicPaths.add( - DrawingPath( - points: List.from(_currentPoints), - color: _selectedColor, - strokeWidth: _strokeWidth, - isEraser: _isEraser, - ), - ); - _currentPoints = []; - } else { - // NORMAL DRAWING - _paths.add( - DrawingPath( - points: List.from(_currentPoints), - color: _selectedColor, - strokeWidth: _strokeWidth, - isEraser: _isEraser, - ), - ); - _currentPoints = []; - _hasUnsavedChanges = true; - _resetInactivityTimer(); - } - } - }); - } - - Future<void> _processInpainting(String prompt) async { - if (prompt.isEmpty) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text("Please enter a prompt!"))); - return; - } - - setState(() => _isInpainting = true); - _resetInactivityTimer(); - + Future<void> _loadCanvasFromFile() async { try { - // 1. Check for Image Layers - bool hasImageLayers = elements.any((e) => e['type'] == 'file_image'); + final file = File(widget.existingFile!.filePath); + if (await file.exists()) { + final jsonString = await file.readAsString(); + final dynamic decoded = jsonDecode(jsonString); - if (hasImageLayers) { - // --- INPAINTING FLOW (Existing) --- - if (_tempBaseImage == null) { - _tempBaseImage = await _captureCanvasToFile(); + setState(() { + if (decoded is Map && decoded.containsKey('elements')) { + // New format with dimensions + elements = _jsonToElements(decoded['elements']); + if (decoded['paths'] != null) { + _paths = + (decoded['paths'] as List) + .map((p) => DrawingPath.fromMap(p)) + .toList(); + } else { + _paths = []; + } + // [FIX] 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 + } + } else if (decoded is List) { + // Legacy support + elements = _jsonToElements(decoded); + _paths = []; + _hasInitializedView = false; + } + _hasUnsavedChanges = false; + }); + + // Handle Injected Media (e.g. from Share) + if (widget.injectedMedia != null) { + final oldState = _getCurrentState(); + setState(() { + elements.add({ + 'id': 'shared_${DateTime.now().millisecondsSinceEpoch}', + 'type': 'file_image', + 'content': widget.injectedMedia!.path, + 'position': const Offset(50, 50), + 'size': const Size(150, 150), + 'rotation': 0.0, + }); + }); + _recordChange(oldState); } + } + } catch (e) { + debugPrint("Error loading canvas: $e"); + } + } - if (_tempBaseImage == null) return; - - File? maskFile = await _generateMaskImageFromPaths( - _magicPaths, - _canvasSize, - _tempBaseImage, + List<Map<String, dynamic>> _deepCopyElements( + List<Map<String, dynamic>> source, + ) { + return source.map((e) { + final copy = Map<String, dynamic>.from(e); + if (e['position'] is Offset) { + copy['position'] = Offset( + (e['position'] as Offset).dx, + (e['position'] as Offset).dy, ); - - if (maskFile == null) throw Exception("Failed to generate mask"); - - final String? newImageUrl = await FlaskService().inpaintImage( - imagePath: _tempBaseImage!.path, - maskPath: maskFile.path, - prompt: prompt, + } + if (e['size'] is Size) { + copy['size'] = Size( + (e['size'] as Size).width, + (e['size'] as Size).height, ); + } + return copy; + }).toList(); + } - _addGeneratedImage(newImageUrl); - - } else { - // --- SKETCH-TO-IMAGE FLOW (New) --- - // Capture the entire canvas (strokes only since no images exist) - File? sketchFile = await _captureCanvasToFile(); - if (sketchFile == null) throw Exception("Failed to capture sketch"); - - final String? newImageUrl = await FlaskService().sketchToImage( - sketchPath: sketchFile.path, - userPrompt: prompt, - stylePrompt: "high quality, realistic", // Default style - ); + 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(); + } - _addGeneratedImage(newImageUrl); + 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(); + } - } catch (e) { - debugPrint("Generation Error: $e"); - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text("Generation failed."))); - } finally { - setState(() { - _isInpainting = false; - _tempBaseImage = null; // Reset base image - _magicPaths.clear(); // Always clear magic paths on end - }); - } + void _openLayers() => _showComingSoon('Layers'); + void _openSettings() => _showComingSoon('Settings'); + void _showComingSoon([dynamic feature]) => ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Coming soon'))); + + // [UPDATED] New Bottom Sheet for Assets + void _openStylesheet() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: + (context) => DraggableScrollableSheet( + initialChildSize: 0.7, + minChildSize: 0.5, + maxChildSize: 0.9, + builder: + (_, controller) => _AssetPickerSheet( + projectId: widget.projectId, + scrollController: controller, + onAddAssets: (List<String> paths) { + _addAssetsToCanvas(paths); + Navigator.pop(context); + }, + ), + ), + ); } - void _addGeneratedImage(String? newImageUrl) { - if (newImageUrl != null) { - debugPrint("✅ Adding generated image to canvas: $newImageUrl"); - setState(() { - elements.add({ - 'id': 'gen_${DateTime.now().millisecondsSinceEpoch}', - 'type': 'file_image', - 'content': newImageUrl, - 'position': const Offset(0, 0), - 'size': _canvasSize, - 'rotation': 0.0, - }); - // Clear magic paths now that operation is done - _magicPaths.clear(); + void _addAssetsToCanvas(List<String> paths) { + if (paths.isEmpty) return; + final oldState = _getCurrentState(); + setState(() { + for (var path in paths) { + elements.add({ + 'id': + 'asset_${DateTime.now().millisecondsSinceEpoch}_${math.Random().nextInt(1000)}', + 'type': 'file_image', + 'content': path, + 'position': const Offset(50, 50), + 'size': const Size(150, 150), + 'rotation': 0.0, }); } + _hasUnsavedChanges = true; + }); + _recordChange(oldState); } - // Updated to generate mask as: Base Image + Drawing Strokes - Future<File?> _generateMaskImageFromPaths( - List<DrawingPath> paths, - Size size, - File? baseImageFile, - ) async { + // =========================================================================== + // UI BUILDER + // =========================================================================== + + @override + Widget build(BuildContext context) { + Map<String, dynamic>? selectedEl; try { - final recorder = ui.PictureRecorder(); - final canvas = Canvas( - recorder, - Rect.fromLTWH(0, 0, size.width, size.height), - ); + selectedEl = elements.firstWhere((e) => e['id'] == selectedId); + } catch (_) {} - // 1. Draw Base Image First (if available) - if (baseImageFile != null) { - final data = await baseImageFile.readAsBytes(); - final codec = await ui.instantiateImageCodec(data); - final frameInfo = await codec.getNextFrame(); - final baseImage = frameInfo.image; - - paintImage( - canvas: canvas, - rect: Rect.fromLTWH(0, 0, size.width, size.height), - image: baseImage, - fit: BoxFit.cover, // Or contain, depending on your logic - ); - } else { - // Fallback to black if no base image (shouldn't happen based on logic) - canvas.drawRect( - Rect.fromLTWH(0, 0, size.width, size.height), - Paint()..color = Colors.black, - ); - } + final bool isTextSelected = + selectedEl != null && selectedEl['type'] == 'text'; + final bool showTextOverlay = _isTextToolsActive || isTextSelected; - // 2. Draw Strokes ON TOP of the base image - for (final path in paths) { - final paint = - Paint() - ..color = path.color // Use the drawing color (e.g. Blue) - ..strokeWidth = path.strokeWidth - ..strokeCap = StrokeCap.round - ..strokeJoin = StrokeJoin.round - ..style = PaintingStyle.stroke; + 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); - if (path.points.length > 1) { - final Path p = Path(); - p.moveTo(path.points.first.offset.dx, path.points.first.offset.dy); - for (int i = 1; i < path.points.length; i++) { - p.lineTo(path.points[i].offset.dx, path.points[i].offset.dy); - } - canvas.drawPath(p, paint); - } else if (path.points.isNotEmpty) { - canvas.drawPoints( - ui.PointMode.points, - [path.points.first.offset], - paint, - ); - } - } + final double transX = + (constraints.maxWidth - (_canvasSize.width * initialScale)) / + 2; + final double transY = + (constraints.maxHeight - + (_canvasSize.height * initialScale)) / + 2; - final picture = recorder.endRecording(); - final img = await picture.toImage( - size.width.toInt(), - size.height.toInt(), - ); - final byteData = await img.toByteData(format: ui.ImageByteFormat.png); - if (byteData == null) return null; + _transformationController.value = + Matrix4.identity() + ..translate(transX, transY) + ..scale(initialScale); + } - final tempDir = await getTemporaryDirectory(); - final file = File( - '${tempDir.path}/mask_${DateTime.now().millisecondsSinceEpoch}.png', - ); - await file.writeAsBytes(byteData.buffer.asUint8List()); - return file; - } catch (e) { - debugPrint("Mask Generation Error: $e"); - return null; - } - } + 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: RepaintBoundary( + // WRAPPED CANVAS IN REPAINT BOUNDARY + key: _canvasGlobalKey, + 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.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); + }); + } + }, + 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: (_) => _handleGestureStart(), + onPanUpdate: _onPanUpdate, + onPanEnd: (details) { + _onPanEnd(details); + _handleGestureEnd(); + }, + child: CustomPaint( + size: Size.infinite, + painter: CanvasPainter( + paths: _paths, + // CONDITIONALLY HIDE MAGIC PATHS DURING CAPTURE + magicPaths: _isCapturingBase ? [] : _magicPaths, + // CONDITIONALLY HIDE CURRENT POINTS DURING CAPTURE + currentPoints: _isCapturingBase ? [] : _currentPoints, + currentColor: + _isEraser + ? Colors.transparent + : _selectedColor, + currentWidth: _strokeWidth, + isEraser: _isEraser, + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), - // Just toggles state; drawing data stays in _paths and is saved via _saveCanvas - Future<void> _saveAndCloseMagicDraw() async { - setState(() { - _isMagicDrawActive = false; - _magicPaths.clear(); // Clear magic strokes if canceled - _tempBaseImage = null; - }); - } + // AI Description Banner + if (_aiDescription != null && !_isMagicDrawActive) + Positioned( + top: 10, + left: 16, + right: 16, + child: GestureDetector( + onTap: () { + setState(() { + _isDescriptionExpanded = !_isDescriptionExpanded; + }); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.95), + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2.0), + child: Icon( + Icons.auto_awesome, + size: 20, + color: Colors.indigo.shade400, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + _aiDescription!, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Colors.black87, + ), + maxLines: _isDescriptionExpanded ? null : 1, + overflow: + _isDescriptionExpanded + ? TextOverflow.visible + : TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + Icon( + _isDescriptionExpanded + ? Icons.keyboard_arrow_up + : Icons.keyboard_arrow_down, + size: 20, + color: Colors.grey[600], + ), + ], + ), + ), + ), + ), - PreferredSizeWidget _buildAppBar() { - return AppBar( - leadingWidth: 160, - leading: SafeArea( - child: Row( - children: [ - IconButton( - icon: SvgPicture.asset( - 'assets/icons/arrow-left-s-line.svg', - width: 22, - colorFilter: const ColorFilter.mode( - Colors.black, - BlendMode.srcIn, - ), - ), - onPressed: () { - if (_isMagicDrawActive) { - _saveAndCloseMagicDraw(); - } else { - _handleBackNavigation(); - } - }, - ), - IconButton( - icon: SvgPicture.asset( - 'assets/icons/arrow-go-back-line.svg', - width: 22, - colorFilter: ColorFilter.mode( - _changeStack.canUndo ? Colors.black : Colors.grey[400]!, - BlendMode.srcIn, - ), - ), - onPressed: - _changeStack.canUndo - ? () => setState(() => _changeStack.undo()) - : null, - ), - IconButton( - icon: SvgPicture.asset( - 'assets/icons/arrow-go-forward-line.svg', - width: 22, - colorFilter: ColorFilter.mode( - _changeStack.canRedo ? Colors.black : Colors.grey[400]!, - BlendMode.srcIn, - ), - ), - onPressed: - _changeStack.canRedo - ? () => setState(() => _changeStack.redo()) - : null, - ), - ], - ), - ), - backgroundColor: Colors.white, - foregroundColor: Colors.black, - elevation: 0, - actions: [ - 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/save-3-line.svg', - width: 22, + 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), + // CONNECTED CALLBACKS: + onPromptSubmit: (prompt) => _processInpainting(prompt), + isProcessing: _isInpainting, ), - onPressed: _saveCanvas, - ), - IconButton( - icon: SvgPicture.asset( - 'assets/icons/upload-2-line.svg', - width: 22, + + 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, // REMOVED + onColorChanged: + (c) => + _updateSelectedTextProperty('style_color', c.value), + onFontSizeChanged: + (s) => _updateSelectedTextProperty('style_fontSize', s), ), - onPressed: _exportProject, - ), - IconButton( - icon: SvgPicture.asset( - 'assets/icons/settings-line.svg', - width: 22, + + Positioned( + bottom: 0, + left: 0, + right: 0, + child: CanvasBottomBar( + activeItem: + _isMagicDrawActive + ? "Magic Draw" + : (_isTextToolsActive ? "Text" : null), + onMagicDraw: + () => setState(() { + _isMagicDrawActive = !_isMagicDrawActive; + _isTextToolsActive = false; + _exitEditMode(); + _magicPaths.clear(); // Clear old magic paths + _tempBaseImage = null; // Force recapture next time + }), + onMedia: _pickImageFromGallery, + onStylesheet: _openStylesheet, + onTools: () => _showComingSoon('Tools'), + onText: _toggleTextTools, + onSelect: () => _showComingSoon('Select'), + onPlugins: () => _showComingSoon('Plugins'), + ), ), - onPressed: _openSettings, - ), - const SizedBox(width: 8), - ], - ), + ], + ); + }, ), - ], + ), ); } - Future<void> _pickImageFromGallery() async { - final List<XFile> images = await _picker.pickMultiImage(); - if (images.isNotEmpty) { - final oldState = _getCurrentState(); - setState(() { - for (int i = 0; i < images.length; i++) { - elements.add({ - 'id': '${DateTime.now().millisecondsSinceEpoch}_$i', - 'type': 'file_image', - 'content': images[i].path, - 'position': const Offset(50, 50), - 'size': const Size(150, 150), - 'rotation': 0.0, - }); - } - _hasUnsavedChanges = true; - }); - _recordChange(oldState); - } - } - - List<Map<String, dynamic>> _deepCopyElements( - List<Map<String, dynamic>> source, - ) { - return source.map((e) { - final copy = Map<String, dynamic>.from(e); - if (e['position'] is Offset) { - copy['position'] = Offset( - (e['position'] as Offset).dx, - (e['position'] as Offset).dy, - ); - } - if (e['size'] is Size) { - copy['size'] = Size( - (e['size'] as Size).width, - (e['size'] as Size).height, - ); - } - return copy; - }).toList(); - } - - List<Map<String, dynamic>> _elementsToJson( - List<Map<String, dynamic>> elements, - ) { - return elements.map((e) { - final copy = Map<String, dynamic>.from(e); - if (e['position'] is Offset) { - copy['position'] = { - 'dx': (e['position'] as Offset).dx, - 'dy': (e['position'] as Offset).dy, - }; - } - if (e['size'] is Size) { - copy['size'] = { - 'width': (e['size'] as Size).width, - 'height': (e['size'] as Size).height, - }; - } - return copy; - }).toList(); - } - - List<Map<String, dynamic>> _jsonToElements(List<dynamic> jsonList) { - return jsonList.map((item) { - final e = Map<String, dynamic>.from(item); - if (e['position'] is Map) { - e['position'] = Offset(e['position']['dx'], e['position']['dy']); - } - if (e['size'] is Map) { - e['size'] = Size(e['size']['width'], e['size']['height']); - } - e['rotation'] = (e['rotation'] as num).toDouble(); - return e; - }).toList(); - } - - void _handleBackNavigation() { - // If in magic draw mode, just close tool first - if (_isMagicDrawActive) { - _saveAndCloseMagicDraw(); - return; - } - - if (!_hasUnsavedChanges && widget.existingFile != null) { - Navigator.pop(context); - return; - } + // --- DRAWING HELPERS --- - 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 - // Note: The redirect logic is handled in _saveCanvas for new files. - // For existing files, we pop here. - if (mounted && widget.existingFile != null) { - Navigator.pop(context); - } - }, - child: const Text("Save"), - ), - ], - ), - ); + void _onPanUpdate(DragUpdateDetails details) { + setState(() { + _currentPoints.add( + DrawingPoint(offset: details.localPosition, paint: Paint()), + ); + }); } - 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(), - ), + Future<void> _onPanEnd(DragEndDetails details) async { + setState(() { + if (_currentPoints.isNotEmpty) { + if (_isMagicDrawActive) { + // ADD TO MAGIC PATHS (Temporary mask) + _magicPaths.add( + DrawingPath( + points: List.from(_currentPoints), + color: _selectedColor, + strokeWidth: _strokeWidth, + isEraser: _isEraser, ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text("Cancel"), - ), - FilledButton( - onPressed: () => Navigator.pop(ctx, nameController.text.trim()), - child: const Text("Save"), - ), - ], - ), - ); + ); + _currentPoints = []; + } else { + // NORMAL DRAWING + _paths.add( + DrawingPath( + points: List.from(_currentPoints), + color: _selectedColor, + strokeWidth: _strokeWidth, + isEraser: _isEraser, + ), + ); + _currentPoints = []; + _hasUnsavedChanges = true; + _resetInactivityTimer(); + } + } + }); } - // Helper to generate preview image - Future<String?> _generatePreviewImage() async { + Future<void> _processInpainting(String prompt) async { + if (prompt.isEmpty) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text("Please enter a prompt!"))); + return; + } + + setState(() => _isInpainting = true); + _resetInactivityTimer(); + try { - final boundary = - _canvasGlobalKey.currentContext?.findRenderObject() - as RenderRepaintBoundary?; - if (boundary == null) return null; + // 1. Check for Image Layers + bool hasImageLayers = elements.any((e) => e['type'] == 'file_image'); - // Capture image with lower pixel ratio for preview thumbnail - final ui.Image image = await boundary.toImage(pixelRatio: 1.0); - final ByteData? byteData = await image.toByteData( - format: ui.ImageByteFormat.png, - ); + if (hasImageLayers) { + // --- INPAINTING FLOW (Existing) --- + if (_tempBaseImage == null) { + _tempBaseImage = await _captureCanvasToFile(); + } - if (byteData == null) return null; - final Uint8List pngBytes = byteData.buffer.asUint8List(); + if (_tempBaseImage == null) return; - final directory = await getApplicationDocumentsDirectory(); - final previewDir = Directory('${directory.path}/previews'); - if (!await previewDir.exists()) { - await previewDir.create(recursive: true); - } + File? maskFile = await _generateMaskImageFromPaths( + _magicPaths, + _canvasSize, + _tempBaseImage, + ); - final String fileName = - "preview_${DateTime.now().millisecondsSinceEpoch}.png"; - final String filePath = '${previewDir.path}/$fileName'; + if (maskFile == null) throw Exception("Failed to generate mask"); - final File imgFile = File(filePath); - await imgFile.writeAsBytes(pngBytes); - return filePath; - } catch (e) { - debugPrint("Error generating preview: $e"); - return null; - } - } + final String? newImageUrl = await FlaskService().inpaintImage( + imagePath: _tempBaseImage!.path, + maskPath: maskFile.path, + prompt: prompt, + ); - Future<void> _saveCanvas() async { - try { - String fileName = "Canvas ${DateTime.now().toString().split(' ')[0]}"; - bool isNewFile = widget.existingFile == null; + _addGeneratedImage(newImageUrl); - // 1. IF NEW FILE: Ask user for name - if (isNewFile) { - final userFileName = await _showNameDialog(); - if (userFileName == null || userFileName.isEmpty) return; // Cancelled - fileName = userFileName; - } + } else { + // --- SKETCH-TO-IMAGE FLOW (New) --- + // Capture the entire canvas (strokes only since no images exist) + File? sketchFile = await _captureCanvasToFile(); + if (sketchFile == null) throw Exception("Failed to capture sketch"); - // 2. Generate Preview - final String? previewPath = await _generatePreviewImage(); + final String? newImageUrl = await FlaskService().sketchToImage( + sketchPath: sketchFile.path, + userPrompt: prompt, + stylePrompt: "high quality, realistic", // Default style + ); - // 3. Serialize Elements AND Paths (Drawing) to JSON - final jsonList = _elementsToJson(elements); - final pathsJson = _paths.map((p) => p.toMap()).toList(); + _addGeneratedImage(newImageUrl); + } - final saveData = { - 'elements': jsonList, - 'paths': pathsJson, - 'width': _canvasSize.width, // Saving Width - 'height': _canvasSize.height, // Saving Height - 'preview_path': previewPath, // Saving Preview Path - }; - final jsonString = jsonEncode(saveData); + } catch (e) { + debugPrint("Generation Error: $e"); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text("Generation failed."))); + } finally { + setState(() { + _isInpainting = false; + _tempBaseImage = null; // Reset base image + _magicPaths.clear(); // Always clear magic paths on end + }); + } + } - final directory = await getTemporaryDirectory(); - final tempFile = File( - '${directory.path}/canvas_temp_${DateTime.now().millisecondsSinceEpoch}.json', + void _addGeneratedImage(String? newImageUrl) { + if (newImageUrl != null) { + debugPrint("✅ Adding generated image to canvas: $newImageUrl"); + setState(() { + elements.add({ + 'id': 'gen_${DateTime.now().millisecondsSinceEpoch}', + 'type': 'file_image', + 'content': newImageUrl, + 'position': const Offset(0, 0), + 'size': _canvasSize, + 'rotation': 0.0, + }); + // Clear magic paths now that operation is done + _magicPaths.clear(); + }); + } + } + + // Updated to generate mask as: Base Image + Drawing Strokes + Future<File?> _generateMaskImageFromPaths( + List<DrawingPath> paths, + Size size, + File? baseImageFile, + ) async { + try { + final recorder = ui.PictureRecorder(); + final canvas = Canvas( + recorder, + Rect.fromLTWH(0, 0, size.width, size.height), ); - 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); + // 1. Draw Base Image First (if available) + if (baseImageFile != null) { + final data = await baseImageFile.readAsBytes(); + final codec = await ui.instantiateImageCodec(data); + final frameInfo = await codec.getNextFrame(); + final baseImage = frameInfo.image; + + paintImage( + canvas: canvas, + rect: Rect.fromLTWH(0, 0, size.width, size.height), + image: baseImage, + fit: BoxFit.cover, // Or contain, depending on your logic + ); } else { - // Save as new file - await _fileService.saveFile( - tempFile, - widget.projectId, - name: fileName, - description: "Editable Canvas Board", + // Fallback to black if no base image (shouldn't happen based on logic) + canvas.drawRect( + Rect.fromLTWH(0, 0, size.width, size.height), + Paint()..color = Colors.black, ); } - if (await tempFile.exists()) await tempFile.delete(); + // 2. Draw Strokes ON TOP of the base image + for (final path in paths) { + final paint = + Paint() + ..color = path.color // Use the drawing color (e.g. Blue) + ..strokeWidth = path.strokeWidth + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round + ..style = PaintingStyle.stroke; - setState(() => _hasUnsavedChanges = false); + 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, + ); + } + } - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text("Canvas Saved Successfully")), - ); - if (isNewFile) { - Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (_) => ProjectFilePage(projectId: widget.projectId), + final picture = recorder.endRecording(); + final img = await picture.toImage( + size.width.toInt(), + size.height.toInt(), + ); + final byteData = await img.toByteData(format: ui.ImageByteFormat.png); + if (byteData == null) return null; + + final tempDir = await getTemporaryDirectory(); + final file = File( + '${tempDir.path}/mask_${DateTime.now().millisecondsSinceEpoch}.png', + ); + await file.writeAsBytes(byteData.buffer.asUint8List()); + return file; + } catch (e) { + debugPrint("Mask Generation Error: $e"); + return null; + } + } + + // Just toggles state; drawing data stays in _paths and is saved via _saveCanvas + Future<void> _saveAndCloseMagicDraw() async { + setState(() { + _isMagicDrawActive = false; + _magicPaths.clear(); // Clear magic strokes if canceled + _tempBaseImage = null; + }); + } + + PreferredSizeWidget _buildAppBar() { + return AppBar( + leadingWidth: 160, + leading: SafeArea( + child: Row( + children: [ + IconButton( + icon: SvgPicture.asset( + 'assets/icons/arrow-left-s-line.svg', + width: 22, + colorFilter: const ColorFilter.mode( + Colors.black, + BlendMode.srcIn, + ), + ), + onPressed: () { + if (_isMagicDrawActive) { + _saveAndCloseMagicDraw(); + } else { + _handleBackNavigation(); + } + }, ), - ); - } - } - } catch (e) { - debugPrint("Save Error: $e"); - } + IconButton( + icon: SvgPicture.asset( + 'assets/icons/arrow-go-back-line.svg', + width: 22, + colorFilter: ColorFilter.mode( + _changeStack.canUndo ? Colors.black : Colors.grey[400]!, + BlendMode.srcIn, + ), + ), + onPressed: + _changeStack.canUndo + ? () => setState(() => _changeStack.undo()) + : null, + ), + IconButton( + icon: SvgPicture.asset( + 'assets/icons/arrow-go-forward-line.svg', + width: 22, + colorFilter: ColorFilter.mode( + _changeStack.canRedo ? Colors.black : Colors.grey[400]!, + BlendMode.srcIn, + ), + ), + onPressed: + _changeStack.canRedo + ? () => setState(() => _changeStack.redo()) + : null, + ), + ], + ), + ), + backgroundColor: Colors.white, + foregroundColor: Colors.black, + elevation: 0, + actions: [ + 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/save-3-line.svg', + width: 22, + ), + onPressed: _saveCanvas, + ), + IconButton( + icon: SvgPicture.asset( + 'assets/icons/upload-2-line.svg', + width: 22, + ), + onPressed: _exportProject, + ), + IconButton( + icon: SvgPicture.asset( + 'assets/icons/settings-line.svg', + width: 22, + ), + onPressed: _openSettings, + ), + const SizedBox(width: 8), + ], + ), + ), + ], + ); } - 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 Map && decoded.containsKey('elements')) { - // New format with dimensions - elements = _jsonToElements(decoded['elements']); - if (decoded['paths'] != null) { - _paths = - (decoded['paths'] as List) - .map((p) => DrawingPath.fromMap(p)) - .toList(); - } else { - _paths = []; - } - // [FIX] 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 - } - } else if (decoded is List) { - // Legacy support - elements = _jsonToElements(decoded); - _paths = []; - } - _hasUnsavedChanges = false; - }); - if (widget.injectedMedia != null) { - final oldState = _getCurrentState(); - setState(() { - elements.add({ - 'id': 'shared_${DateTime.now().millisecondsSinceEpoch}', - 'type': 'file_image', - 'content': widget.injectedMedia!.path, - 'position': const Offset(50, 50), - 'size': const Size(150, 150), - 'rotation': 0.0, - }); + Future<void> _pickImageFromGallery() async { + final List<XFile> images = await _picker.pickMultiImage(); + if (images.isNotEmpty) { + final oldState = _getCurrentState(); + setState(() { + for (int i = 0; i < images.length; i++) { + elements.add({ + 'id': '${DateTime.now().millisecondsSinceEpoch}_$i', + 'type': 'file_image', + 'content': images[i].path, + 'position': const Offset(50, 50), + 'size': const Size(150, 150), + 'rotation': 0.0, }); - _recordChange(oldState); } - } - } catch (e) { - debugPrint("Error loading canvas: $e"); + _hasUnsavedChanges = true; + }); + _recordChange(oldState); } } - - void _openLayers() => _showComingSoon('Layers'); - void _openSettings() => _showComingSoon('Settings'); - void _showComingSoon([dynamic feature]) => ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Coming soon'))); } // ============================================================================= @@ -2012,4 +2047,269 @@ class _BottomBarItem extends StatelessWidget { ), ); } -} -\ No newline at end of file +} + +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/project_file_page.dart b/lib/ui/pages/project_file_page.dart @@ -10,6 +10,7 @@ import '../../data/repos/project_repo.dart'; import '../widgets/bottom_bar.dart'; import 'create_file_page.dart'; import 'canvas_board_page.dart'; +import '../widgets/top_bar.dart'; import 'package:image/image.dart' as img; @@ -202,7 +203,6 @@ class _ProjectFilePageState extends State<ProjectFilePage> { await _loadEverything(); } - Future<void> _deleteFile(FileModel file) async { final confirm = await showDialog<bool>( context: context, @@ -244,7 +244,6 @@ class _ProjectFilePageState extends State<ProjectFilePage> { } } - // --------------------------------------- // CREATE FILE // --------------------------------------- @@ -257,13 +256,30 @@ class _ProjectFilePageState extends State<ProjectFilePage> { ).then((_) => _loadEverything()); } + String _formatRelative(DateTime date) { + final now = DateTime.now(); + final diff = now.difference(date); + + if (diff.inDays > 0) { + return "${diff.inDays} day${diff.inDays > 1 ? 's' : ''} ago"; + } + if (diff.inHours > 0) { + return "${diff.inHours} hour${diff.inHours > 1 ? 's' : ''} ago"; + } + if (diff.inMinutes > 0) { + return "${diff.inMinutes} min ago"; + } + return "just now"; + } + // --------------------------------------- // FILE CARD (Same UI but thumbnail updated) // --------------------------------------- Widget _fileCard(FileModel file) { - final date = DateFormat.yMMMd().format(file.lastUpdated); final meta = _fileMetadata[file.id] ?? {}; final preview = meta["preview"] ?? ""; + final dimensions = meta["dimensions"] ?? "Unknown"; + final realPreview = preview.isNotEmpty && File(preview).existsSync() ? preview @@ -273,97 +289,120 @@ class _ProjectFilePageState extends State<ProjectFilePage> { onTap: () => _openFile(file), child: Container( margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(16), border: Border.all(color: const Color(0xFFE4E4E7)), ), + + // ❗ NO padding here — keeps left flush child: Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - // --------------------------------------- - // ✔ NEW THUMBNAIL SIZE (HomePage style) - // --------------------------------------- - Container( - width: 80, - height: 80, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - color: Colors.grey[300], + // ---------- THUMBNAIL (FLUSH) ---------- + ClipRRect( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + bottomLeft: Radius.circular(16), ), - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.file( - File(realPreview), - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => const Icon(Icons.image), - ), + child: Image.file( + File(realPreview), + width: 120, + height: 120, + fit: BoxFit.cover, + errorBuilder: + (_, __, ___) => Container( + width: 120, + height: 120, + color: Colors.grey[300], + child: const Icon(Icons.broken_image), + ), ), ), + // ---------- SPACING ---------- const SizedBox(width: 12), - // --------------------------------------- - // TEXT INFO (unchanged UI) - // --------------------------------------- + // ---------- RIGHT SIDE TEXT ---------- Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - _breadcrumbFor(file), - style: TextStyle( - fontSize: 11, - color: Colors.grey[600], - fontFamily: 'GeneralSans', + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Breadcrumb + Text( + _breadcrumbFor(file), + style: TextStyle( + fontSize: 11, + color: Colors.grey[600], + fontFamily: 'GeneralSans', + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + + // Name + Text( + file.name, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + fontFamily: 'GeneralSans', + ), ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), + const SizedBox(height: 6), - Text( - file.name, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - fontFamily: 'GeneralSans', + // Dimensions + Text( + dimensions, + style: TextStyle( + fontSize: 13, + color: Colors.grey[700], + fontFamily: 'GeneralSans', + ), ), - ), - const SizedBox(height: 2), + const SizedBox(height: 6), - Text( - "Edited $date", - style: TextStyle( - fontSize: 12, - color: Colors.grey[600], - fontFamily: 'GeneralSans', + // Updated time + Text( + "Last edited • ${_formatRelative(file.lastUpdated)}", + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + fontFamily: 'GeneralSans', + ), ), - ), - ], + ], + ), ), ), - const SizedBox(width: 8), + // ---------- MENU ---------- PopupMenuButton<String>( onSelected: (value) => _handleFileMenuAction(file, value), itemBuilder: - (context) => [ - const PopupMenuItem(value: "open", child: Text("Open")), - const PopupMenuItem(value: "rename", child: Text("Rename")), - const PopupMenuItem(value: "delete", child: Text("Delete")), + (_) => const [ + PopupMenuItem(value: "open", child: Text("Open")), + PopupMenuItem(value: "rename", child: Text("Rename")), + PopupMenuItem(value: "delete", child: Text("Delete")), ], icon: const Icon(Icons.more_vert, size: 20), ), + const SizedBox(width: 4), // slight right breathing space ], ), ), ); } + + + // --------------------------------------- // BREADCRUMB // --------------------------------------- @@ -402,17 +441,24 @@ class _ProjectFilePageState extends State<ProjectFilePage> { Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xFFF7F7F8), - appBar: AppBar( - backgroundColor: const Color(0xFFF7F7F8), - elevation: 0, - title: const Text( - "Project Files", - style: TextStyle( - fontFamily: 'GeneralSans', - fontWeight: FontWeight.w500, - color: Color(0xFF27272A), - ), - ), + appBar: TopBar( + currentProjectId: widget.projectId, + onBack: () => Navigator.pop(context), + titleOverride: "Project Files", + onProjectChanged: (project) { + // When user switches project from dropdown, + // refresh this page with the new projectId + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (_) => ProjectFilePage(projectId: project.id!), + ), + ); + }, + hideDropdown: true, + onSettingsPressed: () {}, + onLayoutToggle: () {}, + isAlternateView: false, ), bottomNavigationBar: BottomBar( @@ -578,13 +624,29 @@ class _ProjectFilePageState extends State<ProjectFilePage> { // --------------------------------------- Widget _buildEventDropdown() { return Container( + height: 36, padding: const EdgeInsets.symmetric(horizontal: 12), decoration: BoxDecoration( - color: Colors.grey[200], - borderRadius: BorderRadius.circular(6), + color: const Color(0xFFE4E4E7), // subtle grey + borderRadius: BorderRadius.circular(8), ), child: DropdownButton<ProjectModel>( value: _selectedEvent, + isExpanded: false, + underline: const SizedBox(), + icon: const Icon( + Icons.keyboard_arrow_down_rounded, + size: 18, + color: Color(0xFF27272A), + ), + style: const TextStyle( + fontFamily: "GeneralSans", + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFF27272A), + ), + dropdownColor: Colors.white, + borderRadius: BorderRadius.circular(8), items: _events .map( @@ -593,22 +655,23 @@ class _ProjectFilePageState extends State<ProjectFilePage> { child: Text( e.title, style: const TextStyle( - fontFamily: 'GeneralSans', + fontFamily: "GeneralSans", fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFF27272A), ), ), ), ) .toList(), - onChanged: (e) { - if (e != null) _onSelectEvent(e); + onChanged: (value) { + if (value != null) _onSelectEvent(value); }, - underline: const SizedBox(), - isExpanded: true, ), ); } - + + // --------------------------------------- // FILTER // --------------------------------------- diff --git a/lib/ui/pages/share_to_file_page.dart b/lib/ui/pages/share_to_file_page.dart @@ -492,98 +492,76 @@ class _ShareToFilePageState extends State<ShareToFilePage> { } Widget _buildRecentFileItem(FileModel file) { - final previewPath = _resolvePreviewPath(file); - - return Container( - margin: const EdgeInsets.only(bottom: 8), - child: InkWell( - onTap: () => _onFileSelected(file), - borderRadius: BorderRadius.circular(16), - child: Container( - padding: const EdgeInsets.fromLTRB(4, 4, 0, 4), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: const Color(0xFFE4E4E7), width: 1), - ), - child: Row( - children: [ - // thumbnail - SizedBox( - width: 72, - height: 72, - child: Center( - child: Container( - width: 66, - height: 66, - decoration: BoxDecoration( - color: Colors.grey[200], - borderRadius: BorderRadius.circular(8), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.08), - blurRadius: 6, - offset: Offset(0, 3), - ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: - previewPath.isNotEmpty && - File(previewPath).existsSync() - ? Image.file( - File(previewPath), - fit: BoxFit.cover, - errorBuilder: (c, e, s) => _placeholderIcon(), - ) - : _placeholderIcon(), + final preview = _resolvePreviewPath(file); + + return InkWell( + onTap: () => _onFileSelected(file), + child: Container( + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Color(0xFFE4E4E7)), + ), + child: Row( + children: [ + // ---- FLUSH LEFT THUMBNAIL ---- + ClipRRect( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + bottomLeft: Radius.circular(16), + ), + child: Image.file( + File(preview), + width: 120, + height: 120, + fit: BoxFit.cover, + errorBuilder: + (_, __, ___) => Container( + width: 120, + height: 120, + color: Colors.grey[300], + child: const Icon(Icons.image), ), - ), - ), ), + ), - const SizedBox(width: 12), + const SizedBox(width: 12), - // info - Expanded( + // ---- TEXT ---- + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ FutureBuilder<String>( future: _getProjectEventLabel(file), - builder: (context, snapshot) { - final label = snapshot.data ?? ""; - return Text( - label, - style: const TextStyle( - fontFamily: 'GeneralSans', - fontSize: 12, - fontWeight: FontWeight.w400, - color: Color(0xFF71717B), + builder: + (_, s) => Text( + s.data ?? "", + style: const TextStyle( + fontSize: 12, + color: Color(0xFF71717B), + fontFamily: "GeneralSans", + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ); - }, ), const SizedBox(height: 6), Text( file.name, style: const TextStyle( - fontFamily: 'GeneralSans', - fontSize: 16, - fontWeight: FontWeight.w500, - color: Color(0xFF27272A), + fontSize: 15, + fontWeight: FontWeight.w600, + fontFamily: "GeneralSans", ), - maxLines: 1, - overflow: TextOverflow.ellipsis, ), const SizedBox(height: 6), Text( _formatDate(file.lastUpdated), style: const TextStyle( - fontFamily: 'GeneralSans', fontSize: 12, color: Color(0xFF71717B), ), @@ -591,104 +569,89 @@ class _ShareToFilePageState extends State<ShareToFilePage> { ], ), ), - ], - ), + ), + ], ), ), ); } - Widget _buildFileItem(FileModel file) { - final previewPath = _resolvePreviewPath(file); - return Container( - margin: const EdgeInsets.only(bottom: 8), - child: InkWell( - onTap: () => _onFileSelected(file), - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.fromLTRB(4, 4, 0, 4), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: const Color(0xFFE4E4E7), width: 1), - ), - child: Row( - children: [ - SizedBox( - width: 72, - height: 72, - child: Center( - child: Container( - width: 66, - height: 66, - decoration: BoxDecoration( - color: Colors.grey[200], - borderRadius: BorderRadius.circular(8), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.08), - blurRadius: 6, - offset: Offset(0, 3), - ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: - previewPath.isNotEmpty && - File(previewPath).existsSync() - ? Image.file( - File(previewPath), - fit: BoxFit.cover, - errorBuilder: (c, e, s) => _placeholderIcon(), - ) - : _placeholderIcon(), + Widget _buildFileItem(FileModel file) { + final preview = _resolvePreviewPath(file); + + return InkWell( + onTap: () => _onFileSelected(file), + child: Container( + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Color(0xFFE4E4E7)), + ), + child: Row( + children: [ + // ---- FLUSH LEFT THUMBNAIL ---- + ClipRRect( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + bottomLeft: Radius.circular(16), + ), + child: Image.file( + File(preview), + width: 120, + height: 120, + fit: BoxFit.cover, + errorBuilder: + (_, __, ___) => Container( + width: 120, + height: 120, + color: Colors.grey[300], + child: const Icon(Icons.image), ), - ), - ), ), + ), - const SizedBox(width: 12), + const SizedBox(width: 12), - Expanded( + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ FutureBuilder<String>( future: _getProjectEventLabel(file), - builder: (context, snapshot) { - final label = snapshot.data ?? ""; - return Text( - label, - style: const TextStyle( - fontFamily: 'GeneralSans', - fontSize: 12, - fontWeight: FontWeight.w400, - color: Color(0xFF71717B), + builder: + (_, s) => Text( + s.data ?? "", + style: const TextStyle( + fontSize: 12, + color: Color(0xFF71717B), + fontFamily: "GeneralSans", + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ); - }, ), + const SizedBox(height: 4), + Text( file.name, style: const TextStyle( - fontFamily: 'GeneralSans', - fontSize: 16, - fontWeight: FontWeight.w500, + fontSize: 15, + fontWeight: FontWeight.w600, color: Color(0xFF27272A), + fontFamily: "GeneralSans", ), - maxLines: 1, - overflow: TextOverflow.ellipsis, ), - const SizedBox(height: 4), + + const SizedBox(height: 6), + Text( _formatDate(file.lastUpdated), style: const TextStyle( - fontFamily: 'GeneralSans', fontSize: 12, color: Color(0xFF71717B), ), @@ -696,8 +659,8 @@ class _ShareToFilePageState extends State<ShareToFilePage> { ], ), ), - ], - ), + ), + ], ), ), );