creek

The AI Image Editor of 2030
commit 5495fe41daaee683edf1b55682e2caf9e052b88f
parent dfe1a3d56e61f957881f08376be5847b3660091c
Author: maydayv7 <maydayv7@gmail.com>
Date:   Sat,  6 Dec 2025 03:14:14 +0530

Refactor image_* pages and add moodboard image card

Diffstat:
Mlib/ui/pages/define_brand_page.dart | 399++++++++++++++++---------------------------------------------------------------
Mlib/ui/pages/home_page.dart | 4++--
Mlib/ui/pages/image_analysis_page.dart | 51++++++++++++++-------------------------------------
Mlib/ui/pages/image_details_page.dart | 1048++++++++++++++++++++++---------------------------------------------------------
Mlib/ui/pages/image_save_page.dart | 973++++++++++++++++++-------------------------------------------------------------
Mlib/ui/pages/project_board_page_alternate.dart | 357+++++++++++--------------------------------------------------------------------
Mlib/ui/pages/project_tag_page.dart | 158+++++++++++++++++++------------------------------------------------------------
Mlib/ui/pages/share_handler_page.dart | 37+++++++++++++++----------------------
Mlib/ui/pages/share_to_file_page.dart | 483++++++++++++++-----------------------------------------------------------------
Mlib/ui/pages/share_to_moodboard_page.dart | 196+++++++++++++++++--------------------------------------------------------------
Mlib/ui/widgets/file_card.dart | 212++++++++++++++++++++++++++++---------------------------------------------------
Alib/ui/widgets/moodboard_image_card.dart | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alib/ui/widgets/note_input_sheet.dart | 209+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alib/ui/widgets/primary_button.dart | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alib/ui/widgets/selection_overlay_painter.dart | 115+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alib/ui/widgets/text_field.dart | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
16 files changed, 1473 insertions(+), 3008 deletions(-)

diff --git a/lib/ui/pages/define_brand_page.dart b/lib/ui/pages/define_brand_page.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:creekui/services/project_service.dart'; import 'package:creekui/ui/styles/variables.dart'; +import 'package:creekui/ui/widgets/text_field.dart'; +import 'package:creekui/ui/widgets/primary_button.dart'; import 'project_detail_page.dart'; class DefineBrandPage extends StatefulWidget { @@ -18,10 +20,7 @@ class DefineBrandPage extends StatefulWidget { } class _DefineBrandPageState extends State<DefineBrandPage> { - // Service final ProjectService _projectService = ProjectService(); - - // Controllers final _projectNameController = TextEditingController(); final _descriptionController = TextEditingController(); final _problemController = TextEditingController(); @@ -29,7 +28,6 @@ class _DefineBrandPageState extends State<DefineBrandPage> { final _whereWillAppearController = TextEditingController(); final _competitorInputController = TextEditingController(); - // State bool _isLoading = false; final List<String> _keywords = ['Colour', 'Fonts', 'Composition']; final List<Map<String, String>> _competitorBrands = [ @@ -68,16 +66,14 @@ class _DefineBrandPageState extends State<DefineBrandPage> { return; } - setState(() { - _isLoading = true; - }); + setState(() => _isLoading = true); try { // 2. Prepare data final String title = _projectNameController.text.trim(); final String description = _descriptionController.text.trim(); - // 3. Call the Service and capture the NEW ID + // 3. Call service and capture new ID final int newId = await _projectService.createProject( title, description: description.isEmpty ? null : description, @@ -103,11 +99,7 @@ class _DefineBrandPageState extends State<DefineBrandPage> { ); } } finally { - if (mounted) { - setState(() { - _isLoading = false; - }); - } + if (mounted) setState(() => _isLoading = false); } } @@ -125,7 +117,7 @@ class _DefineBrandPageState extends State<DefineBrandPage> { @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: const Color(0xFFFAFAFA), + backgroundColor: Variables.background, body: SafeArea( child: Column( children: [ @@ -142,8 +134,6 @@ class _DefineBrandPageState extends State<DefineBrandPage> { color: Variables.textSecondary, ), onPressed: () => Navigator.pop(context), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), ), ], ), @@ -178,87 +168,51 @@ class _DefineBrandPageState extends State<DefineBrandPage> { ), ), ), - const SizedBox(height: 16), - - // Title and Subtitle - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Define Your Brand', - style: TextStyle( - fontFamily: 'GeneralSans', - fontSize: 20, - fontWeight: FontWeight.w500, - color: Variables.textPrimary, - height: 24 / 20, - ), - ), - const SizedBox(height: 4), - Text( - 'Answer a few quick questions to help us craft your unique style guide.', - style: TextStyle( - fontFamily: 'GeneralSans', - fontSize: 14, - fontWeight: FontWeight.w400, - color: Variables.textSecondary, - height: 20 / 14, - ), - ), - ], + Text('Define Your Brand', style: Variables.headerStyle), + const SizedBox(height: 4), + Text( + 'Answer a few quick questions to help us craft your unique style guide.', + style: Variables.captionStyle.copyWith(fontSize: 14), ), - const SizedBox(height: 32), - - // Form Fields - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildFormField( - label: 'Project Name', - hintText: 'Enter project name', - controller: _projectNameController, - required: true, - ), - const SizedBox(height: 16), - _buildFormField( - label: 'What do you want & who is it for.', - hintText: 'Describe your work and your audience.', - controller: _descriptionController, - maxLines: 3, - required: false, - ), - const SizedBox(height: 16), - _buildFormField( - label: 'What problem you solve.', - hintText: - 'Explain the main issue your brand addresses.', - controller: _problemController, - maxLines: 3, - required: false, - ), - const SizedBox(height: 16), - _buildFormField( - label: 'Long-term goal for the brand.', - hintText: 'E.g. - Improving food availability...', - controller: _goalController, - required: false, - ), - const SizedBox(height: 32), - _buildKeywordsSection(), - const SizedBox(height: 32), - _buildCompetitorBrandsSection(), - const SizedBox(height: 32), - _buildFormField( - label: 'Where will the brand appear', - hintText: 'Banners, Posters, Instagram..', - controller: _whereWillAppearController, - required: false, - ), - ], + CommonTextField( + label: 'Project Name', + hintText: 'Enter project name', + controller: _projectNameController, + isRequired: true, ), - const SizedBox(height: 100), // Space for bottom button + const SizedBox(height: 16), + CommonTextField( + label: 'What do you want & who is it for.', + hintText: 'Describe your work and your audience.', + controller: _descriptionController, + maxLines: 3, + ), + const SizedBox(height: 16), + CommonTextField( + label: 'What problem you solve.', + hintText: 'Explain the main issue your brand addresses.', + controller: _problemController, + maxLines: 3, + ), + const SizedBox(height: 16), + CommonTextField( + label: 'Long-term goal for the brand.', + hintText: 'E.g. - Improving food availability...', + controller: _goalController, + ), + const SizedBox(height: 32), + _buildKeywordsSection(), + const SizedBox(height: 32), + _buildCompetitorBrandsSection(), + const SizedBox(height: 32), + CommonTextField( + label: 'Where will the brand appear', + hintText: 'Banners, Posters, Instagram..', + controller: _whereWillAppearController, + ), + const SizedBox(height: 100), ], ), ), @@ -271,144 +225,27 @@ class _DefineBrandPageState extends State<DefineBrandPage> { bottomNavigationBar: Container( padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), decoration: BoxDecoration( - color: const Color(0xFFFAFAFA), + color: Variables.background, boxShadow: [ BoxShadow( - color: Colors.black.withValues(alpha: 0.05), + color: Colors.black.withOpacity(0.05), blurRadius: 10, offset: const Offset(0, -2), ), ], ), child: SafeArea( - child: SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: _isLoading ? null : _handleFinish, - style: ElevatedButton.styleFrom( - backgroundColor: Variables.textPrimary, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(112), - ), - elevation: 0, - ), - child: - _isLoading - ? const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator( - color: Colors.white, - strokeWidth: 2, - ), - ) - : Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - 'Create Project', - style: TextStyle( - fontFamily: 'GeneralSans', - fontSize: 14, - fontWeight: FontWeight.w500, - color: Colors.white, - ), - ), - const SizedBox(width: 12), - SvgPicture.asset( - 'assets/icons/generate_icon.svg', - width: 18, - height: 18, - colorFilter: const ColorFilter.mode( - Colors.white, - BlendMode.srcIn, - ), - ), - ], - ), - ), + child: PrimaryButton( + text: 'Create Project', + isLoading: _isLoading, + onPressed: _handleFinish, + iconPath: 'assets/icons/generate_icon.svg', ), ), ), ); } - Widget _buildFormField({ - required String label, - required String hintText, - required TextEditingController controller, - int maxLines = 1, - bool required = false, - }) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - label, - style: TextStyle( - fontFamily: 'GeneralSans', - fontSize: 14, - fontWeight: FontWeight.w500, - color: Variables.textPrimary, - height: 20 / 14, - ), - ), - if (required) - Text( - '*', - style: TextStyle( - fontFamily: 'GeneralSans', - fontSize: 12, - fontWeight: FontWeight.w500, - color: const Color(0xFF4F39F6), - height: 16 / 12, - ), - ), - ], - ), - const SizedBox(height: 6), - Container( - decoration: BoxDecoration( - color: const Color(0xFFE4E4E7), - borderRadius: BorderRadius.circular(8), - ), - child: TextField( - controller: controller, - maxLines: maxLines, - style: TextStyle( - fontFamily: 'GeneralSans', - fontSize: 14, - fontWeight: FontWeight.w400, - color: Variables.textPrimary, - height: 20 / 14, - ), - decoration: InputDecoration( - hintText: hintText, - hintStyle: TextStyle( - fontFamily: 'GeneralSans', - fontSize: 14, - fontWeight: FontWeight.w400, - color: Variables.textSecondary, - height: 20 / 14, - ), - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - ), - ), - ), - ], - ); - } - Widget _buildKeywordsSection() { return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -417,22 +254,13 @@ class _DefineBrandPageState extends State<DefineBrandPage> { children: [ Text( '2-3 vibe keywords', - style: TextStyle( - fontFamily: 'GeneralSans', - fontSize: 14, - fontWeight: FontWeight.w500, - color: Variables.textPrimary, - height: 20 / 14, - ), + style: Variables.bodyStyle.copyWith(fontWeight: FontWeight.w500), ), Text( '*', - style: TextStyle( - fontFamily: 'GeneralSans', - fontSize: 12, - fontWeight: FontWeight.w500, + style: Variables.bodyStyle.copyWith( color: const Color(0xFF4F39F6), - height: 16 / 12, + fontSize: 12, ), ), ], @@ -457,21 +285,14 @@ class _DefineBrandPageState extends State<DefineBrandPage> { children: [ Text( keyword, - style: TextStyle( - fontFamily: 'GeneralSans', + style: Variables.captionStyle.copyWith( fontSize: 12, - fontWeight: FontWeight.w400, color: Variables.textPrimary, - height: 16 / 12, ), ), const SizedBox(width: 8), GestureDetector( - onTap: () { - setState(() { - _keywords.remove(keyword); - }); - }, + onTap: () => setState(() => _keywords.remove(keyword)), child: Icon( Icons.close, size: 16, @@ -489,7 +310,7 @@ class _DefineBrandPageState extends State<DefineBrandPage> { vertical: 6, ), decoration: BoxDecoration( - border: Border.all(color: const Color(0xFFE4E4E7), width: 1), + border: Border.all(color: Variables.borderSubtle), borderRadius: BorderRadius.circular(48), ), child: Row( @@ -497,12 +318,9 @@ class _DefineBrandPageState extends State<DefineBrandPage> { children: [ Text( 'Add More', - style: TextStyle( - fontFamily: 'GeneralSans', + style: Variables.captionStyle.copyWith( fontSize: 12, - fontWeight: FontWeight.w400, color: Variables.textPrimary, - height: 16 / 12, ), ), const SizedBox(width: 4), @@ -525,60 +343,23 @@ class _DefineBrandPageState extends State<DefineBrandPage> { children: [ Text( '2-3 reference/competitor brands.', - style: TextStyle( - fontFamily: 'GeneralSans', - fontSize: 14, - fontWeight: FontWeight.w500, - color: Variables.textPrimary, - height: 20 / 14, - ), + style: Variables.bodyStyle.copyWith(fontWeight: FontWeight.w500), ), Text( '*', - style: TextStyle( - fontFamily: 'GeneralSans', - fontSize: 12, - fontWeight: FontWeight.w500, + style: Variables.bodyStyle.copyWith( color: const Color(0xFF4F39F6), - height: 16 / 12, + fontSize: 12, ), ), ], ), const SizedBox(height: 6), - Container( - decoration: BoxDecoration( - color: const Color(0xFFE4E4E7), - borderRadius: BorderRadius.circular(8), - ), - child: TextField( - controller: _competitorInputController, - onSubmitted: (_) => _addCompetitorBrand(), - style: TextStyle( - fontFamily: 'GeneralSans', - fontSize: 14, - fontWeight: FontWeight.w400, - color: Variables.textPrimary, - height: 20 / 14, - ), - decoration: InputDecoration( - hintText: 'Type the name of brands here...', - hintStyle: TextStyle( - fontFamily: 'GeneralSans', - fontSize: 14, - fontWeight: FontWeight.w400, - color: Variables.textSecondary, - height: 20 / 14, - ), - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - ), - ), + CommonTextField( + label: '', + hintText: 'Type the name of brands here...', + controller: _competitorInputController, + onSubmitted: (_) => _addCompetitorBrand(), ), if (_competitorBrands.isNotEmpty) ...[ const SizedBox(height: 8), @@ -596,11 +377,8 @@ class _DefineBrandPageState extends State<DefineBrandPage> { vertical: 8, ), decoration: BoxDecoration( - color: const Color(0xFFFAFAFA), - border: Border.all( - color: const Color(0xFFE4E4E7), - width: 1, - ), + color: Variables.surfaceSubtle, + border: Border.all(color: Variables.borderSubtle), borderRadius: BorderRadius.circular(64), ), child: Row( @@ -609,40 +387,28 @@ class _DefineBrandPageState extends State<DefineBrandPage> { Container( width: 30, height: 30, - decoration: BoxDecoration( + decoration: const BoxDecoration( color: Colors.white, shape: BoxShape.circle, ), child: Center( child: Text( brand['initial'] ?? '', - style: TextStyle( - fontFamily: 'GeneralSans', + style: Variables.captionStyle.copyWith( + fontWeight: FontWeight.bold, fontSize: 12, - fontWeight: FontWeight.w500, - color: Variables.textPrimary, ), ), ), ), const SizedBox(width: 8), - Text( - brand['name'] ?? '', - style: TextStyle( - fontFamily: 'GeneralSans', - fontSize: 14, - fontWeight: FontWeight.w400, - color: Variables.textSecondary, - height: 20 / 14, - ), - ), + Text(brand['name'] ?? '', style: Variables.bodyStyle), const SizedBox(width: 8), GestureDetector( - onTap: () { - setState(() { - _competitorBrands.removeAt(index); - }); - }, + onTap: + () => setState( + () => _competitorBrands.removeAt(index), + ), child: Icon( Icons.close, size: 16, @@ -680,11 +446,8 @@ class _DefineBrandPageState extends State<DefineBrandPage> { ElevatedButton( onPressed: () { final val = controller.text.trim(); - if (val.isNotEmpty && !_keywords.contains(val)) { - setState(() { - _keywords.add(val); - }); - } + if (val.isNotEmpty && !_keywords.contains(val)) + setState(() => _keywords.add(val)); Navigator.pop(context); }, child: const Text('Add'), diff --git a/lib/ui/pages/home_page.dart b/lib/ui/pages/home_page.dart @@ -40,7 +40,7 @@ class _HomePageState extends State<HomePage> { final Map<int, List<String>> _projectPreviews = {}; Map<String, Map<String, String>> _fileMetadata = {}; bool _isLoading = true; - final String _userName = "Alex"; + final String _userName = "Alex"; // TODO @override void initState() { @@ -619,7 +619,7 @@ class _HomePageState extends State<HomePage> { ), ); }).toList()), - const SizedBox(height: 24), + const SizedBox(height: 12), ], SectionHeader( title: 'Projects', diff --git a/lib/ui/pages/image_analysis_page.dart b/lib/ui/pages/image_analysis_page.dart @@ -4,7 +4,8 @@ import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import 'package:path_provider/path_provider.dart'; import 'package:creekui/services/analyze/image_analyzer.dart'; -import 'package:creekui/styles/variables.dart'; +import 'package:creekui/ui/styles/variables.dart'; +import 'package:creekui/ui/widgets/empty_state.dart'; class ImageAnalysisPage extends StatefulWidget { const ImageAnalysisPage({super.key}); @@ -53,7 +54,6 @@ class _ImageAnalysisPageState extends State<ImageAnalysisPage> { final appDir = await getApplicationDocumentsDirectory(); final fileName = sourcePath.split('/').last; final targetPath = '${appDir.path}/$fileName'; - final file = File(sourcePath); await file.copy(targetPath); @@ -126,7 +126,6 @@ class _ImageAnalysisPageState extends State<ImageAnalysisPage> { @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; return Scaffold( backgroundColor: Colors.white, appBar: AppBar( @@ -136,13 +135,9 @@ class _ImageAnalysisPageState extends State<ImageAnalysisPage> { icon: const Icon(Icons.arrow_back, color: Colors.black), onPressed: () => Navigator.pop(context), ), - title: const Text( + title: Text( "Image Analysis", - style: TextStyle( - color: Colors.black, - fontFamily: 'GeneralSans', - fontWeight: FontWeight.bold, - ), + style: Variables.headerStyle.copyWith(fontSize: 20), ), actions: [ if (_selectedImage != null) @@ -164,11 +159,11 @@ class _ImageAnalysisPageState extends State<ImageAnalysisPage> { width: double.infinity, height: 250, decoration: BoxDecoration( - color: Colors.grey[100], + color: Variables.borderSubtle, borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( - color: Colors.black.withValues(alpha: 0.05), + color: Colors.black.withOpacity(0.05), blurRadius: 10, offset: const Offset(0, 4), ), @@ -182,23 +177,10 @@ class _ImageAnalysisPageState extends State<ImageAnalysisPage> { if (_selectedImage != null) Image.file(_selectedImage!, fit: BoxFit.contain) else - Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.bug_report_outlined, - size: 48, - color: Colors.grey[400], - ), - const SizedBox(height: 12), - Text( - "Select image to test full suite", - style: TextStyle( - fontFamily: 'GeneralSans', - color: Colors.grey[500], - ), - ), - ], + const EmptyState( + icon: Icons.bug_report_outlined, + title: "No image selected", + subtitle: "Select an image to test full suite", ), if (_isAnalyzing) Container( @@ -237,13 +219,9 @@ class _ImageAnalysisPageState extends State<ImageAnalysisPage> { // Raw JSON Result if (_analysisResult != null) ...[ - const Text( + Text( "Results", - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - fontFamily: 'GeneralSans', - ), + style: Variables.headerStyle.copyWith(fontSize: 18), ), const SizedBox(height: 12), _buildJsonViewer(_analysisResult!), @@ -254,8 +232,8 @@ class _ImageAnalysisPageState extends State<ImageAnalysisPage> { ), floatingActionButton: FloatingActionButton.extended( onPressed: _showSourceSelector, - backgroundColor: colorScheme.primary, - foregroundColor: colorScheme.onPrimary, + backgroundColor: Variables.textPrimary, + foregroundColor: Colors.white, icon: const Icon(Icons.add_photo_alternate), label: Text( _selectedImage == null ? "Select Image" : "Change Image", @@ -268,7 +246,6 @@ class _ImageAnalysisPageState extends State<ImageAnalysisPage> { Widget _buildJsonViewer(Map<String, dynamic> data) { const encoder = JsonEncoder.withIndent(' '); final String prettyJson = encoder.convert(data); - return Container( width: double.infinity, padding: const EdgeInsets.all(16), diff --git a/lib/ui/pages/image_details_page.dart b/lib/ui/pages/image_details_page.dart @@ -1,5 +1,4 @@ import 'dart:io'; -import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:creekui/services/image_service.dart'; @@ -7,47 +6,11 @@ import 'package:creekui/services/note_service.dart'; import 'package:creekui/data/models/note_model.dart'; import 'package:creekui/data/models/image_model.dart'; import 'package:creekui/utils/image_actions_helper.dart'; - -// --- STATE MACHINE FOR SELECTION MODE --- -enum DragHandle { - none, - topLeft, - topRight, - bottomLeft, - bottomRight, - center, // For dragging the entire box -} - -// --- HELPER CLASS FOR TEMPORARY NOTES (Needed for consistency) --- -class TempNote { - final double normX; - final double normY; - final double normWidth; - final double normHeight; - final String content; - final String category; - - TempNote({ - required this.normX, - required this.normY, - required this.normWidth, - required this.normHeight, - required this.content, - required this.category, - }); -} - -// --- EXTENSION TO NORMALIZE RECT --- -extension on Rect { - Rect normalize() { - return Rect.fromLTRB( - left < right ? left : right, - top < bottom ? top : bottom, - left > right ? left : right, - top > bottom ? top : bottom, - ); - } -} +import 'package:creekui/ui/styles/variables.dart'; +import 'package:creekui/ui/widgets/primary_button.dart'; +import 'package:creekui/ui/widgets/empty_state.dart'; +import 'package:creekui/ui/widgets/selection_overlay_painter.dart'; +import 'package:creekui/ui/widgets/note_input_sheet.dart'; class ImageDetailsPage extends StatefulWidget { final String imagePath; @@ -66,18 +29,16 @@ class ImageDetailsPage extends StatefulWidget { } class _ImageDetailsPageState extends State<ImageDetailsPage> { - // Services final NoteService _noteService = NoteService(); final ImageService _imageService = ImageService(); - // State ImageModel? _imageModel; List<NoteModel> _notes = []; List<String> _currentTags = []; bool _isLoading = true; int? _activeNoteId; - // -- DRAWING/RESIZING STATE -- + // Drawing/Resizing bool _isDrawMode = false; bool _isResizing = false; final GlobalKey _imageKey = GlobalKey(); @@ -87,13 +48,10 @@ class _ImageDetailsPageState extends State<ImageDetailsPage> { Rect? _finalSelectionRect; Size? _imageRenderSize; - // Resizing state DragHandle _activeHandle = DragHandle.none; Offset? _startDragLocalOffset; + final double _handleSize = 25.0; - final double _handleSize = 25.0; // Resizing constant - - // Master List of Tags final List<String> _allAvailableTags = [ 'Compositions', 'Subject', @@ -134,8 +92,7 @@ class _ImageDetailsPageState extends State<ImageDetailsPage> { } } - // --- ACTIONS --- - + // Actions void _resetSelectionMode() { setState(() { _isDrawMode = false; @@ -149,15 +106,12 @@ class _ImageDetailsPageState extends State<ImageDetailsPage> { } void _activateDrawMode() { - _resetSelectionMode(); // Reset any previous selection - setState(() { - _isDrawMode = true; // Start initial drawing mode - }); + _resetSelectionMode(); + setState(() => _isDrawMode = true); } void _confirmSelectionAndShowModal() { if (_finalSelectionRect != null) { - // Exit resizing mode before showing the modal setState(() { _isResizing = false; _activeHandle = DragHandle.none; @@ -168,7 +122,6 @@ class _ImageDetailsPageState extends State<ImageDetailsPage> { void _openNotesSheet({int? highlightId}) { setState(() => _activeNoteId = highlightId); - showModalBottomSheet( context: context, isScrollControlled: true, @@ -182,13 +135,10 @@ class _ImageDetailsPageState extends State<ImageDetailsPage> { _activateDrawMode(); }, ), - ).whenComplete(() { - setState(() => _activeNoteId = null); - }); + ).whenComplete(() => setState(() => _activeNoteId = null)); } - // --- DRAWING/RESIZING GESTURES --- - + // Gestures Offset? _getLocalPosition(Offset globalPosition) { final RenderBox? box = _imageKey.currentContext?.findRenderObject() as RenderBox?; @@ -203,14 +153,41 @@ class _ImageDetailsPageState extends State<ImageDetailsPage> { // Clamp coordinates to ensure we don't draw/drag outside the image final dx = local.dx.clamp(0.0, box.size.width); final dy = local.dy.clamp(0.0, box.size.height); - return Offset(dx, dy); } - // --- DRAWING HANDLERS --- + // Resizing Handlers + DragHandle _getDragHandle(Offset pos) { + if (_finalSelectionRect == null) return DragHandle.none; + final rect = _finalSelectionRect!; + + // Check corners first + if (Rect.fromCircle( + center: rect.topLeft, + radius: _handleSize, + ).contains(pos)) + return DragHandle.topLeft; + if (Rect.fromCircle( + center: rect.topRight, + radius: _handleSize, + ).contains(pos)) + return DragHandle.topRight; + if (Rect.fromCircle( + center: rect.bottomLeft, + radius: _handleSize, + ).contains(pos)) + return DragHandle.bottomLeft; + if (Rect.fromCircle( + center: rect.bottomRight, + radius: _handleSize, + ).contains(pos)) + return DragHandle.bottomRight; + if (rect.contains(pos)) return DragHandle.center; + return DragHandle.none; + } + void _onPanStart(DragStartDetails details) { if (_isDrawMode) { - // START DRAWING final pos = _getLocalPosition(details.globalPosition); if (pos == null) return; setState(() { @@ -218,409 +195,166 @@ class _ImageDetailsPageState extends State<ImageDetailsPage> { _currentPos = pos; }); } else if (_isResizing && _finalSelectionRect != null) { - // START RESIZING/MOVING - _onResizeStart(details); + final pos = _getLocalPosition(details.globalPosition); + if (pos == null) return; + final handle = _getDragHandle(pos); + if (handle != DragHandle.none) { + setState(() { + _activeHandle = handle; + if (handle == DragHandle.center) { + _startDragLocalOffset = pos - _finalSelectionRect!.topLeft; + } + }); + } } } void _onPanUpdate(DragUpdateDetails details) { if (_isDrawMode) { - // DRAWING final pos = _getLocalPosition(details.globalPosition); if (pos == null) return; + setState(() => _currentPos = pos); + } else if (_isResizing && _finalSelectionRect != null) { + final pos = _getLocalPosition(details.globalPosition); + if (pos == null || _activeHandle == DragHandle.none) return; + setState(() { - _currentPos = pos; + Rect newRect = _finalSelectionRect!; + final newPoint = pos; + + switch (_activeHandle) { + case DragHandle.topLeft: + newRect = Rect.fromLTRB( + newPoint.dx, + newPoint.dy, + newRect.right, + newRect.bottom, + ); + break; + case DragHandle.topRight: + newRect = Rect.fromLTRB( + newRect.left, + newPoint.dy, + newPoint.dx, + newRect.bottom, + ); + break; + case DragHandle.bottomLeft: + newRect = Rect.fromLTRB( + newPoint.dx, + newRect.top, + newRect.right, + newPoint.dy, + ); + break; + case DragHandle.bottomRight: + newRect = Rect.fromLTRB( + newRect.left, + newRect.top, + newPoint.dx, + newPoint.dy, + ); + break; + case DragHandle.center: + if (_startDragLocalOffset != null) { + final newTopLeft = newPoint - _startDragLocalOffset!; + newRect = Rect.fromLTWH( + newTopLeft.dx, + newTopLeft.dy, + newRect.width, + newRect.height, + ); + } + break; + case DragHandle.none: + return; + } + + if (_imageRenderSize != null) { + final cl = newRect.left.clamp(0.0, _imageRenderSize!.width); + final ct = newRect.top.clamp(0.0, _imageRenderSize!.height); + final cr = newRect.right.clamp(0.0, _imageRenderSize!.width); + final cb = newRect.bottom.clamp(0.0, _imageRenderSize!.height); + newRect = Rect.fromLTRB(cl, ct, cr, cb).normalize(); + } else { + newRect = newRect.normalize(); + } + + if (newRect.width > 10 && newRect.height > 10) + _finalSelectionRect = newRect; }); - } else if (_isResizing && _finalSelectionRect != null) { - // RESIZING/MOVING - _onResizeUpdate(details); } } void _onPanEnd(DragEndDetails details) { if (_isDrawMode && _startPos != null && _currentPos != null) { - // END DRAWING, TRANSITION TO RESIZING MODE final rect = Rect.fromPoints(_startPos!, _currentPos!).normalize(); - if (rect.width < 10 || rect.height < 10) { _resetSelectionMode(); return; } - setState(() { _isDrawMode = false; - _isResizing = true; // Enter resizing/confirming mode + _isResizing = true; _finalSelectionRect = rect; _startPos = null; _currentPos = null; }); } else if (_isResizing) { - // END RESIZING/MOVING - _onResizeEnd(details); - } - } - - // --- RESIZING HANDLERS --- - DragHandle _getDragHandle(Offset pos) { - if (_finalSelectionRect == null) return DragHandle.none; - - final rect = _finalSelectionRect!; - - // Check corners - if (Rect.fromCircle( - center: rect.topLeft, - radius: _handleSize, - ).contains(pos)) { - return DragHandle.topLeft; - } else if (Rect.fromCircle( - center: rect.topRight, - radius: _handleSize, - ).contains(pos)) { - return DragHandle.topRight; - } else if (Rect.fromCircle( - center: rect.bottomLeft, - radius: _handleSize, - ).contains(pos)) { - return DragHandle.bottomLeft; - } else if (Rect.fromCircle( - center: rect.bottomRight, - radius: _handleSize, - ).contains(pos)) { - return DragHandle.bottomRight; - } - // Check if dragging the whole box (center) - else if (rect.contains(pos)) { - return DragHandle.center; - } - - return DragHandle.none; - } - - void _onResizeStart(DragStartDetails details) { - if (!_isResizing || _finalSelectionRect == null) return; - - final pos = _getLocalPosition(details.globalPosition); - if (pos == null) return; - - final handle = _getDragHandle(pos); - if (handle != DragHandle.none) { setState(() { - _activeHandle = handle; - // Calculate offset for moving the entire rect - if (handle == DragHandle.center) { - _startDragLocalOffset = pos - _finalSelectionRect!.topLeft; - } + _activeHandle = DragHandle.none; + _startDragLocalOffset = null; }); } } - void _onResizeUpdate(DragUpdateDetails details) { - if (!_isResizing || - _finalSelectionRect == null || - _activeHandle == DragHandle.none) { - return; - } - - final pos = _getLocalPosition(details.globalPosition); - if (pos == null) return; - - setState(() { - Rect newRect = _finalSelectionRect!; - final newPoint = pos; - - switch (_activeHandle) { - case DragHandle.topLeft: - newRect = Rect.fromLTRB( - newPoint.dx, - newPoint.dy, - newRect.right, - newRect.bottom, - ); - break; - case DragHandle.topRight: - newRect = Rect.fromLTRB( - newRect.left, - newPoint.dy, - newPoint.dx, - newRect.bottom, - ); - break; - case DragHandle.bottomLeft: - newRect = Rect.fromLTRB( - newPoint.dx, - newRect.top, - newRect.right, - newPoint.dy, - ); - break; - case DragHandle.bottomRight: - newRect = Rect.fromLTRB( - newRect.left, - newRect.top, - newPoint.dx, - newPoint.dy, - ); - break; - case DragHandle.center: - if (_startDragLocalOffset != null) { - final newTopLeft = newPoint - _startDragLocalOffset!; - newRect = Rect.fromLTWH( - newTopLeft.dx, - newTopLeft.dy, - newRect.width, - newRect.height, - ); - } - break; - case DragHandle.none: - return; - } - - // Clamp the final rectangle to the image boundaries (0,0 to width, height) - final imageSize = _imageRenderSize; - if (imageSize != null) { - final clampedLeft = newRect.left.clamp(0.0, imageSize.width); - final clampedTop = newRect.top.clamp(0.0, imageSize.height); - final clampedRight = newRect.right.clamp(0.0, imageSize.width); - final clampedBottom = newRect.bottom.clamp(0.0, imageSize.height); - - newRect = - Rect.fromLTRB( - clampedLeft, - clampedTop, - clampedRight, - clampedBottom, - ).normalize(); - } else { - newRect = newRect.normalize(); - } - - // Ensure min size - if (newRect.width > 10 && newRect.height > 10) { - _finalSelectionRect = newRect; - } - }); - } - - void _onResizeEnd(DragEndDetails details) { - if (!_isResizing) return; - setState(() { - _activeHandle = DragHandle.none; - _startDragLocalOffset = null; - }); - } - - // --- ADD NOTE INPUT DIALOG --- + // Show modal void _showAddNoteInputDialog() { - final TextEditingController newNoteController = TextEditingController(); - // Default to the first tag or 'Compositions', ensure it exists in the list to prevent crashes - String newCategory = + String initialCategory = _allAvailableTags.contains('Compositions') ? 'Compositions' - : (_allAvailableTags.isNotEmpty - ? _allAvailableTags.first - : 'General'); + : (_allAvailableTags.firstOrNull ?? 'General'); showModalBottomSheet( context: context, isScrollControlled: true, - backgroundColor: Colors.transparent, // Crucial for the overlay wrapper + backgroundColor: Colors.transparent, builder: (context) { - final mediaQuery = MediaQuery.of(context); - - // 1. Wrap content in StatefulBuilder for Dropdown updates - final modalContent = StatefulBuilder( - builder: (BuildContext context, StateSetter setModalState) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 20, - ), - child: Column( - children: [ - // --- ROW 1: Header (Avatar, Name, Pill Dropdown) --- - Row( - children: [ - // Avatar - const CircleAvatar( - radius: 18, - backgroundColor: Colors.grey, - child: Icon( - Icons.person, - color: Colors.white, - size: 20, - ), - ), - const SizedBox(width: 12), - - // Name - const Text( - "User", - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - color: Colors.black87, - ), - ), - - const Spacer(), - - // Dropdown - Container( - height: 40, - padding: const EdgeInsets.symmetric(horizontal: 16), - decoration: BoxDecoration( - color: const Color(0xFFE0E5FF), - borderRadius: BorderRadius.circular(20), - ), - child: DropdownButtonHideUnderline( - child: DropdownButton<String>( - value: - _allAvailableTags.contains(newCategory) - ? newCategory - : null, - hint: const Text("Type"), - isDense: true, - icon: const Icon( - Icons.keyboard_arrow_down, - size: 20, - color: Colors.black54, - ), - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Colors.black87, - ), - focusColor: Colors.transparent, - dropdownColor: Colors.white, - items: - _allAvailableTags - .map( - (c) => DropdownMenuItem( - value: c, - child: Text(c), - ), - ) - .toList(), - onChanged: (v) { - // Update local state for the modal - setModalState(() { - newCategory = v!; - }); - }, - ), - ), - ), - ], - ), - - const SizedBox(height: 20), - - // --- ROW 2: Input Field & Send Button --- - Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded( - child: TextField( - controller: newNoteController, - autofocus: true, - maxLines: 1, - style: const TextStyle(fontSize: 14), - decoration: InputDecoration( - filled: true, - fillColor: const Color(0xFFF3F4F6), - hintText: "Enter note details...", - hintStyle: TextStyle(color: Colors.grey[600]), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 14, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - ), - ), - ), - const SizedBox(width: 12), - - // Send Button - Container( - margin: const EdgeInsets.only(bottom: 2), - child: IconButton( - icon: const Icon(Icons.send_outlined), - color: Colors.black87, - iconSize: 28, - onPressed: () async { - if (newNoteController.text.isNotEmpty && - _finalSelectionRect != null && - _imageRenderSize != null) { - // Normalization logic - final normalizedRect = - _finalSelectionRect!.normalize(); - final nX = - normalizedRect.center.dx / - _imageRenderSize!.width; - final nY = - normalizedRect.center.dy / - _imageRenderSize!.height; - final nW = - normalizedRect.width / - _imageRenderSize!.width; - final nH = - normalizedRect.height / - _imageRenderSize!.height; - - await _noteService.addNote( - widget.imageId, - newNoteController.text.trim(), - newCategory, - normX: nX, - normY: nY, - normWidth: nW, - normHeight: nH, - ); - - final updatedNotes = await _noteService - .getNotesForImage(widget.imageId); - - // Update the main state of ImageDetailsPage - if (mounted) { - setState(() { - _notes = updatedNotes; - _finalSelectionRect = - null; // Clear selection - }); - } - - if (context.mounted) Navigator.pop(context); - } - }, - ), - ), - ], - ), - ], - ), - ), - const SizedBox(height: 10), - ], - ); - }, - ); - return NoteModalOverlay( - modalContent: modalContent, - screenSize: mediaQuery.size, + screenSize: MediaQuery.of(context).size, + modalContent: NoteInputSheet( + categories: _allAvailableTags, + initialCategory: initialCategory, + onSubmit: (content, category) async { + if (_finalSelectionRect != null && _imageRenderSize != null) { + final normRect = _finalSelectionRect!.normalize(); + await _noteService.addNote( + widget.imageId, + content, + category, + normX: normRect.center.dx / _imageRenderSize!.width, + normY: normRect.center.dy / _imageRenderSize!.height, + normWidth: normRect.width / _imageRenderSize!.width, + normHeight: normRect.height / _imageRenderSize!.height, + ); + final updatedNotes = await _noteService.getNotesForImage( + widget.imageId, + ); + if (mounted) + setState(() { + _notes = updatedNotes; + _finalSelectionRect = null; + }); + if (context.mounted) Navigator.pop(context); + } + }, + ), ); }, ); } - // --- EDIT TAGS DIALOG --- + // Edit Tags Dialog void _openEditTagsDialog() { showDialog( context: context, @@ -641,15 +375,13 @@ class _ImageDetailsPageState extends State<ImageDetailsPage> { _allAvailableTags.map((tag) { final isSelected = tempTags.contains(tag); return GestureDetector( - onTap: () { - setState(() { - if (isSelected) { - tempTags.remove(tag); - } else { - tempTags.add(tag); - } - }); - }, + onTap: + () => setState( + () => + isSelected + ? tempTags.remove(tag) + : tempTags.add(tag), + ), child: Container( padding: const EdgeInsets.symmetric( horizontal: 12, @@ -666,35 +398,21 @@ class _ImageDetailsPageState extends State<ImageDetailsPage> { isSelected ? const Color(0xFF7C4DFF) : Colors.grey[300]!, - width: 1.0, ), ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (isSelected) ...[ - const Icon( - Icons.close, - size: 14, - color: Color(0xFF7C4DFF), - ), - const SizedBox(width: 4), - ], - Text( - tag, - style: TextStyle( - fontSize: 13, - fontWeight: - isSelected - ? FontWeight.w600 - : FontWeight.normal, - color: - isSelected - ? const Color(0xFF7C4DFF) - : Colors.black87, - ), - ), - ], + child: Text( + tag, + style: TextStyle( + fontSize: 13, + fontWeight: + isSelected + ? FontWeight.w600 + : FontWeight.normal, + color: + isSelected + ? const Color(0xFF7C4DFF) + : Colors.black87, + ), ), ), ); @@ -797,7 +515,7 @@ class _ImageDetailsPageState extends State<ImageDetailsPage> { ) : null, actions: [ - // 1. CONFIRM SELECTION (Resizing mode) + // 1. Confirm Selection (Resizing mode) if (_isResizing && _finalSelectionRect != null) IconButton( icon: const Icon( @@ -807,14 +525,14 @@ class _ImageDetailsPageState extends State<ImageDetailsPage> { onPressed: _confirmSelectionAndShowModal, ), - // 2. CANCEL SELECTION (Any selection mode) + // 2. Cancel Selection (Any selection mode) if (isSelectionModeActive) IconButton( icon: const Icon(Icons.close, color: Colors.black), onPressed: _resetSelectionMode, ), - // 3. STANDARD ACTIONS (when NOT selecting) + // 3. Standard Actions (when NOT selecting) if (!isSelectionModeActive && _imageModel != null) ...[ // Share Button IconButton( @@ -862,7 +580,7 @@ class _ImageDetailsPageState extends State<ImageDetailsPage> { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // --- 1. IMAGE CONTAINER --- + // 1. Image Container Container( margin: const EdgeInsets.symmetric(horizontal: 16), constraints: const BoxConstraints(maxHeight: 500), @@ -874,18 +592,6 @@ class _ImageDetailsPageState extends State<ImageDetailsPage> { borderRadius: BorderRadius.circular(20), child: LayoutBuilder( builder: (context, constraints) { - // Unified Pan Handlers - final onPanStartHandler = - isSelectionModeActive - ? _onPanStart - : null; - final onPanUpdateHandler = - isSelectionModeActive - ? _onPanUpdate - : null; - final onPanEndHandler = - isSelectionModeActive ? _onPanEnd : null; - return Stack( fit: StackFit.passthrough, children: [ @@ -894,9 +600,9 @@ class _ImageDetailsPageState extends State<ImageDetailsPage> { panEnabled: !isSelectionModeActive, scaleEnabled: !isSelectionModeActive, child: GestureDetector( - onPanStart: onPanStartHandler, - onPanUpdate: onPanUpdateHandler, - onPanEnd: onPanEndHandler, + onPanStart: _onPanStart, + onPanUpdate: _onPanUpdate, + onPanEnd: _onPanEnd, child: Stack( children: [ Image.file( @@ -906,14 +612,14 @@ class _ImageDetailsPageState extends State<ImageDetailsPage> { width: double.infinity, ), - // DRAWING OVERLAY (if in drawing mode) + // Drawing Overlay (if in drawing mode) if (_isDrawMode && _startPos != null && _currentPos != null) Positioned.fill( child: CustomPaint( painter: - ResizingSelectionOverlayPainter( + SelectionOverlayPainter( rect: Rect.fromPoints( _startPos!, @@ -924,13 +630,13 @@ class _ImageDetailsPageState extends State<ImageDetailsPage> { ), ), - // FINAL SELECTION RECT (if in resizing mode) + // Final Selection Rect (if in resizing mode) if (_isResizing && _finalSelectionRect != null) Positioned.fill( child: CustomPaint( painter: - ResizingSelectionOverlayPainter( + SelectionOverlayPainter( rect: _finalSelectionRect!, isResizing: true, @@ -950,9 +656,6 @@ class _ImageDetailsPageState extends State<ImageDetailsPage> { note.normX * constraints.maxWidth; final y = note.normY * constraints.maxHeight; - final isActive = - note.id == _activeNoteId; - return Positioned( left: x - 10, top: y - 10, @@ -970,13 +673,13 @@ class _ImageDetailsPageState extends State<ImageDetailsPage> { boxShadow: [ BoxShadow( color: Colors.black - .withValues(alpha: 0.3), + .withOpacity(0.3), blurRadius: 4, offset: const Offset(0, 1), ), ], border: - isActive + note.id == _activeNoteId ? Border.all( color: const Color( 0xFF7C4DFF, @@ -1012,7 +715,6 @@ class _ImageDetailsPageState extends State<ImageDetailsPage> { ), ), child: Row( - mainAxisSize: MainAxisSize.min, children: const [ Text( "Notes", @@ -1031,7 +733,7 @@ class _ImageDetailsPageState extends State<ImageDetailsPage> { ), ), - // INSTRUCTION OVERLAYS + // Instruction Overlays if (_isDrawMode && _startPos == null) Positioned( top: 20, @@ -1097,7 +799,7 @@ class _ImageDetailsPageState extends State<ImageDetailsPage> { const SizedBox(height: 20), - // --- 2. INFO SECTION --- + // 2. Info Section Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Column( @@ -1105,10 +807,9 @@ class _ImageDetailsPageState extends State<ImageDetailsPage> { children: [ Text( _imageModel?.name ?? "Untitled Image", - style: const TextStyle( - fontWeight: FontWeight.bold, + style: Variables.bodyStyle.copyWith( fontSize: 16, - color: Colors.black87, + fontWeight: FontWeight.bold, ), ), @@ -1153,8 +854,6 @@ class _ImageDetailsPageState extends State<ImageDetailsPage> { color: Colors.grey[300], ), const SizedBox(height: 16), - - // Main View: Only show selected tags SizedBox( width: double.infinity, child: @@ -1169,33 +868,33 @@ class _ImageDetailsPageState extends State<ImageDetailsPage> { spacing: 8, runSpacing: 8, children: - _currentTags.map((tag) { - return Container( - padding: - const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: const Color( - 0xFFEEF0FF, - ), - borderRadius: - BorderRadius.circular( - 20, - ), - border: Border.all( - color: - const Color( + _currentTags + .map( + (tag) => Container( + padding: + const EdgeInsets.symmetric( + horizontal: + 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: + const Color( + 0xFFEEF0FF, + ), + borderRadius: + BorderRadius.circular( + 20, + ), + border: Border.all( + color: const Color( 0xFF7C4DFF, ), - width: 1, - ), - ), - child: Text( - tag, - style: - const TextStyle( + ), + ), + child: Text( + tag, + style: const TextStyle( fontSize: 13, fontWeight: FontWeight @@ -1204,9 +903,10 @@ class _ImageDetailsPageState extends State<ImageDetailsPage> { 0xFF7C4DFF, ), ), - ), - ); - }).toList(), + ), + ), + ) + .toList(), ), ), ], @@ -1226,25 +926,22 @@ class _ImageDetailsPageState extends State<ImageDetailsPage> { } } -// --- NOTES LIST SHEET --- +// Notes List Sheet class _NotesListSheet extends StatefulWidget { final List<NoteModel> notes; final int? highlightId; final VoidCallback onAddNotePressed; - const _NotesListSheet({ required this.notes, this.highlightId, required this.onAddNotePressed, }); - @override State<_NotesListSheet> createState() => __NotesListSheetState(); } class __NotesListSheetState extends State<_NotesListSheet> { final ScrollController _scrollController = ScrollController(); - @override void initState() { super.initState(); @@ -1253,13 +950,12 @@ class __NotesListSheetState extends State<_NotesListSheet> { final index = widget.notes.indexWhere( (n) => n.id == widget.highlightId, ); - if (index != -1 && _scrollController.hasClients) { + if (index != -1 && _scrollController.hasClients) _scrollController.animateTo( index * 80.0, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); - } }); } } @@ -1308,85 +1004,78 @@ class __NotesListSheetState extends State<_NotesListSheet> { ), const Divider(), Expanded( - child: ListView.builder( - controller: controller, - itemCount: widget.notes.length, - itemBuilder: (context, index) { - final note = widget.notes[index]; - final isHighlighted = note.id == widget.highlightId; - - return Container( - margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: - isHighlighted - ? const Color(0xFFF3F0FF) - : Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: - isHighlighted - ? const Color(0xFF7C4DFF) - : Colors.grey[200]!, - width: isHighlighted ? 1.5 : 1, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Align( - alignment: Alignment.centerLeft, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 4, - ), + child: + widget.notes.isEmpty + ? const EmptyState( + icon: Icons.comment, + title: "No notes yet", + subtitle: "Tap 'Add Note' to start", + ) + : ListView.builder( + controller: controller, + itemCount: widget.notes.length, + itemBuilder: (context, index) { + final note = widget.notes[index]; + final isHighlighted = note.id == widget.highlightId; + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: const Color(0xFFEEF0FF), - borderRadius: BorderRadius.circular(6), - ), - child: Text( - note.category, - style: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w700, - color: Color(0xFF7C4DFF), + color: + isHighlighted + ? const Color(0xFFF3F0FF) + : Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: + isHighlighted + ? const Color(0xFF7C4DFF) + : Colors.grey[200]!, + width: isHighlighted ? 1.5 : 1, ), ), - ), - ), - const SizedBox(height: 8), - Text( - note.content, - style: const TextStyle( - fontSize: 15, - color: Colors.black87, - height: 1.4, - ), - ), - ], - ), - ); - }, - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: const Color(0xFFEEF0FF), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + note.category, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: Color(0xFF7C4DFF), + ), + ), + ), + const SizedBox(height: 8), + Text( + note.content, + style: const TextStyle( + fontSize: 15, + color: Colors.black87, + height: 1.4, + ), + ), + ], + ), + ); + }, + ), ), - // --- ADD NOTE BUTTON (BOTTOM OF SHEET) --- + // Add Note Button const SizedBox(height: 10), - SizedBox( - width: double.infinity, - height: 50, - child: FilledButton.icon( - onPressed: widget.onAddNotePressed, - icon: const Icon(Icons.add), - label: const Text("Add Note"), - style: FilledButton.styleFrom( - backgroundColor: Colors.black, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - ), + PrimaryButton( + text: "Add Note", + onPressed: widget.onAddNotePressed, + iconPath: "assets/icons/add-line.svg", ), const SizedBox(height: 10), ], @@ -1396,156 +1085,3 @@ class __NotesListSheetState extends State<_NotesListSheet> { ); } } - -// --- REUSABLE WIDGETS FOR MODAL OVERLAY --- - -class NoteModalOverlay extends StatelessWidget { - final Widget modalContent; - final Size screenSize; - - const NoteModalOverlay({ - super.key, - required this.modalContent, - required this.screenSize, - }); - - @override - Widget build(BuildContext context) { - // Note: modalMinHeight is kept only for potential use with MaxHeight, but is not enforced as a minimum - final mq = MediaQuery.of(context); - final keyboardHeight = mq.viewInsets.bottom; - final systemBottomPadding = mq.padding.bottom; - - return Align( - alignment: Alignment.bottomCenter, - // Use AnimatedPadding on the outside to correctly handle keyboard elevation smoothly - child: AnimatedPadding( - duration: const Duration(milliseconds: 250), - curve: Curves.easeOut, - padding: EdgeInsets.only( - bottom: keyboardHeight, // Moves modal up to avoid keyboard - ), - child: ConstrainedBox( - constraints: BoxConstraints(maxHeight: screenSize.height), - child: Material( - color: Colors.white, - elevation: 10, - shadowColor: Colors.black26, - borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), - clipBehavior: Clip.antiAlias, - child: SingleChildScrollView( - // Allows the content inside to scroll if keyboard reduces available space - child: Padding( - // Only apply system bottom padding for safe area/gesture bar here - padding: EdgeInsets.only(bottom: systemBottomPadding), - child: modalContent, - ), - ), - ), - ), - ), - ); - } -} - -// --- FULL OVERLAY PAINTER WITH RESIZING LOGIC --- -class ResizingSelectionOverlayPainter extends CustomPainter { - final Rect rect; - final bool isResizing; - final DragHandle activeHandle; - - ResizingSelectionOverlayPainter({ - required this.rect, - required this.isResizing, - this.activeHandle = DragHandle.none, - }); - - @override - void paint(Canvas canvas, Size size) { - // 1. DIM BACKGROUND (Black overlay with hole for the selected area) - if (isResizing) { - final Path backgroundPath = - Path()..addRect(Rect.fromLTWH(0, 0, size.width, size.height)); - final Path holePath = Path()..addRect(rect); - final Path overlayPath = Path.combine( - ui.PathOperation.difference, - backgroundPath, - holePath, - ); - - canvas.drawPath(overlayPath, Paint()..color = Colors.black54); - } - - // 2. DRAW DASHED BORDER - final Paint borderPaint = - Paint() - ..color = const Color(0xFF448AFF) - ..strokeWidth = 2.0 - ..style = PaintingStyle.stroke; - - double dashWidth = 6; - double dashSpace = 4; - Path borderPath = Path()..addRect(rect); - - for (ui.PathMetric pathMetric in borderPath.computeMetrics()) { - double distance = 0.0; - while (distance < pathMetric.length) { - canvas.drawPath( - pathMetric.extractPath(distance, distance + dashWidth), - borderPaint, - ); - distance += (dashWidth + dashSpace); - } - } - - // 3. DRAW CENTER DOT/RESIZE HANDLES - if (!isResizing) { - // Draw center dot in initial draw mode - final Paint dotPaint = - Paint() - ..color = Colors.white - ..style = PaintingStyle.fill; - - canvas.drawCircle( - rect.center, - 8, - Paint() - ..color = Colors.black26 - ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 3), - ); - canvas.drawCircle(rect.center, 6, dotPaint); - } else { - // Draw resize handles in resizing mode - final List<Offset> corners = [ - rect.topLeft, - rect.topRight, - rect.bottomLeft, - rect.bottomRight, - ]; - - const double handleRadius = 8; - final Paint handleShadow = - Paint() - ..color = Colors.black.withValues(alpha: 0.3) - ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 2); - final Paint handleFill = Paint()..color = Colors.white; - final Paint handleBorder = - Paint() - ..color = const Color(0xFF448AFF) - ..strokeWidth = 2 - ..style = PaintingStyle.stroke; - - for (final corner in corners) { - canvas.drawCircle(corner, handleRadius, handleShadow); - canvas.drawCircle(corner, handleRadius, handleFill); - canvas.drawCircle(corner, handleRadius, handleBorder); - } - } - } - - @override - bool shouldRepaint(covariant ResizingSelectionOverlayPainter oldDelegate) => - rect != oldDelegate.rect || - isResizing != oldDelegate.isResizing || - activeHandle != oldDelegate.activeHandle; -} diff --git a/lib/ui/pages/image_save_page.dart b/lib/ui/pages/image_save_page.dart @@ -1,20 +1,18 @@ import 'dart:io'; -import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:creekui/services/image_service.dart'; import 'package:creekui/services/note_service.dart'; +import 'package:creekui/ui/styles/variables.dart'; +import 'package:creekui/ui/widgets/primary_button.dart'; +import 'package:creekui/ui/widgets/selection_overlay_painter.dart'; +import 'package:creekui/ui/widgets/note_input_sheet.dart'; import 'project_board_page.dart'; -// --- HELPER CLASS FOR TEMPORARY NOTES --- +// Temporary note model class TempNote { - final double normX; - final double normY; - final double normWidth; - final double normHeight; - final String content; - final String category; - + final double normX, normY, normWidth, normHeight; + final String content, category; TempNote({ required this.normX, required this.normY, @@ -45,37 +43,22 @@ class ImageSavePage extends StatefulWidget { State<ImageSavePage> createState() => _ImageSavePageState(); } -// --- STATE MACHINE FOR SELECTION MODE --- -enum DragHandle { - none, - topLeft, - topRight, - bottomLeft, - bottomRight, - center, // For dragging the entire box -} - class _ImageSavePageState extends State<ImageSavePage> { - // --- SERVICES --- final ImageService _imageService = ImageService(); final NoteService _noteService = NoteService(); - // --- STATE --- late PageController _pageController; int _currentImageIndex = 0; - // Data storage per image final Map<int, Set<String>> _tagsPerImage = {}; final Map<int, List<TempNote>> _notesPerImage = {}; - // CRITICAL: A unique key for EACH image to calculate drawing coordinates correctly + // A unique key for each image to calculate drawing coordinates correctly late List<GlobalKey> _imageKeys; - final TextEditingController _commentController = TextEditingController(); - String _selectedCategory = 'Compositions'; bool _isSaving = false; - // --- DRAWING/RESIZING STATE --- + // Drawing/Resizing state bool _isDrawMode = false; // True when initial drag is happening (to create box) bool _isResizing = @@ -88,6 +71,7 @@ class _ImageSavePageState extends State<ImageSavePage> { DragHandle _activeHandle = DragHandle.none; Offset? _startDragLocalOffset; // Used for moving the entire rect final Map<int, Size> _imageRenderSizes = {}; + final double _handleSize = 25.0; final List<String> _availableTags = [ 'Compositions', @@ -103,20 +87,6 @@ class _ImageSavePageState extends State<ImageSavePage> { 'Emotion', ]; - final List<String> _categories = [ - 'Compositions', - 'Subject', - 'Fonts', - 'Background', - 'Texture', - 'Colours', - 'Material Look', - 'Lighting', - 'Style', - 'Era', - 'Emotion', - ]; - @override void initState() { super.initState(); @@ -124,8 +94,6 @@ class _ImageSavePageState extends State<ImageSavePage> { // Generate a unique key for every image path _imageKeys = List.generate(widget.imagePaths.length, (_) => GlobalKey()); - - // Initialize data maps for (int i = 0; i < widget.imagePaths.length; i++) { _tagsPerImage[i] = {}; _notesPerImage[i] = []; @@ -135,11 +103,10 @@ class _ImageSavePageState extends State<ImageSavePage> { @override void dispose() { _pageController.dispose(); - _commentController.dispose(); super.dispose(); } - // --- ACTIONS --- + // Actions void _activateSelectionMode() { setState(() { _isDrawMode = true; // Start initial drawing mode @@ -164,507 +131,238 @@ class _ImageSavePageState extends State<ImageSavePage> { void _confirmSelectionAndShowModal() { if (_finalSelectionRect != null) { // Exit resizing mode before showing the modal to prevent visual conflict - setState(() { - _isResizing = false; - }); + setState(() => _isResizing = false); _showNoteModal(); } } void _toggleTag(String tag) { setState(() { - final currentTags = _tagsPerImage[_currentImageIndex]!; - if (currentTags.contains(tag)) { - currentTags.remove(tag); - } else { - currentTags.add(tag); - } + final tags = _tagsPerImage[_currentImageIndex]!; + if (tags.contains(tag)) + tags.remove(tag); + else + tags.add(tag); }); } - // --- DRAWING/RESIZING GESTURES (UPDATED LOGIC) --- - final double _handleSize = - 25.0; // The size of the touch area for resizing handles - - // Helper: Convert global screen touch to local image coordinates + // Gestures Offset? _getLocalPosition(Offset globalPosition) { final currentKey = _imageKeys[_currentImageIndex]; final RenderBox? box = currentKey.currentContext?.findRenderObject() as RenderBox?; if (box == null) return null; _imageRenderSizes[_currentImageIndex] = box.size; - - // Convert global to local final local = box.globalToLocal(globalPosition); // Clamp coordinates to ensure we don't draw/drag outside the image final dx = local.dx.clamp(0.0, box.size.width); final dy = local.dy.clamp(0.0, box.size.height); - return Offset(dx, dy); } - // --- INITIAL DRAWING HANDLERS --- - void _onPanStart(DragStartDetails details) { - if (!_isDrawMode) return; - - final pos = _getLocalPosition(details.globalPosition); - if (pos == null) return; - - setState(() { - _startPos = pos; - _currentPos = pos; - }); - } - - void _onPanUpdate(DragUpdateDetails details) { - if (!_isDrawMode) return; - - final pos = _getLocalPosition(details.globalPosition); - if (pos == null) return; - - setState(() { - _currentPos = pos; - }); - } - - void _onPanEnd(DragEndDetails details) { - if (!_isDrawMode || _startPos == null || _currentPos == null) return; - - // Create the rect from the corrected local positions, normalizing points - final rect = Rect.fromPoints(_startPos!, _currentPos!).normalize(); - - // Check if the selected area is too small - if (rect.width < 10 || rect.height < 10) { - _resetSelectionMode(); - return; - } - - setState(() { - _isDrawMode = false; - _isResizing = true; // Enter resizing/confirming mode - _finalSelectionRect = rect; - _startPos = null; - _currentPos = null; - }); - } - - // --- RESIZING HANDLERS --- DragHandle _getDragHandle(Offset pos) { if (_finalSelectionRect == null) return DragHandle.none; - final rect = _finalSelectionRect!; - // final center = rect.center; // Not used but helpful for context - // final top = rect.top; - // final bottom = rect.bottom; - // final left = rect.left; - // final right = rect.right; - // Check corners + // Check corners first if (Rect.fromCircle( center: rect.topLeft, radius: _handleSize, - ).contains(pos)) { + ).contains(pos)) return DragHandle.topLeft; - } else if (Rect.fromCircle( + if (Rect.fromCircle( center: rect.topRight, radius: _handleSize, - ).contains(pos)) { + ).contains(pos)) return DragHandle.topRight; - } else if (Rect.fromCircle( + if (Rect.fromCircle( center: rect.bottomLeft, radius: _handleSize, - ).contains(pos)) { + ).contains(pos)) return DragHandle.bottomLeft; - } else if (Rect.fromCircle( + if (Rect.fromCircle( center: rect.bottomRight, radius: _handleSize, - ).contains(pos)) { + ).contains(pos)) return DragHandle.bottomRight; - } - // Check if dragging the whole box (center) - else if (rect.contains(pos)) { - // Only allow center drag if we are not actively drawing (i.e. we are in resizing mode) - return DragHandle.center; - } - + if (rect.contains(pos)) return DragHandle.center; return DragHandle.none; } - void _onResizeStart(DragStartDetails details) { - if (!_isResizing || _finalSelectionRect == null) return; - - final pos = _getLocalPosition(details.globalPosition); - if (pos == null) return; - - final handle = _getDragHandle(pos); - if (handle != DragHandle.none) { + void _onPanStart(DragStartDetails details) { + if (_isDrawMode) { + final pos = _getLocalPosition(details.globalPosition); + if (pos == null) return; setState(() { - _activeHandle = handle; - // Calculate offset for moving the entire rect, not for resizing - if (handle == DragHandle.center) { - _startDragLocalOffset = pos - _finalSelectionRect!.topLeft; - } + _startPos = pos; + _currentPos = pos; }); + } else if (_isResizing && _finalSelectionRect != null) { + final pos = _getLocalPosition(details.globalPosition); + if (pos == null) return; + final handle = _getDragHandle(pos); + if (handle != DragHandle.none) { + setState(() { + _activeHandle = handle; + if (handle == DragHandle.center) { + _startDragLocalOffset = pos - _finalSelectionRect!.topLeft; + } + }); + } } } - void _onResizeUpdate(DragUpdateDetails details) { - if (!_isResizing || - _finalSelectionRect == null || - _activeHandle == DragHandle.none) { - return; - } - - final pos = _getLocalPosition(details.globalPosition); - if (pos == null) return; - - setState(() { - Rect newRect = _finalSelectionRect!; - final newPoint = pos; + void _onPanUpdate(DragUpdateDetails details) { + if (_isDrawMode) { + final pos = _getLocalPosition(details.globalPosition); + if (pos == null) return; + setState(() => _currentPos = pos); + } else if (_isResizing && _finalSelectionRect != null) { + final pos = _getLocalPosition(details.globalPosition); + if (pos == null || _activeHandle == DragHandle.none) return; - switch (_activeHandle) { - case DragHandle.topLeft: - newRect = Rect.fromLTRB( - newPoint.dx, - newPoint.dy, - newRect.right, - newRect.bottom, - ); - break; - case DragHandle.topRight: - newRect = Rect.fromLTRB( - newRect.left, - newPoint.dy, - newPoint.dx, - newRect.bottom, - ); - break; - case DragHandle.bottomLeft: - newRect = Rect.fromLTRB( - newPoint.dx, - newRect.top, - newRect.right, - newPoint.dy, - ); - break; - case DragHandle.bottomRight: - newRect = Rect.fromLTRB( - newRect.left, - newRect.top, - newPoint.dx, - newPoint.dy, - ); - break; - case DragHandle.center: - if (_startDragLocalOffset != null) { - final newTopLeft = newPoint - _startDragLocalOffset!; - newRect = Rect.fromLTWH( - newTopLeft.dx, - newTopLeft.dy, - newRect.width, - newRect.height, + setState(() { + Rect newRect = _finalSelectionRect!; + final newPoint = pos; + + switch (_activeHandle) { + case DragHandle.topLeft: + newRect = Rect.fromLTRB( + newPoint.dx, + newPoint.dy, + newRect.right, + newRect.bottom, ); - } - break; - case DragHandle.none: - return; - } - - // Clamp the final rectangle to the image boundaries (0,0 to width, height) - final imageSize = _imageRenderSizes[_currentImageIndex]; - if (imageSize != null) { - final clampedLeft = newRect.left.clamp(0.0, imageSize.width); - final clampedTop = newRect.top.clamp(0.0, imageSize.height); - final clampedRight = newRect.right.clamp(0.0, imageSize.width); - final clampedBottom = newRect.bottom.clamp(0.0, imageSize.height); + break; + case DragHandle.topRight: + newRect = Rect.fromLTRB( + newRect.left, + newPoint.dy, + newPoint.dx, + newRect.bottom, + ); + break; + case DragHandle.bottomLeft: + newRect = Rect.fromLTRB( + newPoint.dx, + newRect.top, + newRect.right, + newPoint.dy, + ); + break; + case DragHandle.bottomRight: + newRect = Rect.fromLTRB( + newRect.left, + newRect.top, + newPoint.dx, + newPoint.dy, + ); + break; + case DragHandle.center: + if (_startDragLocalOffset != null) { + final newTopLeft = newPoint - _startDragLocalOffset!; + newRect = Rect.fromLTWH( + newTopLeft.dx, + newTopLeft.dy, + newRect.width, + newRect.height, + ); + } + break; + case DragHandle.none: + return; + } - newRect = - Rect.fromLTRB( - clampedLeft, - clampedTop, - clampedRight, - clampedBottom, - ).normalize(); - } else { - newRect = newRect.normalize(); - } + final imageSize = _imageRenderSizes[_currentImageIndex]; + if (imageSize != null) { + final cl = newRect.left.clamp(0.0, imageSize.width); + final ct = newRect.top.clamp(0.0, imageSize.height); + final cr = newRect.right.clamp(0.0, imageSize.width); + final cb = newRect.bottom.clamp(0.0, imageSize.height); + newRect = Rect.fromLTRB(cl, ct, cr, cb).normalize(); + } else { + newRect = newRect.normalize(); + } - // Ensure min size - if (newRect.width > 10 && newRect.height > 10) { - _finalSelectionRect = newRect; - } - }); + if (newRect.width > 10 && newRect.height > 10) + _finalSelectionRect = newRect; + }); + } } - void _onResizeEnd(DragEndDetails details) { - if (!_isResizing) return; - setState(() { - _activeHandle = DragHandle.none; - _startDragLocalOffset = null; - }); + void _onPanEnd(DragEndDetails details) { + if (_isDrawMode && _startPos != null && _currentPos != null) { + final rect = Rect.fromPoints(_startPos!, _currentPos!).normalize(); + if (rect.width < 10 || rect.height < 10) { + _resetSelectionMode(); + return; + } + setState(() { + _isDrawMode = false; + _isResizing = true; + _finalSelectionRect = rect; + _startPos = null; + _currentPos = null; + }); + } else if (_isResizing) { + setState(() { + _activeHandle = DragHandle.none; + _startDragLocalOffset = null; + }); + } } - //ADD NOTE MODAL + // Modal void _showNoteModal() { - _commentController.clear(); - // Ensure we have a valid selection to proceed - if (_finalSelectionRect == null) return; - + String initialCategory = _availableTags.firstOrNull ?? 'General'; showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, - builder: (context) { - final mediaQuery = MediaQuery.of(context); - - // 1. Wrap the content variable in StatefulBuilder - final modalContent = StatefulBuilder( - builder: (BuildContext context, StateSetter setModalState) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // --- ROW 1: Header --- - Row( - children: [ - // Avatar - Container( - width: 30, - height: 30, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: const Color(0xFFFAFAFA), - width: 1.25, - ), - ), - child: const CircleAvatar( - radius: 15, - backgroundColor: Colors.grey, - child: Icon( - Icons.person, - color: Colors.white, - size: 18, - ), - ), - ), - const SizedBox(width: 10), - - // User name - const Text( - "Alex", - style: TextStyle( - fontFamily: 'GeneralSans', - fontSize: 12, - fontWeight: FontWeight.w500, - color: Colors.black, - letterSpacing: 0.4, - height: 16 / 12, - ), - ), - - const Spacer(), - - // Dropdown - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: const Color(0xFFE0E7FF), - borderRadius: BorderRadius.circular(1000), - ), - child: DropdownButtonHideUnderline( - child: DropdownButton<String>( - value: - _categories.contains(_selectedCategory) - ? _selectedCategory - : null, - hint: const Text( - "Type", - style: TextStyle( - fontFamily: 'GeneralSans', - fontSize: 12, - fontWeight: FontWeight.normal, - color: Color(0xFF27272A), - height: 16 / 12, - ), - ), - isDense: true, - icon: const Icon( - Icons.arrow_drop_down, - size: 20, - color: Color(0xFF27272A), - ), - style: const TextStyle( - fontFamily: 'GeneralSans', - fontSize: 12, - fontWeight: FontWeight.normal, - color: Color(0xFF27272A), - height: 16 / 12, - ), - focusColor: Colors.transparent, - dropdownColor: Colors.white, - items: - _categories - .map( - (c) => DropdownMenuItem( - value: c, - child: Text( - c, - style: const TextStyle( - fontFamily: 'GeneralSans', - fontSize: 12, - fontWeight: FontWeight.normal, - ), - ), - ), - ) - .toList(), - onChanged: (v) { - setModalState(() { - _selectedCategory = v!; - }); - }, - ), - ), - ), - ], - ), - - const SizedBox(height: 16), - - // --- ROW 2: Input & Send --- - Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded( - child: Container( - decoration: BoxDecoration( - color: const Color(0xFFF4F4F5), - border: Border.all( - color: const Color(0xFFE4E4E7), - width: 1, - ), - borderRadius: BorderRadius.circular(8), - ), - child: TextField( - controller: _commentController, - autofocus: true, - maxLines: null, - style: const TextStyle( - fontFamily: 'GeneralSans', - fontSize: 12, - fontWeight: FontWeight.normal, - color: Color(0xFF27272A), - height: 16 / 12, - ), - decoration: InputDecoration( - hintText: - "I love the serif font and how it is used...", - hintStyle: const TextStyle( - fontFamily: 'GeneralSans', - fontSize: 12, - fontWeight: FontWeight.normal, - color: Color(0xFF27272A), - height: 16 / 12, - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - ), - ), - ), - ), - - const SizedBox(width: 8), - - // Send button - IconButton( - icon: const Icon( - Icons.send, - color: Color(0xFF27272A), - size: 24, - ), - onPressed: () { - _addTempNote(); - Navigator.pop(context); - }, - padding: EdgeInsets.zero, - constraints: const BoxConstraints( - minWidth: 24, - minHeight: 24, - ), - ), - ], - ), - ], - ), - ), - const SizedBox(height: 10), - ], - ); - }, - ); - - return NoteModalOverlay( - modalContent: modalContent, - screenSize: mediaQuery.size, - ); - }, + builder: + (context) => NoteModalOverlay( + screenSize: MediaQuery.of(context).size, + modalContent: NoteInputSheet( + categories: _availableTags, + initialCategory: initialCategory, + onSubmit: (content, category) { + final imageSize = _imageRenderSizes[_currentImageIndex]; + if (_finalSelectionRect != null && imageSize != null) { + final nX = _finalSelectionRect!.center.dx / imageSize.width; + final nY = _finalSelectionRect!.center.dy / imageSize.height; + final nW = _finalSelectionRect!.width / imageSize.width; + final nH = _finalSelectionRect!.height / imageSize.height; + + final newNote = TempNote( + normX: nX, + normY: nY, + normWidth: nW, + normHeight: nH, + content: content, + category: category, + ); + setState(() { + _notesPerImage[_currentImageIndex]?.add(newNote); + _finalSelectionRect = null; + }); + Navigator.pop(context); + } + }, + ), + ), ); } - void _addTempNote() { - // Use the stored render size for the current image index - final imageSize = _imageRenderSizes[_currentImageIndex]; - - if (_finalSelectionRect != null && - imageSize != null && - _commentController.text.isNotEmpty) { - // Calculate Normalized Coordinates (0.0 - 1.0) - final nX = _finalSelectionRect!.center.dx / imageSize.width; - final nY = _finalSelectionRect!.center.dy / imageSize.height; - final nW = _finalSelectionRect!.width / imageSize.width; - final nH = _finalSelectionRect!.height / imageSize.height; - - final newNote = TempNote( - normX: nX, - normY: nY, - normWidth: nW, - normHeight: nH, - content: _commentController.text.trim(), - category: _selectedCategory, - ); - - setState(() { - _notesPerImage[_currentImageIndex]?.add(newNote); - _finalSelectionRect = null; // Clear selection after saving note - }); - } - } - - // --- FINAL SAVE --- + // Final Save Future<void> _onSaveToMoodboard() async { setState(() => _isSaving = true); - try { for (int i = 0; i < widget.imagePaths.length; i++) { String path = widget.imagePaths[i]; final file = File(path); if (!file.existsSync()) continue; - // 1. Save Image final tags = _tagsPerImage[i] ?? {}; final imageId = await _imageService.saveOrUpdateImage( file, @@ -672,7 +370,6 @@ class _ImageSavePageState extends State<ImageSavePage> { tags: tags.toList(), ); - // 2. Save Notes for this specific image final notes = _notesPerImage[i] ?? []; for (var note in notes) { await _noteService.addNote( @@ -688,30 +385,26 @@ class _ImageSavePageState extends State<ImageSavePage> { } if (mounted) { - // Find the most suitable ScaffoldMessengerState ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('All images saved successfully!'), backgroundColor: Colors.green, ), ); - - if (widget.isFromShare) { + if (widget.isFromShare) SystemNavigator.pop(); - } else { - // Navigate to project board page in grid view (alternate view) + else Navigator.pushAndRemoveUntil( context, MaterialPageRoute( builder: (_) => ProjectBoardPage( projectId: widget.projectId, - initialShowAlternateView: true, // Show grid view + initialShowAlternateView: true, ), ), - (route) => false, // Remove all previous routes + (route) => false, ); - } } } catch (e) { if (mounted) { @@ -727,7 +420,6 @@ class _ImageSavePageState extends State<ImageSavePage> { Widget build(BuildContext context) { final currentTags = _tagsPerImage[_currentImageIndex] ?? {}; final String parentName = widget.parentProjectName?.trim() ?? ''; - final String titleText = parentName.isNotEmpty ? '$parentName / ${widget.projectName}' @@ -752,23 +444,16 @@ class _ImageSavePageState extends State<ImageSavePage> { ), title: Text( titleText, - style: const TextStyle( - fontFamily: 'GeneralSans', - color: Colors.black, - fontSize: 20, - fontWeight: FontWeight.w500, - height: 24 / 20, - letterSpacing: 0, - ), + style: Variables.headerStyle.copyWith(fontSize: 20), ), actions: [ - // CONFIRM SELECTION BUTTON (Visible only in resizing mode) + // Confirm Selection Button (Visible only in resizing mode) if (_isResizing && _finalSelectionRect != null) IconButton( icon: const Icon(Icons.check, color: Color(0xFF7C86FF), size: 24), onPressed: _confirmSelectionAndShowModal, ), - // CANCEL SELECTION BUTTON (Visible only in drawing/resizing mode) + // Cancel Selection Button (Visible only in drawing/resizing mode) if (_isDrawMode || _isResizing) IconButton( icon: const Icon(Icons.close, color: Colors.black87, size: 24), @@ -778,7 +463,7 @@ class _ImageSavePageState extends State<ImageSavePage> { ), body: Column( children: [ - // --- HORIZONTAL IMAGE CAROUSEL --- + // Horizontal Image Carousel Expanded( child: Container( width: double.infinity, @@ -800,12 +485,11 @@ class _ImageSavePageState extends State<ImageSavePage> { ? const NeverScrollableScrollPhysics() : const PageScrollPhysics(), itemCount: widget.imagePaths.length, - onPageChanged: (index) { - setState(() { - _currentImageIndex = index; - _resetSelectionMode(); // Reset selection mode on page change - }); - }, + onPageChanged: + (index) => setState(() { + _currentImageIndex = index; + _resetSelectionMode(); + }), itemBuilder: (context, index) { return LayoutBuilder( builder: (context, constraints) { @@ -813,15 +497,24 @@ class _ImageSavePageState extends State<ImageSavePage> { final onPanStartHandler = _isDrawMode ? _onPanStart - : (_isResizing ? _onResizeStart : null); + : (_isResizing && + index == _currentImageIndex + ? _onPanStart + : null); final onPanUpdateHandler = _isDrawMode ? _onPanUpdate - : (_isResizing ? _onResizeUpdate : null); + : (_isResizing && + index == _currentImageIndex + ? _onPanUpdate + : null); final onPanEndHandler = _isDrawMode ? _onPanEnd - : (_isResizing ? _onResizeEnd : null); + : (_isResizing && + index == _currentImageIndex + ? _onPanEnd + : null); return Stack( fit: StackFit.expand, @@ -837,14 +530,13 @@ class _ImageSavePageState extends State<ImageSavePage> { onPanEnd: onPanEndHandler, child: Stack( children: [ - // THE IMAGE WITH UNIQUE KEY Image.file( File(widget.imagePaths[index]), key: _imageKeys[index], fit: BoxFit.contain, width: double.infinity, ), - // DRAWING OVERLAY (if in drawing mode) + // Drawing Overlay (if in drawing mode) if (_isDrawMode && index == _currentImageIndex && _startPos != null && @@ -853,15 +545,16 @@ class _ImageSavePageState extends State<ImageSavePage> { child: CustomPaint( painter: SelectionOverlayPainter( - rect: Rect.fromPoints( - _startPos!, - _currentPos!, - ), + rect: + Rect.fromPoints( + _startPos!, + _currentPos!, + ).normalize(), isResizing: false, ), ), ), - // FINAL SELECTION RECT (if in resizing mode) + // Final Selection Rect (if in resizing mode) if (_isResizing && index == _currentImageIndex && _finalSelectionRect != null) @@ -882,7 +575,7 @@ class _ImageSavePageState extends State<ImageSavePage> { ), ), ), - // EXISTING NOTE INDICATORS (Dots) - visible only if no selection is active + // Existing Note Indicators (Dots) - visible only if no selection is active if (!isPageLocked) ...(_notesPerImage[index] ?? []).map((note) { return Positioned( @@ -900,8 +593,8 @@ class _ImageSavePageState extends State<ImageSavePage> { shape: BoxShape.circle, boxShadow: [ BoxShadow( - color: Colors.black.withValues( - alpha: 0.3, + color: Colors.black.withOpacity( + 0.3, ), blurRadius: 4, offset: const Offset(0, 1), @@ -915,7 +608,7 @@ class _ImageSavePageState extends State<ImageSavePage> { ), ); }), - // PAGE DOTS + // Page Dots if (widget.imagePaths.length > 1 && !isPageLocked) Positioned( @@ -927,7 +620,7 @@ class _ImageSavePageState extends State<ImageSavePage> { MainAxisAlignment.center, children: List.generate( widget.imagePaths.length, - (index) => Container( + (i) => Container( margin: const EdgeInsets.symmetric( horizontal: 4, ), @@ -936,17 +629,17 @@ class _ImageSavePageState extends State<ImageSavePage> { decoration: BoxDecoration( shape: BoxShape.circle, color: - _currentImageIndex == index + _currentImageIndex == i ? Colors.blue - : Colors.white.withValues( - alpha: 0.5, + : Colors.white.withOpacity( + 0.5, ), ), ), ), ), ), - // NOTES BUTTON (Visible only when not drawing/resizing) + // Notes Button (Visible only when not drawing/resizing) if (!isPageLocked) Positioned( bottom: 24, @@ -976,13 +669,11 @@ class _ImageSavePageState extends State<ImageSavePage> { fontFamily: 'GeneralSans', fontSize: 14, fontWeight: FontWeight.w500, - height: 20 / 14, - letterSpacing: 0.25, ), ), ), ), - // INSTRUCTION OVERLAY (for initial drawing) + // Instruction overlay (for initial drawing) if (_isDrawMode && _startPos == null) Positioned( top: 20, @@ -1003,16 +694,14 @@ class _ImageSavePageState extends State<ImageSavePage> { child: const Text( "Drag on image to select area", style: TextStyle( - fontFamily: 'GeneralSans', color: Colors.white, fontSize: 12, - fontWeight: FontWeight.normal, ), ), ), ), ), - // INSTRUCTION OVERLAY (for resizing) + // Instruction overlay (for resizing) if (_isResizing && _finalSelectionRect != null && _activeHandle == DragHandle.none) @@ -1035,10 +724,8 @@ class _ImageSavePageState extends State<ImageSavePage> { child: const Text( "Adjust area or tap checkmark to confirm", style: TextStyle( - fontFamily: 'GeneralSans', color: Colors.white, fontSize: 12, - fontWeight: FontWeight.normal, ), ), ), @@ -1055,21 +742,18 @@ class _ImageSavePageState extends State<ImageSavePage> { ), ), ), - // --- BOTTOM FORM (TAGS & SAVE) --- + // Bottom Form (Tags & Save) Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Tags box matching Figma design + // Tags box Container( width: double.infinity, decoration: BoxDecoration( color: const Color(0xFFFEFEFE), - border: Border.all( - color: const Color(0xFFE4E4E7), - width: 1, - ), + border: Border.all(color: Variables.borderSubtle), borderRadius: BorderRadius.circular(16), ), child: Column( @@ -1087,25 +771,16 @@ class _ImageSavePageState extends State<ImageSavePage> { fontFamily: 'GeneralSans', fontSize: 14, fontWeight: FontWeight.w500, - color: Colors.black, - letterSpacing: 0.25, - height: 20 / 14, ), ), if (widget.imagePaths.length > 1) Text( "Image ${_currentImageIndex + 1}/${widget.imagePaths.length}", - style: const TextStyle( - fontFamily: 'GeneralSans', - color: Colors.grey, - fontSize: 12, - fontWeight: FontWeight.normal, - ), + style: Variables.captionStyle, ), ], ), ), - // Divider const Divider( height: 0, thickness: 1, @@ -1148,9 +823,6 @@ class _ImageSavePageState extends State<ImageSavePage> { style: TextStyle( fontFamily: 'GeneralSans', fontSize: 14, - fontWeight: FontWeight.normal, - height: 20 / 14, - letterSpacing: 0, color: isSelected ? const Color(0xFF27272A) @@ -1166,39 +838,11 @@ class _ImageSavePageState extends State<ImageSavePage> { ), ), const SizedBox(height: 24), - SizedBox( - width: double.infinity, - height: 54, - child: ElevatedButton( - onPressed: _isSaving ? null : _onSaveToMoodboard, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.black, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(27), - ), - ), - child: - _isSaving - ? const SizedBox( - height: 24, - width: 24, - child: CircularProgressIndicator( - color: Colors.white, - strokeWidth: 2, - ), - ) - : Text( - 'Save ${widget.imagePaths.length > 1 ? "All" : ""} to Moodboard', - style: const TextStyle( - fontFamily: 'GeneralSans', - fontSize: 14, - fontWeight: FontWeight.w500, - height: 20 / 14, - letterSpacing: 0.25, - ), - ), - ), + PrimaryButton( + text: + 'Save ${widget.imagePaths.length > 1 ? "All" : ""} to Moodboard', + isLoading: _isSaving, + onPressed: _onSaveToMoodboard, ), ], ), @@ -1208,174 +852,3 @@ class _ImageSavePageState extends State<ImageSavePage> { ); } } - -// --------------------------------------------------------- -// --- HELPER CLASSES FOR CUSTOM HALF-PAGE MODAL OVERLAY --- -// --------------------------------------------------------- - -class NoteModalOverlay extends StatelessWidget { - final Widget modalContent; - final Size screenSize; - - const NoteModalOverlay({ - super.key, - required this.modalContent, - required this.screenSize, - }); - - @override - Widget build(BuildContext context) { - // The target initial height of the bottom sheet (half the screen height is no longer the minimum) - final mq = MediaQuery.of(context); - final keyboardHeight = mq.viewInsets.bottom; - final systemBottomPadding = mq.padding.bottom; - - return Align( - alignment: Alignment.bottomCenter, - child: AnimatedPadding( - duration: const Duration(milliseconds: 250), - curve: Curves.easeOut, - padding: EdgeInsets.only( - bottom: keyboardHeight, // Moves modal up to avoid keyboard - ), - child: ConstrainedBox( - constraints: BoxConstraints(maxHeight: screenSize.height), - child: Material( - // Using Material to provide the background, border radius, and shadow. - color: Colors.white, - elevation: - 10, // Replicating the box shadow of the old container for visual style. - shadowColor: Colors.black26, - borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), - clipBehavior: Clip.antiAlias, - child: SingleChildScrollView( - // Allows scrolling if content + keyboard height exceed screen height - child: Padding( - // Add system bottom padding to respect the safe area/gesture bar - padding: EdgeInsets.only(bottom: systemBottomPadding), - child: modalContent, - ), - ), - ), - ), - ), - ); - } -} - -// --- EXTENSION TO NORMALIZE RECT --- -extension on Rect { - Rect normalize() { - return Rect.fromLTRB( - left < right ? left : right, - top < bottom ? top : bottom, - left > right ? left : right, - top > bottom ? top : bottom, - ); - } -} - -// --- OVERLAY PAINTER (KEPT FOR MAIN IMAGE SELECTION HIGHLIGHT) --- -class SelectionOverlayPainter extends CustomPainter { - final Rect rect; - final bool isResizing; - final DragHandle activeHandle; - - SelectionOverlayPainter({ - required this.rect, - required this.isResizing, - this.activeHandle = DragHandle.none, - }); - - @override - void paint(Canvas canvas, Size size) { - // 1. DIM BACKGROUND (Black overlay with hole for the selected area) - if (isResizing) { - final Path backgroundPath = - Path()..addRect(Rect.fromLTWH(0, 0, size.width, size.height)); - final Path holePath = Path()..addRect(rect); - final Path overlayPath = Path.combine( - ui.PathOperation.difference, - backgroundPath, - holePath, - ); - - canvas.drawPath(overlayPath, Paint()..color = Colors.black54); - } - - // 2. DRAW DASHED BORDER - final Paint borderPaint = - Paint() - ..color = const Color(0xFF448AFF) - ..strokeWidth = 2.0 - ..style = PaintingStyle.stroke; - - double dashWidth = 6; - double dashSpace = 4; - Path borderPath = Path()..addRect(rect); - - for (ui.PathMetric pathMetric in borderPath.computeMetrics()) { - double distance = 0.0; - while (distance < pathMetric.length) { - canvas.drawPath( - pathMetric.extractPath(distance, distance + dashWidth), - borderPaint, - ); - distance += (dashWidth + dashSpace); - } - } - - // 3. DRAW CENTER DOT (Only needed in drawing mode, the app bar button replaces the functionality in resizing mode) - if (!isResizing) { - final Paint dotPaint = - Paint() - ..color = Colors.white - ..style = PaintingStyle.fill; - - canvas.drawCircle( - rect.center, - 8, - Paint() - ..color = Colors.black26 - ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 3), - ); - canvas.drawCircle(rect.center, 6, dotPaint); - } - - // 4. DRAW RESIZE HANDLES (Only in resizing mode) - if (isResizing) { - final List<Offset> corners = [ - rect.topLeft, - rect.topRight, - rect.bottomLeft, - rect.bottomRight, - ]; - - final Paint handleShadow = - Paint() - ..color = Colors.black.withValues(alpha: 0.3) - ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 2); - - final Paint handleFill = Paint()..color = Colors.white; - final Paint handleBorder = - Paint() - ..color = const Color(0xFF448AFF) - ..strokeWidth = 2 - ..style = PaintingStyle.stroke; - - const double handleRadius = 8; - - for (final corner in corners) { - canvas.drawCircle(corner, handleRadius, handleShadow); - canvas.drawCircle(corner, handleRadius, handleFill); - canvas.drawCircle(corner, handleRadius, handleBorder); - } - } - } - - @override - bool shouldRepaint(covariant SelectionOverlayPainter oldDelegate) => - rect != oldDelegate.rect || - isResizing != oldDelegate.isResizing || - activeHandle != oldDelegate.activeHandle; -} diff --git a/lib/ui/pages/project_board_page_alternate.dart b/lib/ui/pages/project_board_page_alternate.dart @@ -1,14 +1,13 @@ -import 'dart:io'; import 'package:flutter/material.dart'; import 'package:creekui/ui/styles/variables.dart'; import 'package:creekui/data/models/image_model.dart'; import 'package:creekui/data/repos/image_repo.dart'; -import 'package:creekui/ui/widgets/image_context_menu.dart'; +import 'package:creekui/ui/widgets/moodboard_image_card.dart'; +import 'package:creekui/ui/widgets/empty_state.dart'; import 'image_details_page.dart'; class ProjectBoardPageAlternate extends StatefulWidget { final int projectId; - const ProjectBoardPageAlternate({super.key, required this.projectId}); @override @@ -18,10 +17,8 @@ class ProjectBoardPageAlternate extends StatefulWidget { class ProjectBoardPageAlternateState extends State<ProjectBoardPageAlternate> { final _imageRepo = ImageRepo(); - List<ImageModel> _allImages = []; List<ImageModel> _filteredImages = []; - List<String> _allTags = []; final Set<String> _selectedTags = {}; bool _isLoading = true; @@ -32,23 +29,17 @@ class ProjectBoardPageAlternateState extends State<ProjectBoardPageAlternate> { _loadData(); } - // Called when project changes in parent @override void didUpdateWidget(covariant ProjectBoardPageAlternate oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.projectId != widget.projectId) { - _loadData(); - } + if (oldWidget.projectId != widget.projectId) _loadData(); } - void refreshData() { - _loadData(); - } + void refreshData() => _loadData(); Future<void> _loadData() async { setState(() => _isLoading = true); final images = await _imageRepo.getImages(widget.projectId); - final Set<String> tags = {}; for (var img in images) { tags.addAll(img.tags); @@ -66,136 +57,17 @@ class ProjectBoardPageAlternateState extends State<ProjectBoardPageAlternate> { } void _applyFilter() { - if (_selectedTags.isEmpty) { - setState(() => _filteredImages = _allImages); - } else { - setState(() { - _filteredImages = - _allImages.where((img) { - return img.tags.toSet().intersection(_selectedTags).isNotEmpty; - }).toList(); - }); - } - } - - void showFilterDialog() { - showModalBottomSheet( - context: context, - backgroundColor: Colors.transparent, - isScrollControlled: true, - builder: (context) { - return StatefulBuilder( - builder: (context, setModalState) { - return Container( - height: MediaQuery.of(context).size.height * 0.6, - decoration: const BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - "Filter by Tags", - style: Variables.headerStyle.copyWith(fontSize: 20), - ), - IconButton( - icon: const Icon(Icons.close), - onPressed: () => Navigator.pop(context), - ), - ], - ), - const SizedBox(height: 20), - if (_allTags.isEmpty) - Text("No tags available.", style: Variables.bodyStyle), - - Expanded( - child: SingleChildScrollView( - child: Wrap( - spacing: 10, - runSpacing: 10, - children: - _allTags.map((tag) { - final isSelected = _selectedTags.contains(tag); - return FilterChip( - label: Text(tag.toUpperCase()), - selected: isSelected, - onSelected: (selected) { - setModalState(() { - if (selected) { - _selectedTags.add(tag); - } else { - _selectedTags.remove(tag); - } - }); - // Update main state - setState(() { - _applyFilter(); - }); - }, - labelStyle: Variables.captionStyle.copyWith( - color: - isSelected - ? Colors.white - : Variables.textPrimary, - fontWeight: FontWeight.w600, - ), - backgroundColor: Variables.surfaceSubtle, - selectedColor: Variables.textPrimary, - checkmarkColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - side: BorderSide.none, - ), - ); - }).toList(), - ), - ), - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: TextButton( - onPressed: () { - setState(() { - _selectedTags.clear(); - _applyFilter(); - }); - Navigator.pop(context); - }, - child: const Text( - "Clear All", - style: TextStyle(color: Colors.red), - ), - ), - ), - Expanded( - child: ElevatedButton( - onPressed: () => Navigator.pop(context), - style: ElevatedButton.styleFrom( - backgroundColor: Variables.textPrimary, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: const Text("Done"), - ), - ), - ], - ), - ], - ), - ); - }, - ); - }, - ); + setState(() { + _filteredImages = + _selectedTags.isEmpty + ? _allImages + : _allImages + .where( + (img) => + img.tags.toSet().intersection(_selectedTags).isNotEmpty, + ) + .toList(); + }); } @override @@ -206,11 +78,33 @@ class ProjectBoardPageAlternateState extends State<ProjectBoardPageAlternate> { final rightColumn = <Widget>[]; for (int i = 0; i < _filteredImages.length; i++) { - final item = _buildImageItem(_filteredImages[i], index: i); + final item = MoodboardImageCard( + image: _filteredImages[i], + height: (i % 3 == 0) ? 240 : 180, + showTags: true, + onTap: + () => Navigator.push( + context, + MaterialPageRoute( + builder: + (_) => ImageDetailsPage( + imagePath: _filteredImages[i].filePath, + imageId: _filteredImages[i].id, + projectId: widget.projectId, + ), + ), + ), + onDeleted: refreshData, + ); + if (i % 2 == 0) { - leftColumn.add(item); + leftColumn.add( + Padding(padding: const EdgeInsets.only(bottom: 12), child: item), + ); } else { - rightColumn.add(item); + rightColumn.add( + Padding(padding: const EdgeInsets.only(bottom: 12), child: item), + ); } } @@ -248,55 +142,19 @@ class ProjectBoardPageAlternateState extends State<ProjectBoardPageAlternate> { Expanded( child: _filteredImages.isEmpty - ? Center( - child: Text( - "No images found", - style: Variables.bodyStyle.copyWith( - color: Variables.textSecondary, - ), - ), + ? const EmptyState( + icon: Icons.image_not_supported_outlined, + title: "No images found", + subtitle: "Try removing filters or adding new images", ) : SingleChildScrollView( - padding: const EdgeInsets.fromLTRB( - 16, - 0, - 16, - 80, - ), // Bottom padding for FAB + padding: const EdgeInsets.fromLTRB(16, 0, 16, 80), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: Column( - children: - leftColumn - .map( - (e) => Padding( - padding: const EdgeInsets.only( - bottom: 12, - ), - child: e, - ), - ) - .toList(), - ), - ), + Expanded(child: Column(children: leftColumn)), const SizedBox(width: 12), - Expanded( - child: Column( - children: - rightColumn - .map( - (e) => Padding( - padding: const EdgeInsets.only( - bottom: 12, - ), - child: e, - ), - ) - .toList(), - ), - ), + Expanded(child: Column(children: rightColumn)), ], ), ), @@ -304,125 +162,4 @@ class ProjectBoardPageAlternateState extends State<ProjectBoardPageAlternate> { ], ); } - - Widget _buildImageItem(ImageModel image, {required int index}) { - return ImageContextMenu( - image: image, - onImageDeleted: () => refreshData(), - child: GestureDetector( - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: - (_) => ImageDetailsPage( - imagePath: image.filePath, - imageId: image.id, - projectId: widget.projectId, - ), - ), - ); - }, - child: Container( - height: (index % 3 == 0) ? 240 : 180, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: Variables.surfaceSubtle, - ), - clipBehavior: Clip.antiAlias, - child: Stack( - fit: StackFit.expand, - children: [ - Image.file( - File(image.filePath), - fit: BoxFit.cover, - width: double.infinity, - errorBuilder: - (_, __, ___) => const Center( - child: Icon( - Icons.broken_image, - color: Variables.textDisabled, - ), - ), - ), - if (image.tags.isNotEmpty) - Positioned( - bottom: 0, - left: 0, - right: 0, - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.bottomCenter, - end: Alignment.topCenter, - colors: [ - Colors.black.withValues(alpha: 0.8), - Colors.transparent, - ], - ), - ), - child: Wrap( - spacing: 4, - runSpacing: 4, - children: [ - // Show first 2 tags - ...image.tags.take(2).map((tag) { - return Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(4), - border: Border.all( - color: Colors.white.withValues(alpha: 0.1), - ), - ), - child: Text( - tag.toUpperCase(), - style: const TextStyle( - color: Colors.white, - fontSize: 9, - fontFamily: 'GeneralSans', - fontWeight: FontWeight.w600, - ), - ), - ); - }), - // Show "+x" tag if there are more than 2 tags - if (image.tags.length > 2) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(4), - border: Border.all( - color: Colors.white.withValues(alpha: 0.1), - ), - ), - child: Text( - '+${image.tags.length - 2}', - style: const TextStyle( - color: Colors.white, - fontSize: 9, - fontFamily: 'GeneralSans', - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ), - ), - ), - ], - ), - ), - ), - ); - } } diff --git a/lib/ui/pages/project_tag_page.dart b/lib/ui/pages/project_tag_page.dart @@ -4,11 +4,12 @@ import 'package:image_picker/image_picker.dart'; import 'package:creekui/ui/styles/variables.dart'; import 'package:creekui/ui/widgets/top_bar.dart'; import 'package:creekui/ui/widgets/bottom_bar.dart'; +import 'package:creekui/ui/widgets/moodboard_image_card.dart'; +import 'package:creekui/ui/widgets/empty_state.dart'; import 'package:creekui/data/models/image_model.dart'; import 'package:creekui/data/repos/image_repo.dart'; import 'package:creekui/data/repos/project_repo.dart'; import 'package:creekui/data/repos/note_repo.dart'; -import 'package:creekui/ui/widgets/image_context_menu.dart'; import 'image_save_page.dart'; import 'image_details_page.dart'; @@ -45,31 +46,23 @@ class _ProjectTagPageState extends State<ProjectTagPage> { final allImages = await _imageRepo.getImages(widget.projectId); final List<ImageModel> filtered = []; - // Process logic in parallel for speed await Future.wait( allImages.map((img) async { bool matches = false; - if (widget.tag == 'Uncategorized') { if (img.tags.isEmpty) { final notes = await _noteRepo.getNotesForImage(img.id); - final hasCategorizedNote = notes.any((n) => n.category.isNotEmpty); - if (!hasCategorizedNote) matches = true; + if (!notes.any((n) => n.category.isNotEmpty)) matches = true; } } else { - if (img.tags.contains(widget.tag)) { + if (img.tags.contains(widget.tag)) matches = true; - } else { + else { final notes = await _noteRepo.getNotesForImage(img.id); - if (notes.any((n) => n.category == widget.tag)) { - matches = true; - } + if (notes.any((n) => n.category == widget.tag)) matches = true; } } - - if (matches) { - filtered.add(img); - } + if (matches) filtered.add(img); }), ); @@ -106,154 +99,77 @@ class _ProjectTagPageState extends State<ProjectTagPage> { @override Widget build(BuildContext context) { - // 1. Prepare Columns for Masonry Layout final leftColumn = <Widget>[]; final rightColumn = <Widget>[]; for (int i = 0; i < _images.length; i++) { - final item = _buildStaggeredImageItem(_images[i], index: i); + final item = MoodboardImageCard( + image: _images[i], + height: (i % 3 == 0) ? 240 : 180, + onTap: + () => Navigator.push( + context, + MaterialPageRoute( + builder: + (_) => ImageDetailsPage( + imagePath: _images[i].filePath, + imageId: _images[i].id, + projectId: widget.projectId, + ), + ), + ).then((_) => _loadData()), + onDeleted: _loadData, + ); + if (i % 2 == 0) { - leftColumn.add(item); + leftColumn.add( + Padding(padding: const EdgeInsets.only(bottom: 12), child: item), + ); } else { - rightColumn.add(item); + rightColumn.add( + Padding(padding: const EdgeInsets.only(bottom: 12), child: item), + ); } } return Scaffold( backgroundColor: Variables.background, - appBar: TopBar( currentProjectId: widget.projectId, titleOverride: widget.tag.toUpperCase(), onBack: () => Navigator.pop(context), hideSecondRow: true, ), - bottomNavigationBar: BottomBar( currentTab: BottomBarItem.moodboard, projectId: widget.projectId, ), - floatingActionButton: FloatingActionButton( onPressed: _pickAndRedirect, backgroundColor: Variables.textPrimary, foregroundColor: Variables.background, child: const Icon(Icons.add_photo_alternate_outlined), ), - body: _isLoading ? const Center(child: CircularProgressIndicator()) : _images.isEmpty - ? Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.image_not_supported_outlined, - size: 64, - color: Variables.textDisabled, - ), - const SizedBox(height: 16), - Text( - "No images found for '${widget.tag}'", - style: Variables.bodyStyle.copyWith( - color: Variables.textSecondary, - ), - ), - ], - ), + ? EmptyState( + icon: Icons.image_not_supported_outlined, + title: "No images found", + subtitle: "No images found for '${widget.tag}'", ) : SingleChildScrollView( padding: const EdgeInsets.fromLTRB(16, 16, 16, 80), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: Column( - children: - leftColumn - .map( - (e) => Padding( - padding: const EdgeInsets.only(bottom: 12), - child: e, - ), - ) - .toList(), - ), - ), + Expanded(child: Column(children: leftColumn)), const SizedBox(width: 12), - Expanded( - child: Column( - children: - rightColumn - .map( - (e) => Padding( - padding: const EdgeInsets.only(bottom: 12), - child: e, - ), - ) - .toList(), - ), - ), + Expanded(child: Column(children: rightColumn)), ], ), ), ); } - - Widget _buildStaggeredImageItem(ImageModel image, {required int index}) { - return ImageContextMenu( - image: image, - onImageDeleted: () => _loadData(), - child: GestureDetector( - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: - (_) => ImageDetailsPage( - imagePath: image.filePath, - imageId: image.id, - projectId: widget.projectId, - ), - ), - ).then((_) => _loadData()); - }, - child: Container( - // Simulate staggered heights similar to Alternate Page - height: (index % 3 == 0) ? 240 : 180, - decoration: BoxDecoration( - color: Variables.surfaceSubtle, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: Variables.borderSubtle), - ), - // Explicitly clip content to border radius - child: ClipRRect( - borderRadius: BorderRadius.circular(16), - child: Stack( - fit: StackFit.expand, - children: [ - Image.file( - File(image.filePath), - fit: BoxFit.cover, - width: double.infinity, - errorBuilder: - (_, __, ___) => Container( - color: Variables.surfaceSubtle, - child: const Center( - child: Icon( - Icons.broken_image, - color: Variables.textDisabled, - ), - ), - ), - ), - ], - ), - ), - ), - ), - ); - } } diff --git a/lib/ui/pages/share_handler_page.dart b/lib/ui/pages/share_handler_page.dart @@ -3,6 +3,8 @@ import 'package:flutter/material.dart'; import 'package:creekui/services/download_service.dart'; import 'package:creekui/services/instagram_download_service.dart'; import 'package:creekui/services/image_service.dart'; +import 'package:creekui/ui/styles/variables.dart'; +import 'package:creekui/ui/widgets/primary_button.dart'; import 'share_to_moodboard_page.dart'; import 'share_to_file_page.dart'; @@ -21,12 +23,10 @@ class ShareHandlerPage extends StatefulWidget { } class _ShareHandlerPageState extends State<ShareHandlerPage> { - // Services final _downloadService = DownloadService(); final _instagramService = InstagramDownloadService(); final _imageService = ImageService(); - // State bool _hasError = false; String? _errorMessage; @@ -41,18 +41,17 @@ class _ShareHandlerPageState extends State<ShareHandlerPage> { List<File> tempFiles = []; try { - // CASE A: It's a Local File Path + // CASE A: Local File Path if (await File(sharedContent).exists()) { tempFiles.add(File(sharedContent)); } - // CASE B: It's a URL + // CASE B: URL else { final urlRegExp = RegExp(r'(https?://\S+)'); final match = urlRegExp.firstMatch(sharedContent); if (match != null) { final url = match.group(0)!; - if (url.contains('instagram.com')) { // Instagram Logic final downloadedPaths = await _instagramService @@ -72,13 +71,11 @@ class _ShareHandlerPageState extends State<ShareHandlerPage> { } } - // SUCCESS: Route based on Destination + // Success: Route based on Destination if (tempFiles.isNotEmpty) { if (!mounted) return; - - // CHECK DESTINATION if (widget.destination == 'files') { - // --- ROUTE TO FILES --- + // Files Navigator.pushReplacement( context, MaterialPageRoute( @@ -86,22 +83,15 @@ class _ShareHandlerPageState extends State<ShareHandlerPage> { ), ); } else { - // --- ROUTE TO MOODBOARDS (Default) --- + // Moodboard List<File> permanentFiles = []; - for (var file in tempFiles) { - final id = await _imageService.saveImage( - file, - 0, // Project 0 = Inbox - tags: [], - ); - + final id = await _imageService.saveImage(file, 0, tags: []); final savedImage = await _imageService.getImage(id); if (savedImage != null) { permanentFiles.add(File(savedImage.filePath)); } } - if (mounted) { Navigator.pushReplacement( context, @@ -146,7 +136,7 @@ class _ShareHandlerPageState extends State<ShareHandlerPage> { const SizedBox(height: 16), Text( "Error processing media", - style: Theme.of(context).textTheme.titleMedium, + style: Variables.headerStyle.copyWith(fontSize: 18), ), const SizedBox(height: 8), Text( @@ -155,9 +145,12 @@ class _ShareHandlerPageState extends State<ShareHandlerPage> { style: const TextStyle(color: Colors.grey), ), const SizedBox(height: 24), - ElevatedButton( - onPressed: () => Navigator.pop(context), - child: const Text("Close"), + SizedBox( + width: 200, + child: PrimaryButton( + text: "Close", + onPressed: () => Navigator.pop(context), + ), ), ], ), diff --git a/lib/ui/pages/share_to_file_page.dart b/lib/ui/pages/share_to_file_page.dart @@ -6,6 +6,11 @@ import 'package:creekui/services/file_service.dart'; import 'package:creekui/services/project_service.dart'; import 'package:creekui/data/models/file_model.dart'; import 'package:creekui/data/models/project_model.dart'; +import 'package:creekui/ui/styles/variables.dart'; +import 'package:creekui/ui/widgets/search_bar.dart'; +import 'package:creekui/ui/widgets/file_card.dart'; +import 'package:creekui/ui/widgets/empty_state.dart'; +import 'package:creekui/ui/widgets/section_header.dart'; import 'create_file_page.dart'; import 'canvas_page.dart'; @@ -20,17 +25,14 @@ class ShareToFilePage extends StatefulWidget { class _ShareToFilePageState extends State<ShareToFilePage> { final FileService _fileService = FileService(); final ProjectService _projectService = ProjectService(); - final TextEditingController _searchController = TextEditingController(); List<FileModel> _allFiles = []; List<FileModel> _filteredFiles = []; List<FileModel> _recentFiles = []; - - // file.id -> { 'preview': path, 'dimensions': str } Map<String, Map<String, String>> _fileMetadata = {}; - // project cache to avoid repeated fetches + // Avoid repeated fetches final Map<int, ProjectModel> _projectCache = {}; bool _isLoading = true; @@ -64,29 +66,23 @@ class _ShareToFilePageState extends State<ShareToFilePage> { _filteredFiles = List.from(files); _recentFiles = recent.take(3).toList(); - // Load metadata for files (previews, dimensions) await _loadFileMetadata(files); - setState(() => _isLoading = false); } catch (e) { - debugPrint('Error fetching files in ShareToFilePage: $e'); + debugPrint('Error fetching files: $e'); setState(() => _isLoading = false); } } Future<void> _loadFileMetadata(List<FileModel> files) async { final Map<String, Map<String, String>> meta = {}; - for (final fmodel in files) { try { final f = File(fmodel.filePath); - if (!await f.exists()) { - // skip if disk file missing - continue; - } + if (!await f.exists()) continue; if (fmodel.filePath.toLowerCase().endsWith('.json')) { - // Canvas JSON - attempt to parse preview_path, width/height + // File JSON try { final content = await f.readAsString(); final data = jsonDecode(content); @@ -94,8 +90,7 @@ class _ShareToFilePageState extends State<ShareToFilePage> { String dims = 'Unknown'; if (data is Map) { - if (data['preview_path'] != null && - data['preview_path'].toString().isNotEmpty) { + if (data['preview_path'] != null) { preview = data['preview_path'].toString(); // If preview is relative, try to resolve relative to JSON file if (!File(preview).existsSync()) { @@ -104,35 +99,24 @@ class _ShareToFilePageState extends State<ShareToFilePage> { if (candidate.existsSync()) preview = candidate.path; } } - if (data['width'] != null && data['height'] != null) { - final w = (data['width'] as num).toInt(); - final h = (data['height'] as num).toInt(); - dims = '$w x $h px'; + dims = '${data['width']} x ${data['height']} px'; } } - meta[fmodel.id] = {'preview': preview, 'dimensions': dims}; - } catch (e) { - debugPrint('Error parsing canvas json for ${fmodel.id}: $e'); - } + } catch (_) {} } else { - // Regular image file - assign path and try to get dims + // Regular image file - assign path and try String dims = 'Unknown'; try { final bytes = await f.readAsBytes(); final image = img.decodeImage(bytes); if (image != null) dims = '${image.width} x ${image.height} px'; - } catch (_) { - // ignore - } + } catch (_) {} meta[fmodel.id] = {'preview': fmodel.filePath, 'dimensions': dims}; } - } catch (e) { - debugPrint('Error while loading metadata for ${fmodel.id}: $e'); - } + } catch (_) {} } - _fileMetadata = meta; } @@ -147,8 +131,7 @@ class _ShareToFilePageState extends State<ShareToFilePage> { _allFiles.where((file) { final nameMatch = file.name.toLowerCase().contains(q); final breadcrumb = _getProjectBreadcrumbSync(file).toLowerCase(); - final projectMatch = breadcrumb.contains(q); - return nameMatch || projectMatch; + return nameMatch || breadcrumb.contains(q); }).toList(); } }); @@ -167,69 +150,40 @@ class _ShareToFilePageState extends State<ShareToFilePage> { // Async breadcrumb loader that fetches projects into cache as needed Future<String> _getProjectEventLabel(FileModel file) async { - // load file.projectId if (!_projectCache.containsKey(file.projectId)) { try { final p = await _projectService.getProjectById(file.projectId); if (p != null) _projectCache[file.projectId] = p; - } catch (e) { - debugPrint('Project load failed for ${file.projectId}: $e'); - } + } catch (_) {} } - final project = _projectCache[file.projectId]; if (project == null) return "Unknown"; + if (project.parentId == null) return project.title; - if (project.parentId == null) { - return project.title; - } - - final parentId = project.parentId!; - if (!_projectCache.containsKey(parentId)) { + if (!_projectCache.containsKey(project.parentId!)) { try { - final parent = await _projectService.getProjectById(parentId); - if (parent != null) _projectCache[parentId] = parent; - } catch (e) { - debugPrint('Parent project load failed for $parentId: $e'); - } + final parent = await _projectService.getProjectById(project.parentId!); + if (parent != null) _projectCache[project.parentId!] = parent; + } catch (_) {} } - - final parentProject = _projectCache[parentId]; - if (parentProject == null) return project.title; - return "${parentProject.title} / ${project.title}"; - } - - void _onAddPressed() { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => CreateFilePage(file: widget.sharedImage), - ), - ); + final parent = _projectCache[project.parentId]; + return parent == null + ? project.title + : "${parent.title} / ${project.title}"; } void _onFileSelected(FileModel file) async { try { final f = File(file.filePath); - if (!await f.exists()) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text("File not found"))); - return; - } - - double width = 1080; - double height = 1080; + if (!await f.exists()) return; + double width = 1080, height = 1080; if (file.filePath.toLowerCase().endsWith('.json')) { final content = await f.readAsString(); final data = jsonDecode(content); - - if (data is Map) { - if (data['width'] != null && data['height'] != null) { - width = (data['width'] as num).toDouble(); - height = (data['height'] as num).toDouble(); - } + if (data is Map && data['width'] != null) { + width = (data['width'] as num).toDouble(); + height = (data['height'] as num).toDouble(); } } @@ -237,7 +191,7 @@ class _ShareToFilePageState extends State<ShareToFilePage> { context, MaterialPageRoute( builder: - (context) => CanvasPage( + (_) => CanvasPage( projectId: file.projectId, width: width, height: height, @@ -252,68 +206,38 @@ class _ShareToFilePageState extends State<ShareToFilePage> { } String _formatDate(DateTime date) { - final now = DateTime.now(); - final diff = now.difference(date); + final diff = DateTime.now().difference(date); if (diff.inDays == 0) return 'Today'; if (diff.inDays == 1) return 'Yesterday'; if (diff.inDays < 7) return '${diff.inDays} days ago'; return '${date.day}/${date.month}/${date.year}'; } - // Resolve preview path; if empty or missing, fallback to original filePath - String _resolvePreviewPath(FileModel file) { - final meta = _fileMetadata[file.id]; - if (meta == null) return file.filePath; - final preview = meta['preview'] ?? ''; - if (preview.isNotEmpty && File(preview).existsSync()) return preview; - // fallback: if file is json and has no preview, return placeholder or file.path - if (file.filePath.toLowerCase().endsWith('.json')) { - // attempt to find PNG/JPG sibling in same folder with same base name - final f = File(file.filePath); - final base = f.uri.pathSegments.last; - final nameWithoutExt = base.split('.').first; - final parent = f.parent; - final candidates = [ - '${parent.path}/$nameWithoutExt.png', - '${parent.path}/$nameWithoutExt.jpg', - '${parent.path}/preview_$nameWithoutExt.png', - ]; - for (final c in candidates) { - if (File(c).existsSync()) return c; - } - } - if (File(file.filePath).existsSync()) return file.filePath; - return ''; - } - @override Widget build(BuildContext context) { - final theme = Theme.of(context); - return Scaffold( - backgroundColor: Colors.white, + backgroundColor: Variables.background, appBar: AppBar( - backgroundColor: Colors.white, + backgroundColor: Variables.background, elevation: 0, leading: IconButton( icon: const Icon(Icons.arrow_back, color: Colors.black), onPressed: () => Navigator.pop(context), ), - title: const Text( + title: Text( 'Files', - style: TextStyle( - color: Color(0xFF27272A), - fontFamily: 'GeneralSans', - fontSize: 20, - fontWeight: FontWeight.w500, - height: 1.2, - ), + style: Variables.headerStyle.copyWith(fontSize: 20), ), actions: [ IconButton( icon: const Icon(Icons.add, color: Colors.black, size: 28), - onPressed: _onAddPressed, - tooltip: "Create New File", + onPressed: + () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => CreateFilePage(file: widget.sharedImage), + ), + ), ), ], ), @@ -321,130 +245,61 @@ class _ShareToFilePageState extends State<ShareToFilePage> { _isLoading ? const Center(child: CircularProgressIndicator()) : _allFiles.isEmpty - ? _buildEmptyState() + ? const EmptyState( + icon: Icons.folder_open, + title: "No files yet", + subtitle: "Tap + to create your first file", + ) : Column( children: [ - // Search bar Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), - child: SizedBox( - height: 42, - child: TextField( - controller: _searchController, - onChanged: _filterFiles, - style: const TextStyle( - fontFamily: "GeneralSans", - fontSize: 16, - fontWeight: FontWeight.w400, - color: Color(0xFF71717B), - height: 1.4, - ), - decoration: InputDecoration( - hintText: "Search", - hintStyle: const TextStyle( - fontFamily: "GeneralSans", - fontSize: 16, - fontWeight: FontWeight.w400, - color: Color(0xFF71717B), - ), - prefixIcon: const Icon( - Icons.search, - size: 20, - color: Color(0xFF9F9FA9), - ), - filled: true, - fillColor: const Color(0xFFE4E4E7), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide.none, - ), - contentPadding: const EdgeInsets.symmetric( - vertical: 12, - horizontal: 16, - ), - suffixIcon: - _searchQuery.isNotEmpty - ? IconButton( - icon: const Icon( - Icons.clear, - size: 20, - color: Color(0xFF9F9FA9), - ), - padding: EdgeInsets.zero, - onPressed: () { - _searchController.clear(); - _filterFiles(''); - }, - ) - : null, - ), - ), + child: CommonSearchBar( + controller: _searchController, + onChanged: _filterFiles, ), ), - Expanded( child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Recent Files if (_recentFiles.isNotEmpty && _searchQuery.isEmpty) ...[ const Padding( padding: EdgeInsets.symmetric( - horizontal: 20, + horizontal: 16, vertical: 8, ), - child: Text( - "Recent Files", - style: TextStyle( - fontFamily: 'GeneralSans', - fontSize: 14, - fontWeight: FontWeight.w400, - color: Color(0xFF27272A), - height: 1.43, - ), - ), + child: SectionHeader(title: "Recent Files"), ), Padding( padding: const EdgeInsets.symmetric( - horizontal: 20, + horizontal: 16, ), child: Column( children: _recentFiles - .map( - (file) => _buildRecentFileItem(file), - ) + .map((file) => _buildFileCard(file)) .toList(), ), ), const SizedBox(height: 24), ], - - // All files header Padding( padding: const EdgeInsets.symmetric( - horizontal: 20, + horizontal: 16, vertical: 8, ), - child: Text( - _searchQuery.isEmpty - ? "All Files" - : "Search Results", - style: const TextStyle( - fontFamily: 'GeneralSans', - fontSize: 14, - fontWeight: FontWeight.w400, - color: Color(0xFF27272A), - height: 1.43, - ), + child: SectionHeader( + title: + _searchQuery.isEmpty + ? "All Files" + : "Search Results", ), ), - - // All files list Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), + padding: const EdgeInsets.symmetric(horizontal: 16), child: ListView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), @@ -464,202 +319,26 @@ class _ShareToFilePageState extends State<ShareToFilePage> { ); } - Widget _buildEmptyState() { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.folder_open, size: 80, color: Colors.grey[300]), - const SizedBox(height: 16), - Text( - "No files yet", - style: TextStyle(color: Colors.grey[500], fontSize: 16), + Widget _buildFileCard(FileModel file) { + return FutureBuilder<String>( + future: _getProjectEventLabel(file), + builder: (context, snapshot) { + final meta = _fileMetadata[file.id] ?? {}; + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: FileCard( + file: file, + breadcrumb: snapshot.data ?? "", + dimensions: meta['dimensions'] ?? "Unknown", + previewPath: meta['preview'] ?? "", + timeAgo: _formatDate(file.lastUpdated), + onTap: () => _onFileSelected(file), + onMenuAction: null, ), - const SizedBox(height: 8), - TextButton( - onPressed: _onAddPressed, - child: const Text("Create your first file"), - ), - ], - ), - ); - } - - Widget _buildRecentFileItem(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: [ - 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), - - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - FutureBuilder<String>( - future: _getProjectEventLabel(file), - builder: - (_, s) => Text( - s.data ?? "", - style: const TextStyle( - fontSize: 12, - color: Color(0xFF71717B), - fontFamily: "GeneralSans", - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(height: 6), - Text( - file.name, - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, - fontFamily: "GeneralSans", - ), - ), - const SizedBox(height: 6), - Text( - _formatDate(file.lastUpdated), - style: const TextStyle( - fontSize: 12, - color: Color(0xFF71717B), - ), - ), - ], - ), - ), - ), - ], - ), - ), - ); - } - - 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: [ - 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), - - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - FutureBuilder<String>( - future: _getProjectEventLabel(file), - builder: - (_, s) => Text( - s.data ?? "", - style: const TextStyle( - fontSize: 12, - color: Color(0xFF71717B), - fontFamily: "GeneralSans", - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - - const SizedBox(height: 4), - - Text( - file.name, - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, - color: Color(0xFF27272A), - fontFamily: "GeneralSans", - ), - ), - - const SizedBox(height: 6), - - Text( - _formatDate(file.lastUpdated), - style: const TextStyle( - fontSize: 12, - color: Color(0xFF71717B), - ), - ), - ], - ), - ), - ), - ], - ), - ), + ); + }, ); } - Widget _placeholderIcon() { - return Container( - color: Colors.grey[200], - child: Icon(Icons.image, size: 28, color: Colors.grey[400]), - ); - } + Widget _buildFileItem(FileModel file) => _buildFileCard(file); } diff --git a/lib/ui/pages/share_to_moodboard_page.dart b/lib/ui/pages/share_to_moodboard_page.dart @@ -5,17 +5,16 @@ import 'package:creekui/data/models/project_model.dart'; import 'package:creekui/data/repos/project_repo.dart'; import 'package:creekui/data/repos/image_repo.dart'; import 'package:creekui/services/project_service.dart'; +import 'package:creekui/ui/styles/variables.dart'; +import 'package:creekui/ui/widgets/search_bar.dart'; +import 'package:creekui/ui/widgets/section_header.dart'; import 'image_save_page.dart'; -// --- VIEW MODELS --- - class ProjectItemViewModel { final ProjectModel item; final String? parentTitle; final String? coverPath; - ProjectItemViewModel({required this.item, this.parentTitle, this.coverPath}); - String get title => item.title; bool get isEvent => item.isEvent; int get id => item.id!; @@ -26,7 +25,6 @@ class ProjectGroup { final List<ProjectItemViewModel> events; final String? coverPath; bool isExpanded; - ProjectGroup({ required this.project, this.events = const [], @@ -35,11 +33,8 @@ class ProjectGroup { }); } -// --- WIDGET --- - class ShareToMoodboardPage extends StatefulWidget { final List<File> imageFiles; - const ShareToMoodboardPage({super.key, required this.imageFiles}); @override @@ -74,12 +69,8 @@ class _ShareToMoodboardPageState extends State<ShareToMoodboardPage> { Future<String?> _getProjectCover(int projectId) async { try { final images = await _imageRepo.getImages(projectId); - if (images.isNotEmpty) { - return images.first.filePath; - } - } catch (e) { - debugPrint("Error fetching cover for project $projectId: $e"); - } + if (images.isNotEmpty) return images.first.filePath; + } catch (_) {} return null; } @@ -98,9 +89,7 @@ class _ShareToMoodboardPageState extends State<ShareToMoodboardPage> { final parent = await _projectRepo.getProjectById(item.parentId!); parentTitle = parent?.title; } - final cover = await _getProjectCover(item.id!); - recents.add( ProjectItemViewModel( item: item, @@ -120,15 +109,12 @@ class _ShareToMoodboardPageState extends State<ShareToMoodboardPage> { final eCover = await _getProjectCover(e.id!); eventVMs.add(ProjectItemViewModel(item: e, coverPath: eCover)); } - final pCover = await _getProjectCover(p.id!); - groups.add(ProjectGroup(project: p, events: eventVMs, coverPath: pCover)); } _groupedProjects = groups; _filteredGroupedProjects = groups; - if (mounted) setState(() => _isLoading = false); } @@ -144,7 +130,6 @@ class _ShareToMoodboardPageState extends State<ShareToMoodboardPage> { final projectMatch = g.project.title.toLowerCase().contains(q); final matchingEvents = g.events.where((e) => e.title.toLowerCase().contains(q)).toList(); - if (projectMatch) { filtered.add( ProjectGroup( @@ -210,7 +195,6 @@ class _ShareToMoodboardPageState extends State<ShareToMoodboardPage> { }) { _projectService.openProject(projectId); ReceiveSharingIntent.instance.reset(); - Navigator.push( context, MaterialPageRoute( @@ -237,15 +221,9 @@ class _ShareToMoodboardPageState extends State<ShareToMoodboardPage> { icon: const Icon(Icons.arrow_back, color: Colors.black), onPressed: () => Navigator.pop(context), ), - title: const Text( + title: Text( "MoodBoards", - style: TextStyle( - color: Color(0xFF27272A), - fontFamily: 'GeneralSans', - fontSize: 20, - fontWeight: FontWeight.w500, - height: 1.2, - ), + style: Variables.headerStyle.copyWith(fontSize: 20), ), actions: [ IconButton( @@ -260,70 +238,18 @@ class _ShareToMoodboardPageState extends State<ShareToMoodboardPage> { ? const Center(child: CircularProgressIndicator()) : Column( children: [ - // --- Search Bar --- Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), - child: SizedBox( - height: 42, - child: TextField( - controller: _searchController, - onChanged: _filterProjects, - style: const TextStyle( - fontFamily: "GeneralSans", - fontSize: 16, - fontWeight: FontWeight.w400, - color: Color(0xFF71717B), - height: 1.4, - ), - decoration: InputDecoration( - hintText: "Search", - hintStyle: const TextStyle( - fontFamily: "GeneralSans", - fontSize: 16, - fontWeight: FontWeight.w400, - color: Color(0xFF71717B), - ), - prefixIcon: const Icon( - Icons.search, - size: 20, - color: Color(0xFF9F9FA9), - ), - filled: true, - fillColor: const Color(0xFFE4E4E7), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide.none, - ), - contentPadding: const EdgeInsets.symmetric( - vertical: 12, - horizontal: 16, - ), - suffixIcon: - _searchQuery.isNotEmpty - ? IconButton( - icon: const Icon( - Icons.clear, - size: 20, - color: Color(0xFF9F9FA9), - ), - padding: EdgeInsets.zero, - onPressed: () { - _searchController.clear(); - _filterProjects(""); - }, - ) - : null, - ), - ), + child: CommonSearchBar( + controller: _searchController, + onChanged: _filterProjects, ), ), - Expanded( child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // --- RECENT SECTION --- if (_searchQuery.isEmpty && _recentViewModels.isNotEmpty) ...[ const Padding( @@ -331,15 +257,8 @@ class _ShareToMoodboardPageState extends State<ShareToMoodboardPage> { horizontal: 20, vertical: 8, ), - child: Text( - "Recent Projects/Events", - style: TextStyle( - fontFamily: 'GeneralSans', - fontSize: 14, - fontWeight: FontWeight.w400, - color: Color(0xFF27272A), - height: 1.43, - ), + child: SectionHeader( + title: "Recent Projects/Events", ), ), Padding( @@ -355,24 +274,16 @@ class _ShareToMoodboardPageState extends State<ShareToMoodboardPage> { ), const SizedBox(height: 24), ], - - // --- ALL PROJECTS SECTION --- Padding( padding: const EdgeInsets.symmetric( horizontal: 20, vertical: 8, ), - child: Text( - _searchQuery.isEmpty - ? "All Projects/Events" - : "Search Results", - style: const TextStyle( - fontFamily: 'GeneralSans', - fontSize: 14, - fontWeight: FontWeight.w400, - color: Color(0xFF27272A), - height: 1.43, - ), + child: SectionHeader( + title: + _searchQuery.isEmpty + ? "All Projects/Events" + : "Search Results", ), ), Padding( @@ -397,11 +308,10 @@ class _ShareToMoodboardPageState extends State<ShareToMoodboardPage> { ); } - // --- WIDGETS --- - + // Wrappers for consistent UI Widget _buildRecentItem(ProjectItemViewModel vm) { return Container( - margin: const EdgeInsets.only(bottom: 8), // Reduced spacing + margin: const EdgeInsets.only(bottom: 8), child: InkWell( onTap: () => _navigateToSavePage( @@ -411,15 +321,11 @@ class _ShareToMoodboardPageState extends State<ShareToMoodboardPage> { ), borderRadius: BorderRadius.circular(16), child: Container( - // padding: 4px 0 4px 4px padding: const EdgeInsets.fromLTRB(4, 4, 0, 4), decoration: BoxDecoration( - color: Colors.white, // #FAFAFA + color: Colors.white, borderRadius: BorderRadius.circular(12), - border: Border.all( - color: const Color(0xFFE4E4E7), - width: 1, - ), // #E4E4E7 + border: Border.all(color: Variables.borderSubtle, width: 1), ), child: Row( children: [ @@ -428,7 +334,7 @@ class _ShareToMoodboardPageState extends State<ShareToMoodboardPage> { width: 56, height: 56, decoration: BoxDecoration( - color: const Color(0xFFFAFAFA), + color: Variables.surfaceSubtle, borderRadius: BorderRadius.circular(8), image: vm.coverPath != null @@ -443,35 +349,24 @@ class _ShareToMoodboardPageState extends State<ShareToMoodboardPage> { ? Icon(Icons.image, color: Colors.grey[400], size: 28) : null, ), - const SizedBox(width: 10), // 10px Gap - // Text Content + const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ if (vm.isEvent && vm.parentTitle != null) - Padding( - padding: const EdgeInsets.only(bottom: 2), - child: Text( - vm.parentTitle!, - style: const TextStyle( - fontFamily: 'GeneralSans', - fontSize: 12, - color: Color(0xFF27272A), - fontWeight: FontWeight.w400, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), + Text( + vm.parentTitle!, + style: Variables.captionStyle.copyWith(fontSize: 12), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), Text( vm.title, - style: const TextStyle( - fontFamily: 'GeneralSans', - fontSize: 16, + style: Variables.bodyStyle.copyWith( fontWeight: FontWeight.w500, - color: Color(0xFF27272A), // Same black as event + fontSize: 16, ), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -495,10 +390,8 @@ class _ShareToMoodboardPageState extends State<ShareToMoodboardPage> { clipBehavior: Clip.antiAlias, decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(12), // Match recent radius - border: Border.all( - color: const Color(0xFFE4E4E7), - ), // Match recent border + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Variables.borderSubtle), ), child: Column( children: [ @@ -512,8 +405,8 @@ class _ShareToMoodboardPageState extends State<ShareToMoodboardPage> { contentPadding: const EdgeInsets.symmetric( horizontal: 16, vertical: 4, - ), // Reduced vertical padding - visualDensity: VisualDensity.compact, // Reduce height + ), + visualDensity: VisualDensity.compact, leading: Container( width: 48, height: 48, @@ -535,11 +428,9 @@ class _ShareToMoodboardPageState extends State<ShareToMoodboardPage> { ), title: Text( project.title, - style: const TextStyle( - fontFamily: 'GeneralSans', + style: Variables.bodyStyle.copyWith( + fontWeight: FontWeight.w500, fontSize: 16, - fontWeight: FontWeight.w500, // Matched with Recent - color: Color(0xFF27272A), ), ), trailing: @@ -551,9 +442,8 @@ class _ShareToMoodboardPageState extends State<ShareToMoodboardPage> { : Icons.keyboard_arrow_down, color: Colors.grey[600], ), - onPressed: () { - setState(() => g.isExpanded = !g.isExpanded); - }, + onPressed: + () => setState(() => g.isExpanded = !g.isExpanded), ) : null, ), @@ -564,7 +454,7 @@ class _ShareToMoodboardPageState extends State<ShareToMoodboardPage> { firstChild: const SizedBox.shrink(), secondChild: Container( width: double.infinity, - color: const Color(0xFFF9FAFB), + color: Variables.surfaceSubtle, child: Column( children: g.events.map((e) { @@ -573,7 +463,7 @@ class _ShareToMoodboardPageState extends State<ShareToMoodboardPage> { contentPadding: const EdgeInsets.symmetric( horizontal: 24, vertical: 2, - ), // Reduced vertical padding + ), visualDensity: VisualDensity.compact, leading: Container( width: 40, @@ -601,11 +491,9 @@ class _ShareToMoodboardPageState extends State<ShareToMoodboardPage> { ), title: Text( e.title, - style: const TextStyle( - fontFamily: 'GeneralSans', + style: Variables.bodyStyle.copyWith( fontSize: 15, - fontWeight: FontWeight.w500, // Matched - color: Color(0xFF27272A), + fontWeight: FontWeight.w500, ), ), ); diff --git a/lib/ui/widgets/file_card.dart b/lib/ui/widgets/file_card.dart @@ -12,7 +12,6 @@ class FileCard extends StatelessWidget { final VoidCallback onTap; // Optional actions for the context menu. - // If null, the menu icon is hidden or disabled. final Function(String)? onMenuAction; const FileCard({ @@ -28,9 +27,6 @@ class FileCard extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final isDark = theme.brightness == Brightness.dark; - // Resolve valid image path final bool hasPreview = previewPath.isNotEmpty && File(previewPath).existsSync(); @@ -40,175 +36,119 @@ class FileCard extends StatelessWidget { return GestureDetector( onTap: onTap, child: Container( + margin: const EdgeInsets.only(bottom: 12), decoration: BoxDecoration( - color: theme.scaffoldBackgroundColor, - borderRadius: BorderRadius.circular(Variables.radiusSmall), - // Optional: Add subtle border if needed to match FilePage style - border: Border.all( - color: isDark ? Variables.borderDark : Colors.transparent, - ), + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFE4E4E7)), ), child: Row( - crossAxisAlignment: CrossAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, children: [ // Thumbnail - SizedBox( - width: 88, - height: 88, - child: Center( - child: Container( - width: 80, - height: 80, - decoration: BoxDecoration( - color: - isDark - ? Variables.surfaceDark - : Variables.surfaceSubtle, - borderRadius: BorderRadius.circular(Variables.radiusSmall), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.12), - blurRadius: 6, - offset: const Offset(0, 3), - ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(Variables.radiusSmall), - child: - hasPreview - ? Image( - image: imageProvider!, - fit: BoxFit.cover, - errorBuilder: - (_, __, ___) => _buildPlaceholder(theme), - ) - : _buildPlaceholder(theme), - ), - ), + ClipRRect( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + bottomLeft: Radius.circular(16), + ), + child: SizedBox( + width: 120, + height: 120, + child: + hasPreview + ? Image( + image: imageProvider!, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => _buildPlaceholder(), + ) + : _buildPlaceholder(), ), ), - const SizedBox(width: 16), + + const SizedBox(width: 12), + // Info Column Expanded( child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.symmetric(vertical: 14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (breadcrumb.isNotEmpty) - Text( - breadcrumb, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w500, - fontFamily: 'GeneralSans', - color: theme.colorScheme.onSurface - .withValues(alpha: 0.6), - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 4), - Text( - file.name, - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, - fontFamily: 'GeneralSans', - color: theme.colorScheme.onSurface, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), + if (breadcrumb.isNotEmpty) + Text( + breadcrumb, + style: const TextStyle( + fontSize: 11, + color: Color(0xFF71717B), + fontFamily: 'GeneralSans', ), - const SizedBox(width: 8), - // Menu - if (onMenuAction != null) - PopupMenuButton<String>( - padding: EdgeInsets.zero, - icon: Icon( - Icons.more_vert, - size: 20, - color: theme.colorScheme.onSurface.withValues( - alpha: 0.6, - ), - ), - onSelected: onMenuAction, - itemBuilder: - (_) => const [ - PopupMenuItem( - value: "open", - child: Text("Open"), - ), - PopupMenuItem( - value: "rename", - child: Text("Rename"), - ), - PopupMenuItem( - value: "delete", - child: Text("Delete"), - ), - ], - ) - else - Icon( - Icons.more_vert, - size: 20, - color: theme.colorScheme.onSurface.withValues( - alpha: 0.6, - ), - ), - ], + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + file.name, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + fontFamily: 'GeneralSans', + color: Color(0xFF27272A), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), const SizedBox(height: 6), Text( dimensions, - style: TextStyle( - fontSize: 11, + style: const TextStyle( + fontSize: 13, + color: Color(0xFF71717B), fontFamily: 'GeneralSans', - color: theme.colorScheme.onSurface.withValues( - alpha: 0.6, - ), ), ), - const SizedBox(height: 4), + const SizedBox(height: 6), Text( timeAgo, style: TextStyle( - fontSize: 11, + fontSize: 12, + color: const Color(0xFF71717B).withValues(alpha: 0.8), fontFamily: 'GeneralSans', - color: theme.colorScheme.onSurface.withValues( - alpha: 0.4, - ), ), ), ], ), ), ), + + // Menu + if (onMenuAction != null) + PopupMenuButton<String>( + onSelected: onMenuAction, + itemBuilder: + (_) => 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, + color: Color(0xFF71717B), + ), + ) + else + const SizedBox(width: 40), ], ), ), ); } - Widget _buildPlaceholder(ThemeData theme) { - return Center( - child: Icon( - Icons.image, - size: 24, - color: theme.colorScheme.onSurface.withValues(alpha: 0.3), + Widget _buildPlaceholder() { + return Container( + color: Colors.grey[300], + child: const Center( + child: Icon(Icons.image, size: 32, color: Colors.white), ), ); } diff --git a/lib/ui/widgets/moodboard_image_card.dart b/lib/ui/widgets/moodboard_image_card.dart @@ -0,0 +1,104 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:creekui/data/models/image_model.dart'; +import 'package:creekui/ui/styles/variables.dart'; +import 'package:creekui/ui/widgets/image_context_menu.dart'; + +class MoodboardImageCard extends StatelessWidget { + final ImageModel image; + final VoidCallback onTap; + final VoidCallback onDeleted; + final bool showTags; + final double? height; + + const MoodboardImageCard({ + super.key, + required this.image, + required this.onTap, + required this.onDeleted, + this.showTags = false, + this.height, + }); + + @override + Widget build(BuildContext context) { + return ImageContextMenu( + image: image, + onImageDeleted: onDeleted, + child: GestureDetector( + onTap: onTap, + child: Container( + height: height, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(Variables.radiusMedium), + color: Variables.surfaceSubtle, + border: Border.all(color: Variables.borderSubtle), + ), + clipBehavior: Clip.antiAlias, + child: Stack( + fit: StackFit.expand, + children: [ + Image.file( + File(image.filePath), + fit: BoxFit.cover, + width: double.infinity, + errorBuilder: + (_, __, ___) => const Center( + child: Icon( + Icons.broken_image, + color: Variables.textDisabled, + ), + ), + ), + if (showTags && image.tags.isNotEmpty) + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + Colors.black.withOpacity(0.8), + Colors.transparent, + ], + ), + ), + child: Wrap( + spacing: 4, + runSpacing: 4, + children: + image.tags.take(3).map((tag) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + tag.toUpperCase(), + style: const TextStyle( + color: Colors.white, + fontSize: 9, + fontFamily: 'GeneralSans', + fontWeight: FontWeight.w600, + ), + ), + ); + }).toList(), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/ui/widgets/note_input_sheet.dart b/lib/ui/widgets/note_input_sheet.dart @@ -0,0 +1,209 @@ +import 'package:flutter/material.dart'; +import 'package:creekui/ui/styles/variables.dart'; + +class NoteInputSheet extends StatefulWidget { + final List<String> categories; + final String initialCategory; + final Function(String content, String category) onSubmit; + + const NoteInputSheet({ + super.key, + required this.categories, + required this.initialCategory, + required this.onSubmit, + }); + + @override + State<NoteInputSheet> createState() => _NoteInputSheetState(); +} + +class _NoteInputSheetState extends State<NoteInputSheet> { + late String _selectedCategory; + final TextEditingController _controller = TextEditingController(); + + @override + void initState() { + super.initState(); + _selectedCategory = widget.initialCategory; + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Header Row + Row( + children: [ + Container( + width: 30, + height: 30, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: const Color(0xFFFAFAFA), + width: 1.25, + ), + ), + child: const CircleAvatar( + radius: 15, + backgroundColor: Colors.grey, + child: Icon(Icons.person, color: Colors.white, size: 18), + ), + ), + const SizedBox(width: 10), + const Text( + "Alex", // TODO + style: TextStyle( + fontFamily: 'GeneralSans', + fontSize: 12, + fontWeight: FontWeight.w500, + color: Colors.black, + ), + ), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: const Color(0xFFE0E7FF), + borderRadius: BorderRadius.circular(1000), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton<String>( + value: + widget.categories.contains(_selectedCategory) + ? _selectedCategory + : null, + hint: const Text( + "Type", + style: TextStyle(fontFamily: 'GeneralSans', fontSize: 12), + ), + isDense: true, + icon: const Icon( + Icons.arrow_drop_down, + size: 20, + color: Color(0xFF27272A), + ), + style: const TextStyle( + fontFamily: 'GeneralSans', + fontSize: 12, + color: Color(0xFF27272A), + ), + dropdownColor: Colors.white, + items: + widget.categories + .map( + (c) => DropdownMenuItem(value: c, child: Text(c)), + ) + .toList(), + onChanged: (v) { + if (v != null) setState(() => _selectedCategory = v); + }, + ), + ), + ), + ], + ), + const SizedBox(height: 16), + // Input Row + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Container( + decoration: BoxDecoration( + color: const Color(0xFFF4F4F5), + border: Border.all(color: const Color(0xFFE4E4E7)), + borderRadius: BorderRadius.circular(8), + ), + child: TextField( + controller: _controller, + autofocus: true, + maxLines: null, + style: const TextStyle( + fontFamily: 'GeneralSans', + fontSize: 12, + ), + decoration: const InputDecoration( + hintText: "Enter note details...", + border: InputBorder.none, + contentPadding: EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + ), + ), + ), + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon( + Icons.send, + color: Color(0xFF27272A), + size: 24, + ), + onPressed: () { + if (_controller.text.isNotEmpty) { + widget.onSubmit(_controller.text.trim(), _selectedCategory); + } + }, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 24, minHeight: 24), + ), + ], + ), + const SizedBox(height: 10), + ], + ), + ); + } +} + +class NoteModalOverlay extends StatelessWidget { + final Widget modalContent; + final Size screenSize; + + const NoteModalOverlay({ + super.key, + required this.modalContent, + required this.screenSize, + }); + + @override + Widget build(BuildContext context) { + final mq = MediaQuery.of(context); + return Align( + alignment: Alignment.bottomCenter, + child: AnimatedPadding( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + padding: EdgeInsets.only(bottom: mq.viewInsets.bottom), + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: screenSize.height), + child: Material( + color: Colors.white, + elevation: 10, + shadowColor: Colors.black26, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + clipBehavior: Clip.antiAlias, + child: SingleChildScrollView( + padding: EdgeInsets.only(bottom: mq.padding.bottom), + child: modalContent, + ), + ), + ), + ), + ); + } +} diff --git a/lib/ui/widgets/primary_button.dart b/lib/ui/widgets/primary_button.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:creekui/ui/styles/variables.dart'; + +class PrimaryButton extends StatelessWidget { + final String text; + final VoidCallback? onPressed; + final bool isLoading; + final String? iconPath; + + const PrimaryButton({ + super.key, + required this.text, + this.onPressed, + this.isLoading = false, + this.iconPath, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: isLoading ? null : onPressed, + style: ElevatedButton.styleFrom( + backgroundColor: Variables.textPrimary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(100), + ), + elevation: 0, + ), + child: + isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ) + : Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(text, style: Variables.buttonTextStyle), + if (iconPath != null) ...[ + const SizedBox(width: 12), + SvgPicture.asset( + iconPath!, + width: 18, + height: 18, + colorFilter: const ColorFilter.mode( + Colors.white, + BlendMode.srcIn, + ), + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/ui/widgets/selection_overlay_painter.dart b/lib/ui/widgets/selection_overlay_painter.dart @@ -0,0 +1,115 @@ +import 'dart:ui' as ui; +import 'package:flutter/material.dart'; + +enum DragHandle { none, topLeft, topRight, bottomLeft, bottomRight, center } + +extension RectUtils on Rect { + Rect normalize() { + return Rect.fromLTRB( + left < right ? left : right, + top < bottom ? top : bottom, + left > right ? left : right, + top > bottom ? top : bottom, + ); + } +} + +class SelectionOverlayPainter extends CustomPainter { + final Rect rect; + final bool isResizing; + final DragHandle activeHandle; + + SelectionOverlayPainter({ + required this.rect, + required this.isResizing, + this.activeHandle = DragHandle.none, + }); + + @override + void paint(Canvas canvas, Size size) { + // 1. Dim Background + if (isResizing) { + final Path backgroundPath = + Path()..addRect(Rect.fromLTWH(0, 0, size.width, size.height)); + final Path holePath = Path()..addRect(rect); + final Path overlayPath = Path.combine( + ui.PathOperation.difference, + backgroundPath, + holePath, + ); + canvas.drawPath(overlayPath, Paint()..color = Colors.black54); + } + + // 2. Draw Dashed Border + final Paint borderPaint = + Paint() + ..color = const Color(0xFF448AFF) + ..strokeWidth = 2.0 + ..style = PaintingStyle.stroke; + + double dashWidth = 6; + double dashSpace = 4; + Path borderPath = Path()..addRect(rect); + + for (ui.PathMetric pathMetric in borderPath.computeMetrics()) { + double distance = 0.0; + while (distance < pathMetric.length) { + canvas.drawPath( + pathMetric.extractPath(distance, distance + dashWidth), + borderPaint, + ); + distance += (dashWidth + dashSpace); + } + } + + // 3. Draw Center Dot (Initial) or Resize Handles (Resizing) + if (!isResizing) { + canvas.drawCircle( + rect.center, + 8, + Paint() + ..color = Colors.black26 + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 3), + ); + canvas.drawCircle( + rect.center, + 6, + Paint() + ..color = Colors.white + ..style = PaintingStyle.fill, + ); + } else { + final List<Offset> corners = [ + rect.topLeft, + rect.topRight, + rect.bottomLeft, + rect.bottomRight, + ]; + + final Paint handleShadow = + Paint() + ..color = Colors.black.withOpacity(0.3) + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 2); + final Paint handleFill = Paint()..color = Colors.white; + final Paint handleBorder = + Paint() + ..color = const Color(0xFF448AFF) + ..strokeWidth = 2 + ..style = PaintingStyle.stroke; + + const double handleRadius = 8; + + for (final corner in corners) { + canvas.drawCircle(corner, handleRadius, handleShadow); + canvas.drawCircle(corner, handleRadius, handleFill); + canvas.drawCircle(corner, handleRadius, handleBorder); + } + } + } + + @override + bool shouldRepaint(covariant SelectionOverlayPainter oldDelegate) => + rect != oldDelegate.rect || + isResizing != oldDelegate.isResizing || + activeHandle != oldDelegate.activeHandle; +} diff --git a/lib/ui/widgets/text_field.dart b/lib/ui/widgets/text_field.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:creekui/ui/styles/variables.dart'; + +class CommonTextField extends StatelessWidget { + final String label; + final String hintText; + final TextEditingController controller; + final int maxLines; + final bool isRequired; + final ValueChanged<String>? onSubmitted; + + const CommonTextField({ + super.key, + required this.label, + required this.hintText, + required this.controller, + this.maxLines = 1, + this.isRequired = false, + this.onSubmitted, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + label, + style: Variables.bodyStyle.copyWith(fontWeight: FontWeight.w500), + ), + if (isRequired) + Text( + '*', + style: Variables.bodyStyle.copyWith( + color: const Color(0xFF4F39F6), + fontSize: 12, + ), + ), + ], + ), + const SizedBox(height: 6), + Container( + decoration: BoxDecoration( + color: Variables.borderSubtle, + borderRadius: BorderRadius.circular(Variables.radiusSmall), + ), + child: TextField( + controller: controller, + maxLines: maxLines, + onSubmitted: onSubmitted, + style: Variables.bodyStyle, + decoration: InputDecoration( + hintText: hintText, + hintStyle: Variables.bodyStyle.copyWith( + color: Variables.textSecondary, + ), + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + ), + ), + ], + ); + } +}