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