creek

The AI Image Editor of 2030
commit b60aa5f4900976cc2152df00bdfc8c44db3e544f
parent d175b99e260d085e66cab1a492ad5e43aaa9c8eb
Author: maydayv7 <maydayv7@gmail.com>
Date:   Mon,  1 Dec 2025 16:30:37 +0530

Merge remote-tracking branch 'origin/sanjeebani'

Diffstat:
Mlib/data/models/project_model.dart | 2++
Mlib/services/flask_service.dart | 8++------
Mlib/services/project_service.dart | 16++++++++++++++++
Alib/services/stylesheet_service.dart | 218+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mlib/ui/pages/canvas_board_page.dart | 60++++++++++++++++++++++++++++++++++++++----------------------
Mlib/ui/pages/canvas_toolbar/magic_draw_overlay.dart | 721++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Mlib/ui/pages/create_file_page.dart | 641++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mlib/ui/pages/define_brand_page.dart | 34+++++++++++-----------------------
Mlib/ui/pages/project_file_page.dart | 20++++++++++++++++++--
9 files changed, 1350 insertions(+), 370 deletions(-)

diff --git a/lib/data/models/project_model.dart b/lib/data/models/project_model.dart @@ -61,4 +61,6 @@ class ProjectModel { : [], ); } + + } diff --git a/lib/services/flask_service.dart b/lib/services/flask_service.dart @@ -13,12 +13,8 @@ class FlaskService { // CONFIGURATION // =========================================================================== - // NOTE: REPLACE WITH YOUR WIFI IP ADDRESS - // BOTH PC AND MOBILE SHOULD BE ON SAME WIFI - // NO NEED FOR NGORK OR SMEE - // PORT: 5000, http - static const String _serverUrl = - 'http://172.16.114.193:5000'; // --> READ NOTE (REPLACE WITH IITG_CONNECT WIFI IP) + // NOTE: REPLACE WITH IP ADDRESS OF LOCAL SERVER + static const String _serverUrl = 'http://10.150.40.117:5000'; static const Map<String, String> _headers = { 'Content-Type': 'application/json', }; diff --git a/lib/services/project_service.dart b/lib/services/project_service.dart @@ -76,4 +76,20 @@ class ProjectService { await _projectRepo.deleteProject(projectId); } + + Future<List<ProjectModel>> getRecentProjectsAndEvents() async { + return await _projectRepo.getRecentProjectsAndEvents(); + } + + Future<List<ProjectModel>> getAllProjects() async { + return await _projectRepo.getAllProjects(); + } + + Future<List<ProjectModel>> getEvents(int projectId) async { + return await _projectRepo.getEvents(projectId); + } + + Future<ProjectModel?> getProjectById(int id) async { + return await _projectRepo.getProjectById(id); + } } diff --git a/lib/services/stylesheet_service.dart b/lib/services/stylesheet_service.dart @@ -0,0 +1,218 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +/// A simple model to hold the extracted design tokens +class StylesheetData { + final List<Color> colors; + final List<String> fonts; + + StylesheetData({required this.colors, required this.fonts}); +} + +class StylesheetService { + /// Main entry point: Parses a raw (potentially dirty) JSON string + /// and returns a structured [StylesheetData] object. + StylesheetData parse(String? rawJson) { + if (rawJson == null || rawJson.isEmpty) { + return StylesheetData(colors: [], fonts: []); + } + + // 1. Parse the string into a Map + Map<String, dynamic> data = _parseRawJson(rawJson); + + // 2. Extract and Process Fonts + List<String> fonts = _extractFonts(data); + + // 3. Extract and Process Colors + List<Color> colors = _extractColors(data); + + return StylesheetData(colors: colors, fonts: fonts); + } + + // --------------------------------------------------------------------------- + // PARSING LOGIC + // --------------------------------------------------------------------------- + + /// Safely parses the raw string into a Map, handling dirty AI output. + Map<String, dynamic> _parseRawJson(String rawString) { + try { + // Try standard decode first + return _normalizeResult(jsonDecode(rawString)); + } catch (e) { + try { + // Try cleaning regex then decoding + final cleaned = _cleanJsonString(rawString); + return _normalizeResult(jsonDecode(cleaned)); + } catch (_) { + return {}; + } + } + } + + /// Cleans "dirty" JSON strings by fixing quotes and unquoted keys. + String _cleanJsonString(String raw) { + String cleaned = raw; + // Add quotes to keys + cleaned = cleaned.replaceAllMapped( + RegExp(r'([{,]\s*)([a-zA-Z0-9_\s/]+)(\s*:)'), + (match) => '${match[1]}"${match[2]?.trim()}"${match[3]}', + ); + // Add quotes to string values that aren't booleans or numbers + cleaned = cleaned.replaceAllMapped( + RegExp(r'(:\s*)([a-zA-Z0-9_\-\.\/\s]+)(?=\s*[,}])'), + (match) { + String val = match[2]!.trim(); + if (val == 'true' || + val == 'false' || + val == 'null' || + double.tryParse(val) != null) { + return match[0]!; + } + return '${match[1]}"$val"'; + }, + ); + return cleaned; + } + + /// Normalizes the structure if the API returns { "results": ... } + Map<String, dynamic> _normalizeResult(dynamic parsed) { + if (parsed is String) { + try { + parsed = jsonDecode(parsed); + } catch (_) {} + } + + if (parsed is Map<String, dynamic>) { + if (parsed.containsKey('results') && parsed['results'] is Map) { + return parsed['results']; + } + return parsed; + } + return {}; + } + + // --------------------------------------------------------------------------- + // EXTRACTION LOGIC + // --------------------------------------------------------------------------- + + /// Extracts font names and resolves them to valid Google Font strings. + List<String> _extractFonts(Map<String, dynamic> data) { + dynamic fontData = _findValue(data, ['Typography', 'fonts', 'typography']); + if (fontData == null) return []; + + List<String> rawNames = []; + if (fontData is List) { + for (var item in fontData) { + if (item is Map && item.containsKey('label')) { + rawNames.add(item['label'].toString().trim()); + } else if (item is String) { + rawNames.add(item.trim()); + } + } + } else if (fontData is Map && fontData.containsKey('label')) { + rawNames.add(fontData['label'].toString().trim()); + } else if (fontData is String) { + rawNames.add(fontData.trim()); + } + + return rawNames.map((name) => _resolveGoogleFontName(name)).toList(); + } + + /// Extracts colors from Hex codes or semantic labels. + List<Color> _extractColors(Map<String, dynamic> data) { + dynamic colorData = _findValue(data, [ + 'Colour Palette', + 'Color Palette', + 'colors', + ]); + if (colorData == null) return []; + + List<Color> resolvedColors = []; + + if (colorData is List) { + for (var item in colorData) { + String? label; + if (item is Map) { + label = item['label']?.toString(); + } else if (item is String) { + label = item; + } + + if (label != null) { + resolvedColors.add(_parseColor(label)); + } + } + } + return resolvedColors; + } + + // --------------------------------------------------------------------------- + // HELPERS + // --------------------------------------------------------------------------- + + dynamic _findValue(Map<String, dynamic> map, List<String> keys) { + for (var k in keys) { + if (map.containsKey(k)) return map[k]; + // Case-insensitive check + for (var mapKey in map.keys) { + if (mapKey.toLowerCase() == k.toLowerCase()) return map[mapKey]; + } + } + return null; + } + + String _resolveGoogleFontName(String dirtyName) { + // 1. Exact match check (fastest) + try { + GoogleFonts.getFont(dirtyName); + return dirtyName; + } catch (_) {} + + // 2. Fuzzy match + String cleanInput = dirtyName + .toLowerCase() + .replaceAll(RegExp(r'[-_]regular$'), '') + .replaceAll(RegExp(r'[^a-z0-9]'), ''); + + final allFonts = GoogleFonts.asMap().keys; + for (String officialName in allFonts) { + String cleanOfficial = officialName.toLowerCase().replaceAll( + RegExp(r'[^a-z0-9]'), + '', + ); + if (cleanOfficial == cleanInput) { + return officialName; + } + } + return dirtyName; // Fallback + } + + Color _parseColor(String input) { + // 1. Try parsing Hex (e.g. "#FF0000" or "FF0000") + if (input.startsWith('#') || input.length == 6) { + try { + String hex = input.replaceAll('#', ''); + if (hex.length == 6) { + return Color(int.parse('0xFF$hex')); + } + } catch (_) {} + } + + // 2. Fallback to Semantic Labels + String label = input.toLowerCase(); + if (label.contains('neon')) return const Color(0xFF39FF14); + if (label.contains('earth')) return const Color(0xFF8D6E63); + if (label.contains('pastel')) return const Color(0xFFFFB7B2); + if (label.contains('neutral')) return const Color(0xFFE0E0E0); + if (label.contains('vintage')) return const Color(0xFFD2B48C); + if (label.contains('modern')) return const Color(0xFF212121); + if (label.contains('warm')) return const Color(0xFFFF9800); + if (label.contains('cool')) return const Color(0xFF00BCD4); + if (label.contains('dark')) return const Color(0xFF1a1a1a); + if (label.contains('blue')) return Colors.blue; + if (label.contains('red')) return Colors.red; + + return Colors.grey.shade400; // Default fallback + } +} diff --git a/lib/ui/pages/canvas_board_page.dart b/lib/ui/pages/canvas_board_page.dart @@ -7,6 +7,8 @@ import 'package:image_picker/image_picker.dart'; import 'package:undo/undo.dart'; import './canvas_toolbar/magic_draw_overlay.dart'; import './canvas_toolbar/text_tools_overlay.dart'; +import '../../data/repos/project_repo.dart'; +import '../../services/stylesheet_service.dart'; // --- MODELS --- class DrawingPoint { @@ -65,6 +67,7 @@ class _CanvasBoardPageState extends State<CanvasBoardPage> { String? selectedId; late Size _canvasSize; + List<Color> _brandColors = []; // --- TOOLS --- bool _isMagicDrawActive = false; @@ -92,6 +95,8 @@ class _CanvasBoardPageState extends State<CanvasBoardPage> { super.initState(); _canvasSize = Size(widget.width, widget.height); + _fetchBrandColors(); + if (widget.initialImage != null) { elements.add({ 'id': 'bg_${DateTime.now().millisecondsSinceEpoch}', @@ -112,6 +117,25 @@ class _CanvasBoardPageState extends State<CanvasBoardPage> { super.dispose(); } + Future<void> _fetchBrandColors() async { + try { + final int? pId = int.tryParse(widget.projectId); + if (pId == null) return; + + final project = await ProjectRepo().getProjectById(pId); + if (project != null && project.globalStylesheet != null) { + final styleData = StylesheetService().parse(project.globalStylesheet); + if (mounted && styleData.colors.isNotEmpty) { + setState(() { + _brandColors = styleData.colors; + }); + } + } + } catch (e) { + debugPrint("Error loading brand colors: $e"); + } + } + // --- UNDO/REDO --- // Robust undo function: call this AFTER a change is made, passing the OLD state @@ -354,7 +378,7 @@ class _CanvasBoardPageState extends State<CanvasBoardPage> { color: Colors.white, boxShadow: [ BoxShadow( - color: Colors.black.withValues(alpha: 0.15), + color: Colors.black.withOpacity(0.15), blurRadius: 40, offset: const Offset(0, 10), ), @@ -423,7 +447,6 @@ class _CanvasBoardPageState extends State<CanvasBoardPage> { textController: isSelected ? _textEditingController : null, focusNode: isSelected ? _textFocusNode : null, - // Fix: pass the controller to listen for zooms transformationController: _transformationController, ); }), @@ -435,7 +458,6 @@ class _CanvasBoardPageState extends State<CanvasBoardPage> { key: _drawingKey, child: GestureDetector( onPanStart: (details) { - // Save state before drawing stroke _gestureStartSnapshot = _getCurrentState(); }, onPanUpdate: _onPanUpdate, @@ -473,6 +495,8 @@ class _CanvasBoardPageState extends State<CanvasBoardPage> { selectedColor: _selectedColor, strokeWidth: _strokeWidth, isEraser: _isEraser, + brandColors: _brandColors, + onClose: _saveAndCloseMagicDraw, onColorChanged: (c) => setState(() => _selectedColor = c), onWidthChanged: (w) => setState(() => _strokeWidth = w), onEraserToggle: (e) => setState(() => _isEraser = e), @@ -736,7 +760,7 @@ class _ManipulatingBox extends StatefulWidget { required this.isSelected, required this.isEditing, required this.viewScale, - required this.transformationController, // Receive controller + required this.transformationController, required this.onTap, required this.onDoubleTap, required this.onDragStart, @@ -779,12 +803,10 @@ class _ManipulatingBoxState extends State<_ManipulatingBox> { @override Widget build(BuildContext context) { - // Listen to the transformation controller to get real-time zoom updates return ValueListenableBuilder( valueListenable: widget.transformationController, builder: (context, matrix, child) { final double currentZoom = matrix.getMaxScaleOnAxis(); - // Calculate inverse scale to keep handles visually constant final double handleScale = (1.0 / currentZoom).clamp(0.1, 5.0); final double touchTargetSize = 40.0 * handleScale; final double visualSize = 24.0 * handleScale; @@ -804,8 +826,8 @@ class _ManipulatingBoxState extends State<_ManipulatingBox> { onPanStart: (_) => widget.onDragStart(), onPanUpdate: (details) { if (widget.isSelected && !widget.isEditing) { - // Use real-time zoom to normalize drag delta - final delta = details.delta / currentZoom; + // FIX: Do not divide by currentZoom + final delta = details.delta; final globalDelta = _rotateVector(delta, _rot); setState(() => _pos += globalDelta); widget.onUpdate(_pos, _size, _rot); @@ -824,7 +846,7 @@ class _ManipulatingBoxState extends State<_ManipulatingBox> { ) : widget.type == 'text' ? Border.all( - color: Colors.grey.withValues(alpha: 0.3), + color: Colors.grey.withOpacity(0.3), width: 1.0 * handleScale, ) : null, @@ -849,21 +871,15 @@ class _ManipulatingBoxState extends State<_ManipulatingBox> { icon: Icons.zoom_out_map, color: Colors.blue, onDrag: (delta) { - final normalizedDelta = delta / currentZoom; - final localDelta = _rotateVector( - normalizedDelta, - -_rot, - ); + // FIX: Use delta directly, no un-rotation setState(() { _size = Size( - (_size.width + localDelta.dx).clamp(50.0, 10000.0), - (_size.height + localDelta.dy).clamp(30.0, 10000.0), - ); - final offset = Offset( - localDelta.dx / 2, - localDelta.dy / 2, + (_size.width + delta.dx).clamp(50.0, 10000.0), + (_size.height + delta.dy).clamp(30.0, 10000.0), ); - _pos += _rotateVector(offset, _rot); + // Fix anchor point calculation + final offset = Offset(delta.dx / 2, delta.dy / 2); + _pos += _rotateVector(offset, _rot) - offset; }); widget.onUpdate(_pos, _size, _rot); }, @@ -1073,7 +1089,7 @@ class CanvasBottomBar extends StatelessWidget { border: Border(top: BorderSide(color: Colors.grey[200]!)), boxShadow: [ BoxShadow( - color: Colors.black.withValues(alpha: 0.05), + color: Colors.black.withOpacity(0.05), blurRadius: 10, offset: const Offset(0, -5), ), diff --git a/lib/ui/pages/canvas_toolbar/magic_draw_overlay.dart b/lib/ui/pages/canvas_toolbar/magic_draw_overlay.dart @@ -9,6 +9,8 @@ class MagicDrawTools extends StatefulWidget { final Function(Color) onColorChanged; final Function(double) onWidthChanged; final Function(bool) onEraserToggle; + final VoidCallback onClose; // Added onClose callback signature to match usage + final List<Color> brandColors; const MagicDrawTools({ super.key, @@ -19,6 +21,8 @@ class MagicDrawTools extends StatefulWidget { required this.onColorChanged, required this.onWidthChanged, required this.onEraserToggle, + required this.onClose, + required this.brandColors, }); @override @@ -28,12 +32,21 @@ class MagicDrawTools extends StatefulWidget { class _MagicDrawToolsState extends State<MagicDrawTools> { bool _showStrokeSlider = false; + // Initialize recent colors here so they persist + final List<Color> _recentColors = [ + Colors.blue, + Colors.purple, + const Color(0xFFD81B60), + Colors.pinkAccent, + Colors.amber, + ]; + @override Widget build(BuildContext context) { if (!widget.isActive) return const SizedBox.shrink(); - // Only displaying the bottom tool panel now. - // The top header is handled by the main Scaffold AppBar. + // KEEPING YOUR LOGIC: Only displaying the bottom tool panel. + // The top header/close button from the other branch is ignored. return Positioned( bottom: 140, left: 16, @@ -56,7 +69,7 @@ class _MagicDrawToolsState extends State<MagicDrawTools> { borderRadius: BorderRadius.circular(24), boxShadow: [ BoxShadow( - color: Colors.black.withValues(alpha: 0.1), + color: Colors.black.withOpacity(0.1), blurRadius: 15, offset: const Offset(0, 5), ), @@ -134,7 +147,7 @@ class _MagicDrawToolsState extends State<MagicDrawTools> { border: Border.all(color: Colors.white, width: 2), boxShadow: [ BoxShadow( - color: Colors.black.withValues(alpha: 0.1), + color: Colors.black.withOpacity(0.1), blurRadius: 4, ), ], @@ -246,7 +259,9 @@ class _MagicDrawToolsState extends State<MagicDrawTools> { builder: (context) { return _AdvancedColorPickerSheet( initialColor: widget.selectedColor, + recentColors: _recentColors, onColorChanged: widget.onColorChanged, + brandColors: widget.brandColors, ); }, ); @@ -276,10 +291,14 @@ class _TaperedSliderPainter extends CustomPainter { class _AdvancedColorPickerSheet extends StatefulWidget { final Color initialColor; final ValueChanged<Color> onColorChanged; + final List<Color> recentColors; + final List<Color> brandColors; const _AdvancedColorPickerSheet({ required this.initialColor, required this.onColorChanged, + required this.recentColors, + required this.brandColors, }); @override @@ -291,38 +310,50 @@ class _AdvancedColorPickerSheetState extends State<_AdvancedColorPickerSheet> with SingleTickerProviderStateMixin { late TabController _tabController; late Color _currentColor; + late List<Color> _brandPalette; - final List<Color> _brandPalette = [ - Colors.blue, - const Color(0xFFCCFF00), - Colors.purpleAccent, - const Color(0xFFF0F0F0), - Colors.black, - ]; - - final List<Color> _recentColors = [ - Colors.blue, - Colors.purple, - const Color(0xFFD81B60), - Colors.pinkAccent, - Colors.amber, - ]; + // Controllers for RGB Sliders (Integrated from other branch) + final TextEditingController _rController = TextEditingController(); + final TextEditingController _gController = TextEditingController(); + final TextEditingController _bController = TextEditingController(); @override void initState() { super.initState(); _currentColor = widget.initialColor; _tabController = TabController(length: 3, vsync: this); + _brandPalette = widget.brandColors; // Integrate real brand colors + _updateControllers(); + } + + void _updateControllers() { + _rController.text = _currentColor.red.toString(); + _gController.text = _currentColor.green.toString(); + _bController.text = _currentColor.blue.toString(); } @override void dispose() { _tabController.dispose(); + _rController.dispose(); + _gController.dispose(); + _bController.dispose(); super.dispose(); } void _updateColor(Color color) { - setState(() => _currentColor = color); + setState(() { + _currentColor = color; + + // Update recent colors list + widget.recentColors.removeWhere((c) => c.value == color.value); + widget.recentColors.insert(0, color); + if (widget.recentColors.length > 5) { + widget.recentColors.removeLast(); + } + + _updateControllers(); + }); widget.onColorChanged(color); } @@ -332,13 +363,15 @@ class _AdvancedColorPickerSheetState extends State<_AdvancedColorPickerSheet> height: MediaQuery.of(context).size.height * 0.85, decoration: const BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), ), - child: SafeArea( - child: Column( - children: [ - const SizedBox(height: 8), - Container( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // --- HEADER HANDLE --- + const SizedBox(height: 12), + Center( + child: Container( width: 40, height: 4, decoration: BoxDecoration( @@ -346,341 +379,431 @@ class _AdvancedColorPickerSheetState extends State<_AdvancedColorPickerSheet> borderRadius: BorderRadius.circular(2), ), ), - const SizedBox(height: 16), - TabBar( - controller: _tabController, - labelColor: Colors.black, - unselectedLabelColor: Colors.grey, - indicatorColor: Colors.black, - labelStyle: const TextStyle(fontWeight: FontWeight.w600), - tabs: const [ - Tab(text: 'Grid'), - Tab(text: 'Spectrum'), - Tab(text: 'Sliders'), + ), + const SizedBox(height: 16), + + // --- TABS --- + TabBar( + controller: _tabController, + labelColor: Colors.black, + unselectedLabelColor: Colors.grey, + indicatorColor: Colors.black, + indicatorSize: TabBarIndicatorSize.label, + labelStyle: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + ), + tabs: const [ + Tab(text: 'Grid'), + Tab(text: 'Spectrum'), + Tab(text: 'Sliders'), + ], + ), + + const SizedBox(height: 16), + + // --- HEX / HEADER ROW --- + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 6, + ), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: const [ + Text( + "Hex", + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + ), + ), + SizedBox(width: 4), + Icon( + Icons.keyboard_arrow_down, + size: 16, + color: Colors.grey, + ), + ], + ), + ), + const SizedBox(width: 8), + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: _currentColor, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: Colors.grey.shade200), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + "#${_currentColor.value.toRadixString(16).toUpperCase().substring(2)}", + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ), + const SizedBox(width: 8), + const Icon(Icons.colorize, size: 20, color: Colors.black54), ], ), - const SizedBox(height: 16), - Expanded( - child: TabBarView( - controller: _tabController, - physics: const NeverScrollableScrollPhysics(), - children: [ - _buildGridTab(), - _buildSpectrumTab(), - _buildSlidersTab(), - ], - ), + ), + + const SizedBox(height: 16), + const Divider(height: 1), + + // --- MAIN CONTENT AREA --- + Expanded( + child: TabBarView( + controller: _tabController, + physics: const NeverScrollableScrollPhysics(), + children: [ + _buildGridTab(), + _buildSpectrumTab(), + _buildSlidersTab(), + ], ), - _buildSharedFooter(), - ], - ), + ), + + // --- FOOTER --- + _buildSharedFooter(), + ], ), ); } + // --- HELPER: Generate colors (Integrated from other branch) --- + List<Color> _generateColorGrid() { + List<Color> colors = []; + + // 1. Top Row: Grayscale (White -> Black) + for (int i = 0; i < 9; i++) { + double lightness = 1.0 - (i / 8); // 1.0 to 0.0 + colors.add(HSLColor.fromAHSL(1.0, 0.0, 0.0, lightness).toColor()); + } + + // 2. Main Grid: Hues (Columns) x Shades (Rows) + final int hueSteps = 9; // Columns + final int shadeSteps = 7; // Rows excluding grayscale + + for (int shade = 0; shade < shadeSteps; shade++) { + for (int hueStep = 0; hueStep < hueSteps; hueStep++) { + double hue = (hueStep / hueSteps) * 360; + double saturation = 0.5 + (shade / shadeSteps) * 0.5; + double lightness = 0.8 - (shade / shadeSteps) * 0.5; + + colors.add( + HSLColor.fromAHSL(1.0, hue, saturation, lightness).toColor(), + ); + } + } + return colors; + } + + // --- TAB 1: GRID --- Widget _buildGridTab() { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Column( - children: [ - Expanded( - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: GridView.builder( - physics: const BouncingScrollPhysics(), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 10, - crossAxisSpacing: 2, - mainAxisSpacing: 2, - ), - itemCount: 100, - itemBuilder: (context, index) { - final double hue = (index % 10) * 36.0; - final double saturation = ((index ~/ 10) + 1) / 10.0; - final color = - HSVColor.fromAHSV(1.0, hue, saturation, 0.9).toColor(); - - return GestureDetector( - onTap: () => _updateColor(color), - child: Container(color: color), - ); - }, + final gridColors = _generateColorGrid(); + + return Column( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: GridView.builder( + physics: const BouncingScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 9, + crossAxisSpacing: 0, + mainAxisSpacing: 0, + childAspectRatio: 1.0, ), + itemCount: gridColors.length, + itemBuilder: (context, index) { + final color = gridColors[index]; + final isSelected = _currentColor.value == color.value; + + return GestureDetector( + onTap: () => _updateColor(color), + child: Stack( + alignment: Alignment.center, + children: [ + Container( + decoration: BoxDecoration( + color: color, + border: Border.all( + color: Colors.black.withOpacity(0.05), + width: 0.5, + ), + ), + ), + if (isSelected) + Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 3), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.2), + blurRadius: 4, + spreadRadius: 1, + ), + ], + ), + ), + ], + ), + ); + }, ), ), - const SizedBox(height: 10), - _buildHueSlider(), - _buildOpacitySlider(), - ], - ), + ), + ], ); } + // --- TAB 2: SPECTRUM --- Widget _buildSpectrumTab() { return Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Column( - children: [ - Expanded( - child: ColorPicker( - pickerColor: _currentColor, - onColorChanged: _updateColor, - enableAlpha: false, - displayThumbColor: true, - paletteType: PaletteType.hsvWithHue, - labelTypes: const [], - pickerAreaHeightPercent: 0.8, - pickerAreaBorderRadius: BorderRadius.circular(12), + padding: const EdgeInsets.all(20), + child: LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxHeight < 150) { + return const Center(child: Text("Rotate device")); + } + return SizedBox( + width: constraints.maxWidth, + height: constraints.maxHeight, + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: ColorPicker( + pickerColor: _currentColor, + onColorChanged: _updateColor, + enableAlpha: false, + labelTypes: const [], + displayThumbColor: true, + paletteType: PaletteType.hsvWithHue, + pickerAreaHeightPercent: 0.8, + hexInputBar: false, + ), ), - ), - ], + ); + }, ), ); } + // --- TAB 3: SLIDERS --- Widget _buildSlidersTab() { return Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Column( - children: [ - const SizedBox(height: 20), - _buildRGBSlider("R", Colors.red, _currentColor.red, (v) { - _updateColor(_currentColor.withRed(v.toInt())); - }), - _buildRGBSlider("G", Colors.green, _currentColor.green, (v) { - _updateColor(_currentColor.withGreen(v.toInt())); - }), - _buildRGBSlider("B", Colors.blue, _currentColor.blue, (v) { - _updateColor(_currentColor.withBlue(v.toInt())); - }), - ], + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24), + child: SingleChildScrollView( + child: Column( + children: [ + _buildSingleRGBSlider("Red", Colors.red, _currentColor.red, (v) { + _updateColor(_currentColor.withRed(v.toInt())); + }), + const SizedBox(height: 24), + _buildSingleRGBSlider( + "Green", + Colors.green, + _currentColor.green, + (v) { + _updateColor(_currentColor.withGreen(v.toInt())); + }, + ), + const SizedBox(height: 24), + _buildSingleRGBSlider("Blue", Colors.blue, _currentColor.blue, (v) { + _updateColor(_currentColor.withBlue(v.toInt())); + }), + ], + ), ), ); } - Widget _buildRGBSlider( + Widget _buildSingleRGBSlider( String label, Color activeColor, int value, ValueChanged<double> onChanged, ) { - return Padding( - padding: const EdgeInsets.only(bottom: 16.0), - child: Row( - children: [ - Expanded( - child: SizedBox( - height: 36, - child: SliderTheme( - data: SliderThemeData( - trackHeight: 36, - activeTrackColor: activeColor, - inactiveTrackColor: activeColor.withValues(alpha: 0.2), - thumbColor: Colors.transparent, - thumbShape: const RoundSliderThumbShape( - enabledThumbRadius: 0, - ), - overlayShape: SliderComponentShape.noOverlay, - trackShape: const RectangularSliderTrackShape(), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(18), - child: Slider( - value: value.toDouble(), - min: 0, - max: 255, - onChanged: onChanged, - ), + return Row( + children: [ + Expanded( + child: SizedBox( + height: 30, + child: SliderTheme( + data: SliderThemeData( + trackHeight: 6, + activeTrackColor: activeColor, + inactiveTrackColor: activeColor.withOpacity(0.15), + thumbColor: Colors.white, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 14, + elevation: 4, ), + overlayShape: const RoundSliderOverlayShape(overlayRadius: 24), + trackShape: const RoundedRectSliderTrackShape(), + ), + child: Slider( + value: value.toDouble(), + min: 0, + max: 255, + onChanged: onChanged, ), ), ), - const SizedBox(width: 12), - Container( - width: 50, - padding: const EdgeInsets.all(8), - alignment: Alignment.center, - decoration: BoxDecoration( - border: Border.all(color: Colors.grey.shade300), - borderRadius: BorderRadius.circular(8), - ), - child: Text( - "$value", - style: const TextStyle(fontWeight: FontWeight.bold), - ), - ), - ], - ), - ); - } - - Widget _buildHueSlider() { - return SizedBox( - height: 15, - child: SliderTheme( - data: SliderThemeData( - trackHeight: 8, - trackShape: const RectangularSliderTrackShape(), - thumbShape: const RoundSliderThumbShape( - enabledThumbRadius: 10, - elevation: 2, - ), - thumbColor: Colors.white, - overlayShape: SliderComponentShape.noOverlay, - ), - child: ColorPicker( - pickerColor: _currentColor, - onColorChanged: _updateColor, - enableAlpha: false, - displayThumbColor: true, - paletteType: PaletteType.hsv, - labelTypes: const [], - pickerAreaHeightPercent: 0.0, ), - ), - ); - } - - Widget _buildOpacitySlider() { - return Padding( - padding: const EdgeInsets.only(top: 8.0), - child: SizedBox( - height: 15, - child: SliderTheme( - data: SliderThemeData( - trackHeight: 8, - thumbShape: const RoundSliderThumbShape( - enabledThumbRadius: 10, - elevation: 2, - ), - thumbColor: Colors.white, - overlayShape: SliderComponentShape.noOverlay, + const SizedBox(width: 12), + Container( + width: 50, + height: 36, + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(8), + color: Colors.white, ), - child: Slider( - value: 1.0, - onChanged: (v) {}, - activeColor: Colors.blue, - inactiveColor: Colors.grey[300], + alignment: Alignment.center, + child: Text( + value.toString(), + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14), ), ), - ), + ], ); } + // --- FOOTER --- Widget _buildSharedFooter() { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - "Recently used", - style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - Row( - children: _recentColors.map((c) => _buildColorCircle(c)).toList(), - ), - const SizedBox(height: 16), - - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text( - "Brand Palette", - style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold), - ), - Text( - "Edit", - style: TextStyle( - fontSize: 12, - color: Colors.blue[700], - fontWeight: FontWeight.bold, - ), + return SafeArea( + top: false, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + decoration: BoxDecoration( + color: Colors.white, + border: Border(top: BorderSide(color: Colors.grey.shade100)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "Recently used", + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.grey, ), - ], - ), - const SizedBox(height: 8), - Row( - children: [ - Container( - width: 32, - height: 32, - margin: const EdgeInsets.only(right: 8), - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all(color: Colors.grey.shade300), - ), - child: Icon(Icons.block, size: 16, color: Colors.red[300]), + ), + const SizedBox(height: 10), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: + widget.recentColors + .map((c) => _buildColorCircle(c)) + .toList(), ), - ..._brandPalette.map((c) => _buildColorCircle(c)), - Container( - width: 32, - height: 32, - decoration: BoxDecoration( - color: Colors.grey[200], - shape: BoxShape.circle, + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + "Brand Palette", + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.grey, + ), ), - child: const Icon(Icons.add, size: 18, color: Colors.black54), - ), - ], - ), - const SizedBox(height: 16), - - const Text( - "Gradients", - style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - Row( - children: [ - _buildGradientCircle(Colors.black, Colors.white), - _buildGradientCircle(Colors.grey, Colors.black), - _buildGradientCircle(Colors.white, Colors.grey), - _buildGradientCircle(Colors.black, Colors.grey), - const SizedBox(width: 8), - Container( - width: 32, - height: 32, - decoration: BoxDecoration( - color: Colors.grey[100], - shape: BoxShape.circle, + Text( + "Edit", + style: TextStyle( + fontSize: 12, + color: Colors.blue[700], + fontWeight: FontWeight.bold, + ), ), - child: const Icon(Icons.tune, size: 16, color: Colors.black54), + ], + ), + const SizedBox(height: 10), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _buildAddButton(), + const SizedBox(width: 12), + ..._brandPalette.map((c) => _buildColorCircle(c)).toList(), + ], ), - ], - ), - ], + ), + // EXCLUDED: Gradient feature as requested. + ], + ), ), ); } Widget _buildColorCircle(Color color) { - return Container( - width: 32, - height: 32, - margin: const EdgeInsets.only(right: 12), - decoration: BoxDecoration( - color: color, - shape: BoxShape.circle, - border: Border.all(color: Colors.grey.withValues(alpha: 0.2)), + return GestureDetector( + onTap: () => _updateColor(color), + child: Container( + width: 36, + height: 36, + margin: const EdgeInsets.only(right: 12), + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all(color: Colors.grey.shade200, width: 1), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), ), ); } - Widget _buildGradientCircle(Color c1, Color c2) { + Widget _buildAddButton() { return Container( - width: 32, - height: 32, - margin: const EdgeInsets.only(right: 12), + width: 36, + height: 36, decoration: BoxDecoration( + color: Colors.grey[100], shape: BoxShape.circle, - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [c1, c2], - ), + border: Border.all(color: Colors.grey.shade300), ), + child: const Icon(Icons.add, size: 20, color: Colors.black54), ); } } diff --git a/lib/ui/pages/create_file_page.dart b/lib/ui/pages/create_file_page.dart @@ -2,11 +2,20 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'canvas_board_page.dart'; +import 'define_brand_page.dart'; + +// Services +import '../../services/project_service.dart'; +import '../../services/image_service.dart'; + +// Models +import '../../data/models/project_model.dart'; class CreateFilePage extends StatefulWidget { final File? file; // Made optional for blank canvas creation + final int? projectId; // Optional: If null, user can select a project - const CreateFilePage({super.key, this.file}); + const CreateFilePage({super.key, this.file, this.projectId}); @override State<CreateFilePage> createState() => _CreateFilePageState(); @@ -15,6 +24,10 @@ class CreateFilePage extends StatefulWidget { class _CreateFilePageState extends State<CreateFilePage> { final TextEditingController _searchController = TextEditingController(); + // Project Selection State + int? _selectedProjectId; + String _selectedProjectTitle = "Select Project"; + // Master list of presets final List<CanvasPreset> _allPresets = [ CanvasPreset( @@ -82,6 +95,12 @@ class _CreateFilePageState extends State<CreateFilePage> { void initState() { super.initState(); _filteredPresets = _allPresets; + + // Initialize from passed project ID + _selectedProjectId = widget.projectId; + if (_selectedProjectId != null) { + _selectedProjectTitle = "Current Project"; + } } @override @@ -109,6 +128,39 @@ class _CreateFilePageState extends State<CreateFilePage> { }); } + void _openProjectSelection() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: + (context) => DraggableScrollableSheet( + initialChildSize: 0.85, + minChildSize: 0.5, + maxChildSize: 0.95, + builder: + (_, controller) => Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical( + top: Radius.circular(20), + ), + ), + child: ProjectSelectionModal( + scrollController: controller, + onProjectSelected: (id, title) { + setState(() { + _selectedProjectId = id; + _selectedProjectTitle = title; + }); + Navigator.pop(context); + }, + ), + ), + ), + ); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -129,10 +181,41 @@ class _CreateFilePageState extends State<CreateFilePage> { ), ), actions: [ - IconButton( - icon: const Icon(Icons.add, color: Colors.black), - onPressed: () {}, - ), + // ONLY show selection button if projectId was NOT passed in + if (widget.projectId == null) + Padding( + padding: const EdgeInsets.only(right: 16.0), + child: TextButton.icon( + onPressed: _openProjectSelection, + style: TextButton.styleFrom( + backgroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + ), + icon: Icon( + _selectedProjectId == null + ? Icons.create_new_folder_outlined + : Icons.folder_open, + size: 18, + color: Colors.black, + ), + label: Text( + _selectedProjectTitle, + style: const TextStyle( + color: Colors.black, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), ], ), body: Column( @@ -150,7 +233,7 @@ class _CreateFilePageState extends State<CreateFilePage> { controller: _searchController, onChanged: _runFilter, decoration: InputDecoration( - hintText: 'Search', + hintText: 'Search sizes', hintStyle: TextStyle(color: Colors.grey[400], fontSize: 14), prefixIcon: Icon( Icons.search, @@ -260,7 +343,7 @@ class _CreateFilePageState extends State<CreateFilePage> { return InkWell( onTap: () { if (isCustom) { - _navigateToEditor(1000, 1000); // Default custom size or show dialog + _navigateToEditor(1000, 1000); } else { _navigateToEditor(preset.width, preset.height); } @@ -276,20 +359,17 @@ class _CreateFilePageState extends State<CreateFilePage> { mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Top Section (Blue Box + White Box + Icon) Expanded( child: Container( width: double.infinity, decoration: BoxDecoration( - color: const Color(0xFFE0E7FF), // Blue BG + color: const Color(0xFFE0E7FF), borderRadius: BorderRadius.circular(8), ), child: Center( child: isCustom - // Custom uses hardcoded Icon ? const Icon(Icons.add, size: 30, color: Colors.blue) - // Others use AspectRatio box + SVG : Padding( padding: const EdgeInsets.all(12.0), child: AspectRatio( @@ -329,10 +409,7 @@ class _CreateFilePageState extends State<CreateFilePage> { ), ), ), - const SizedBox(height: 8), - - // Bottom Text Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -360,17 +437,24 @@ class _CreateFilePageState extends State<CreateFilePage> { } void _navigateToEditor(int width, int height) { + // FORCE Selection: If no project is selected, open the modal and return. + if (_selectedProjectId == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("Please select a destination project")), + ); + _openProjectSelection(); + return; + } + Navigator.push( context, MaterialPageRoute( builder: (context) => CanvasBoardPage( - // Generate a random ID for the project - projectId: 'proj_${DateTime.now().millisecondsSinceEpoch}', - // Pass the dimensions from the preset + // Use the selected project ID + projectId: _selectedProjectId.toString(), width: width.toDouble(), height: height.toDouble(), - // Pass the file if it exists (e.g. from "Image to Canvas" flow) initialImage: widget.file, ), ), @@ -393,3 +477,524 @@ class CanvasPreset { this.svgPath, }); } + +// -------------------------------------------------------------------------- +// --- PROJECT SELECTION MODAL --- +// -------------------------------------------------------------------------- + +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!; +} + +class ProjectGroup { + final ProjectModel project; + final List<ProjectItemViewModel> events; + final String? coverPath; + bool isExpanded; + + ProjectGroup({ + required this.project, + this.events = const [], + this.coverPath, + this.isExpanded = false, + }); +} + +class ProjectSelectionModal extends StatefulWidget { + final Function(int id, String title) onProjectSelected; + final ScrollController scrollController; + + const ProjectSelectionModal({ + super.key, + required this.onProjectSelected, + required this.scrollController, + }); + + @override + State<ProjectSelectionModal> createState() => _ProjectSelectionModalState(); +} + +class _ProjectSelectionModalState extends State<ProjectSelectionModal> { + final ProjectService _projectService = ProjectService(); + final ImageService _imageService = ImageService(); + + List<ProjectItemViewModel> _recentViewModels = []; + List<ProjectGroup> _groupedProjects = []; + List<ProjectGroup> _filteredGroupedProjects = []; + + bool _isLoading = true; + String _searchQuery = ""; + final TextEditingController _searchController = TextEditingController(); + + @override + void initState() { + super.initState(); + _loadData(); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + Future<String?> _getProjectCover(int projectId) async { + try { + final images = await _imageService.getImages(projectId); + if (images.isNotEmpty) { + return images.first.filePath; + } + } catch (e) { + debugPrint("Error fetching cover for project $projectId: $e"); + } + return null; + } + + Future<void> _loadData() async { + setState(() => _isLoading = true); + + final recentItems = await _projectService.getRecentProjectsAndEvents(); + final allProjects = await _projectService.getAllProjects(); + + // Build Recent View Models + final List<ProjectItemViewModel> recents = []; + for (var item in recentItems.take(3)) { + String? parentTitle; + if (item.parentId != null) { + final parent = await _projectService.getProjectById(item.parentId!); + parentTitle = parent?.title; + } + final cover = await _getProjectCover(item.id!); + recents.add( + ProjectItemViewModel( + item: item, + parentTitle: parentTitle, + coverPath: cover, + ), + ); + } + _recentViewModels = recents; + + // Build Grouped Projects + final List<ProjectGroup> groups = []; + for (final p in allProjects) { + final rawEvents = await _projectService.getEvents(p.id!); + final List<ProjectItemViewModel> eventVMs = []; + for (final e in rawEvents) { + 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); + } + + void _filterProjects(String query) { + setState(() { + _searchQuery = query; + if (query.isEmpty) { + _filteredGroupedProjects = _groupedProjects; + } else { + final q = query.toLowerCase(); + final List<ProjectGroup> filtered = []; + for (final g in _groupedProjects) { + 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( + project: g.project, + events: g.events, + isExpanded: true, + coverPath: g.coverPath, + ), + ); + } else if (matchingEvents.isNotEmpty) { + filtered.add( + ProjectGroup( + project: g.project, + events: matchingEvents, + isExpanded: true, + coverPath: g.coverPath, + ), + ); + } + } + _filteredGroupedProjects = filtered; + } + }); + } + + Future<void> _createNewProject() async { + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => const DefineBrandPage( + projectName: + "", // Can pass empty string if you want user to type it + ), + ), + ); + + // Check if a project was created and returned + if (result != null && result is Map) { + final newId = result['id']; + final title = result['title']; + if (newId != null && title != null) { + widget.onProjectSelected(newId, title); + } + } + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + // Header handle + Center( + child: Container( + margin: const EdgeInsets.only(top: 12, bottom: 8), + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + ), + + // Header Row + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + "Select Destination", + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + fontFamily: 'GeneralSans', + ), + ), + IconButton( + icon: const Icon(Icons.add), + onPressed: _createNewProject, + tooltip: "Create New Project", + ), + ], + ), + ), + + // Search Bar + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: SizedBox( + height: 42, + child: TextField( + controller: _searchController, + onChanged: _filterProjects, + decoration: InputDecoration( + hintText: "Search Projects", + 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: 0, + horizontal: 16, + ), + ), + ), + ), + ), + + Divider(color: Colors.grey[200]), + + // Content + Expanded( + child: + _isLoading + ? const Center(child: CircularProgressIndicator()) + : ListView( + controller: widget.scrollController, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + children: [ + if (_searchQuery.isEmpty && + _recentViewModels.isNotEmpty) ...[ + const Padding( + padding: EdgeInsets.only(bottom: 8, top: 8), + child: Text( + "Recent Projects/Events", + style: TextStyle( + fontSize: 14, + fontFamily: 'GeneralSans', + color: Color(0xFF27272A), + fontWeight: FontWeight.w400, + ), + ), + ), + ..._recentViewModels.map((vm) => _buildRecentItem(vm)), + const SizedBox(height: 16), + ], + + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + _searchQuery.isEmpty + ? "All Projects/Events" + : "Search Results", + style: const TextStyle( + fontSize: 14, + fontFamily: 'GeneralSans', + color: Color(0xFF27272A), + fontWeight: FontWeight.w400, + ), + ), + ), + ..._filteredGroupedProjects.map( + (g) => _buildProjectGroup(g), + ), + const SizedBox(height: 40), + ], + ), + ), + ], + ); + } + + Widget _buildRecentItem(ProjectItemViewModel vm) { + return Container( + margin: const EdgeInsets.only(bottom: 8), + child: InkWell( + onTap: () => widget.onProjectSelected(vm.id, vm.title), + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.fromLTRB(4, 4, 0, 4), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE4E4E7), width: 1), + ), + child: Row( + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: const Color(0xFFFAFAFA), + borderRadius: BorderRadius.circular(8), + image: + vm.coverPath != null + ? DecorationImage( + image: FileImage(File(vm.coverPath!)), + fit: BoxFit.cover, + ) + : null, + ), + child: + vm.coverPath == null + ? Icon(Icons.image, color: Colors.grey[400], size: 28) + : null, + ), + 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.title, + style: const TextStyle( + fontFamily: 'GeneralSans', + fontSize: 16, + fontWeight: FontWeight.w500, + color: Color(0xFF27272A), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildProjectGroup(ProjectGroup g) { + final hasEvents = g.events.isNotEmpty; + return Container( + margin: const EdgeInsets.only(bottom: 8), + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE4E4E7)), + ), + child: Column( + children: [ + ListTile( + onTap: + () => + hasEvents + ? setState(() => g.isExpanded = !g.isExpanded) + : widget.onProjectSelected( + g.project.id!, + g.project.title, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + visualDensity: VisualDensity.compact, + leading: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(12), + image: + g.coverPath != null + ? DecorationImage( + image: FileImage(File(g.coverPath!)), + fit: BoxFit.cover, + ) + : null, + ), + child: + g.coverPath == null + ? Icon(Icons.folder, color: Colors.grey[500]) + : null, + ), + title: Text( + g.project.title, + style: const TextStyle( + fontFamily: 'GeneralSans', + fontSize: 16, + fontWeight: FontWeight.w500, + color: Color(0xFF27272A), + ), + ), + trailing: + hasEvents + ? IconButton( + icon: Icon( + g.isExpanded + ? Icons.keyboard_arrow_up + : Icons.keyboard_arrow_down, + color: Colors.grey[600], + ), + onPressed: () { + setState(() => g.isExpanded = !g.isExpanded); + }, + ) + : null, + ), + if (hasEvents && g.isExpanded) + AnimatedCrossFade( + firstChild: const SizedBox.shrink(), + secondChild: Container( + width: double.infinity, + color: const Color(0xFFF9FAFB), + child: Column( + children: + g.events.map((e) { + return ListTile( + onTap: () => widget.onProjectSelected(e.id, e.title), + contentPadding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 2, + ), + visualDensity: VisualDensity.compact, + leading: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.grey.shade200), + image: + e.coverPath != null + ? DecorationImage( + image: FileImage(File(e.coverPath!)), + fit: BoxFit.cover, + ) + : null, + ), + child: + e.coverPath == null + ? const Icon( + Icons.event, + size: 20, + color: Colors.grey, + ) + : null, + ), + title: Text( + e.title, + style: const TextStyle( + fontFamily: 'GeneralSans', + fontSize: 15, + fontWeight: FontWeight.w500, + color: Color(0xFF27272A), + ), + ), + ); + }).toList(), + ), + ), + crossFadeState: + g.isExpanded + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + duration: const Duration(milliseconds: 200), + ), + ], + ), + ); + } +} diff --git a/lib/ui/pages/define_brand_page.dart b/lib/ui/pages/define_brand_page.dart @@ -59,7 +59,7 @@ class _DefineBrandPageState extends State<DefineBrandPage> { } Future<void> _handleFinish() async { - // 1. Basic Validation - only Project Name is required + // 1. Basic Validation if (_projectNameController.text.trim().isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Project name is required.')), @@ -72,31 +72,19 @@ class _DefineBrandPageState extends State<DefineBrandPage> { }); try { - // 2. Prepare the data - final brandData = { - 'description': _descriptionController.text.trim(), - 'problem': _problemController.text.trim(), - 'goal': _goalController.text.trim(), - 'keywords': _keywords, - 'competitors': _competitorBrands.map((b) => b['name']).toList(), - 'appear': _whereWillAppearController.text.trim(), - }; - - // 3. Call the Service - debugPrint('BrandData: $brandData'); - await _projectService.createProject( - _projectNameController.text.trim().isEmpty - ? 'Untitled Project' - : _projectNameController.text.trim(), - description: - _descriptionController.text.trim().isEmpty - ? null - : _descriptionController.text.trim(), + // 2. Prepare data + final String title = _projectNameController.text.trim(); + final String description = _descriptionController.text.trim(); + + // 3. Call the Service and capture the NEW ID + final int newId = await _projectService.createProject( + title, + description: description.isEmpty ? null : description, ); - // 4. Success Handling + // 4. Success Handling - Return the ID and Title map if (mounted) { - Navigator.pop(context, true); + Navigator.pop(context, {'id': newId, 'title': title}); } } catch (e) { // 5. Error Handling diff --git a/lib/ui/pages/project_file_page.dart b/lib/ui/pages/project_file_page.dart @@ -54,7 +54,22 @@ class _ProjectFilePageState extends State<ProjectFilePage> { ); if (pickedFile != null && mounted) { - _showAddFileDialog(File(pickedFile.path)); + // Navigate to CreateFilePage with the selected image + await Navigator.push( + context, + MaterialPageRoute( + builder: + (_) => CreateFilePage( + file: File(pickedFile.path), + projectId: widget.projectId, // PASS PROJECT ID + ), + ), + ); + + // Refresh the list after returning from the creation flow + if (mounted) { + _loadData(); + } } } catch (e) { debugPrint("Error picking file: $e"); @@ -169,7 +184,8 @@ class _ProjectFilePageState extends State<ProjectFilePage> { context, MaterialPageRoute( // Passing no file implies a "Blank Canvas" - builder: (_) => const CreateFilePage(), + // Pass projectId so CreateFilePage knows we are in a specific project context + builder: (_) => CreateFilePage(projectId: widget.projectId), ), ).then((_) => _loadData()); }