creek

The AI Image Editor of 2030
commit 8d56d21e4f205773b57c1a5f848906a4d8b21894
parent 5495fe41daaee683edf1b55682e2caf9e052b88f
Author: maydayv7 <maydayv7@gmail.com>
Date:   Sat,  6 Dec 2025 04:06:15 +0530

Create project selector widget and settings page

Also modify test image analysis page and fix crash on back button

Diffstat:
Mlib/ui/pages/create_file_page.dart | 349+++++++++++++++----------------------------------------------------------------
Mlib/ui/pages/image_analysis_page.dart | 161++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mlib/ui/pages/project_detail_page.dart | 31++++++++++++++++++++++++++++---
Alib/ui/pages/settings_page.dart | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Mlib/ui/pages/share_to_file_page.dart | 236+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
Mlib/ui/pages/share_to_moodboard_page.dart | 443+++++--------------------------------------------------------------------------
Alib/ui/widgets/project_selector.dart | 525+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mlib/ui/widgets/section_header.dart | 70+++++++++++++++++++++++++++++++++++++++++++---------------------------
Mlib/ui/widgets/top_bar.dart | 90+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
9 files changed, 1094 insertions(+), 860 deletions(-)

diff --git a/lib/ui/pages/create_file_page.dart b/lib/ui/pages/create_file_page.dart @@ -2,10 +2,9 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:creekui/services/project_service.dart'; -import 'package:creekui/services/image_service.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/project_selector.dart'; import 'canvas_page.dart'; import 'define_brand_page.dart'; @@ -137,7 +136,7 @@ class _CreateFilePageState extends State<CreateFilePage> { color: Colors.white, borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), - child: ProjectSelectionModal( + child: _ProjectSelectionModalContent( scrollController: controller, onProjectSelected: (id, title) { setState(() { @@ -179,18 +178,24 @@ class _CreateFilePageState extends State<CreateFilePage> { @override Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + return Scaffold( - backgroundColor: const Color(0xFFF5F5F7), + backgroundColor: theme.scaffoldBackgroundColor, appBar: AppBar( - backgroundColor: const Color(0xFFF5F5F7), + backgroundColor: theme.scaffoldBackgroundColor, elevation: 0, leading: IconButton( - icon: const Icon(Icons.arrow_back, color: Colors.black), + icon: Icon(Icons.arrow_back, color: theme.colorScheme.onSurface), onPressed: () => Navigator.pop(context), ), title: Text( 'Create Files', - style: Variables.headerStyle.copyWith(fontSize: 16), + style: Variables.headerStyle.copyWith( + fontSize: 16, + color: theme.colorScheme.onSurface, + ), ), // Show project chooser only when ID not passed @@ -201,7 +206,7 @@ class _CreateFilePageState extends State<CreateFilePage> { child: TextButton.icon( onPressed: _openProjectSelection, style: TextButton.styleFrom( - backgroundColor: Colors.white, + backgroundColor: isDark ? Colors.grey[800] : Colors.white, padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 8, @@ -215,11 +220,14 @@ class _CreateFilePageState extends State<CreateFilePage> { ? Icons.create_new_folder_outlined : Icons.folder_open, size: 18, - color: Colors.black, + color: theme.colorScheme.onSurface, ), label: Text( _selectedProjectTitle, - style: Variables.bodyStyle.copyWith(fontSize: 12), + style: Variables.bodyStyle.copyWith( + fontSize: 12, + color: theme.colorScheme.onSurface, + ), ), ), ), @@ -243,7 +251,7 @@ class _CreateFilePageState extends State<CreateFilePage> { child: Text( 'Canvas Sizes', style: Variables.bodyStyle.copyWith( - color: Colors.grey, + color: theme.colorScheme.onSurface.withValues(alpha: 0.5), fontSize: 15, fontWeight: FontWeight.w600, ), @@ -261,7 +269,8 @@ class _CreateFilePageState extends State<CreateFilePage> { childAspectRatio: 0.75, ), itemCount: _filteredPresets.length, - itemBuilder: (_, i) => _buildPresetCard(_filteredPresets[i]), + itemBuilder: + (_, i) => _buildPresetCard(_filteredPresets[i], theme), ), ), ], @@ -269,9 +278,10 @@ class _CreateFilePageState extends State<CreateFilePage> { ); } - // Card widget - Widget _buildPresetCard(CanvasPreset preset) { + Widget _buildPresetCard(CanvasPreset preset, ThemeData theme) { final bool isCustom = preset.name == 'Custom'; + final isDark = theme.brightness == Brightness.dark; + return InkWell( onTap: () => _navigateToEditor( @@ -281,8 +291,11 @@ class _CreateFilePageState extends State<CreateFilePage> { borderRadius: BorderRadius.circular(12), child: Container( decoration: BoxDecoration( - color: Colors.white, + color: theme.cardColor, borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isDark ? Variables.borderDark : Colors.transparent, + ), ), padding: const EdgeInsets.all(8), child: Column( @@ -291,7 +304,7 @@ class _CreateFilePageState extends State<CreateFilePage> { Expanded( child: Container( decoration: BoxDecoration( - color: const Color(0xFFE0E7FF), + color: isDark ? Colors.grey[800] : const Color(0xFFE0E7FF), borderRadius: BorderRadius.circular(8), ), child: Center( @@ -304,7 +317,7 @@ class _CreateFilePageState extends State<CreateFilePage> { aspectRatio: preset.width / preset.height, child: Container( decoration: BoxDecoration( - color: Colors.white, + color: theme.cardColor, borderRadius: BorderRadius.circular(4), boxShadow: [ BoxShadow( @@ -335,11 +348,15 @@ class _CreateFilePageState extends State<CreateFilePage> { style: Variables.bodyStyle.copyWith( fontSize: 11, fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, ), ), Text( preset.displaySize, - style: Variables.captionStyle.copyWith(fontSize: 9), + style: Variables.captionStyle.copyWith( + fontSize: 9, + color: theme.colorScheme.onSurface.withValues(alpha: 0.6), + ), ), ], ), @@ -364,167 +381,24 @@ class CanvasPreset { }); } -// -------------------------------------------------------------------------- -// --- 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 { +// Project Selection Modal +class _ProjectSelectionModalContent extends StatefulWidget { final Function(int id, String title) onProjectSelected; final ScrollController scrollController; - const ProjectSelectionModal({ - super.key, + const _ProjectSelectionModalContent({ required this.onProjectSelected, required this.scrollController, }); @override - State<ProjectSelectionModal> createState() => _ProjectSelectionModalState(); + State<_ProjectSelectionModalContent> createState() => + _ProjectSelectionModalContentState(); } -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 (_) {} - return null; - } - - Future<void> _loadData() async { - setState(() => _isLoading = true); - - final recent = await _projectService.getRecentProjectsAndEvents(); - final allProjects = await _projectService.getAllProjects(); - - // Build Recent - final List<ProjectItemViewModel> recents = []; - for (var item in recent.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 groups - final List<ProjectGroup> groups = []; - for (final p in allProjects) { - final eventsRaw = await _projectService.getEvents(p.id!); - final List<ProjectItemViewModel> events = []; - for (final e in eventsRaw) { - final cover = await _getProjectCover(e.id!); - events.add(ProjectItemViewModel(item: e, coverPath: cover)); - } - final cover = await _getProjectCover(p.id!); - groups.add(ProjectGroup(project: p, events: events, coverPath: cover)); - } - - _groupedProjects = groups; - _filteredGroupedProjects = groups; - - if (mounted) setState(() => _isLoading = false); - } - - void _filterProjects(String query) { - setState(() { - _searchQuery = query; - if (query.isEmpty) { - _filteredGroupedProjects = _groupedProjects; - return; - } - 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; - }); - } +class _ProjectSelectionModalContentState + extends State<_ProjectSelectionModalContent> { + Key _selectorKey = UniqueKey(); Future<void> _createNewProject() async { final result = await Navigator.push( @@ -534,6 +408,11 @@ class _ProjectSelectionModalState extends State<ProjectSelectionModal> { if (result != null && result is Map) { widget.onProjectSelected(result["id"], result["title"]); + } else { + // If project is created but not selected immediately, refreshing the list is a safe bet. + setState(() { + _selectorKey = UniqueKey(); + }); } } @@ -559,135 +438,33 @@ class _ProjectSelectionModalState extends State<ProjectSelectionModal> { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const Text( + Text( "Select Destination", - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + style: Variables.headerStyle.copyWith(fontSize: 18), ), IconButton( icon: const Icon(Icons.add), onPressed: _createNewProject, + tooltip: "Create New Project", ), ], ), ), - - // Search Bar - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: CommonSearchBar( - controller: _searchController, - hintText: "Search Projects", - onChanged: _filterProjects, - ), - ), - - Divider(color: Colors.grey[300]), - + Divider(color: Colors.grey[300], height: 1), + // Selector Expanded( - child: - _isLoading - ? const Center(child: CircularProgressIndicator()) - : ListView( - controller: widget.scrollController, - children: [ - if (_searchQuery.isEmpty && _recentViewModels.isNotEmpty) - const Padding( - padding: EdgeInsets.only(left: 16, bottom: 8), - child: Text( - "Recent Projects/Events", - style: TextStyle(fontSize: 14), - ), - ), - if (_searchQuery.isEmpty) - ..._recentViewModels.map(_buildRecentItem), - - const Padding( - padding: EdgeInsets.only(left: 16, top: 8, bottom: 8), - child: Text( - "All Projects/Events", - style: TextStyle(fontSize: 14), - ), - ), - ..._filteredGroupedProjects.map(_buildProjectGroup), - ], - ), - ), - ], - ); - } - - Widget _buildRecentItem(ProjectItemViewModel vm) { - return InkWell( - onTap: () => widget.onProjectSelected(vm.id, vm.title), - child: ListTile( - leading: _thumbnail(vm.coverPath), - title: Text(vm.title), - subtitle: vm.parentTitle != null ? Text(vm.parentTitle!) : null, - ), - ); - } - - Widget _thumbnail(String? path) { - return Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: Colors.grey[200], - borderRadius: BorderRadius.circular(8), - image: - path != null - ? DecorationImage( - image: FileImage(File(path)), - fit: BoxFit.cover, - ) - : null, - ), - child: path == null ? Icon(Icons.image, color: Colors.grey[400]) : null, - ); - } - - Widget _buildProjectGroup(ProjectGroup g) { - final hasEvents = g.events.isNotEmpty; - - return Container( - margin: const EdgeInsets.only(bottom: 8), - child: Column( - children: [ - ListTile( - onTap: () { - if (hasEvents) { - setState(() => g.isExpanded = !g.isExpanded); - } else { - widget.onProjectSelected(g.project.id!, g.project.title); - } + child: ProjectSelector( + key: _selectorKey, + scrollController: widget.scrollController, + searchHint: "Search Projects", + onProjectSelected: (id, title, parentTitle) { + final displayTitle = + parentTitle != null ? "$parentTitle / $title" : title; + widget.onProjectSelected(id, displayTitle); }, - leading: _thumbnail(g.coverPath), - title: Text(g.project.title), - trailing: - hasEvents - ? Icon( - g.isExpanded - ? Icons.keyboard_arrow_up - : Icons.keyboard_arrow_down, - ) - : null, ), - if (hasEvents && g.isExpanded) - Container( - color: Colors.grey[100], - child: Column( - children: - g.events.map((e) { - return ListTile( - onTap: () => widget.onProjectSelected(e.id, e.title), - leading: _thumbnail(e.coverPath), - title: Text(e.title), - ); - }).toList(), - ), - ), - ], - ), + ), + ], ); } } diff --git a/lib/ui/pages/image_analysis_page.dart b/lib/ui/pages/image_analysis_page.dart @@ -5,7 +5,6 @@ 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/ui/styles/variables.dart'; -import 'package:creekui/ui/widgets/empty_state.dart'; class ImageAnalysisPage extends StatefulWidget { const ImageAnalysisPage({super.key}); @@ -22,6 +21,19 @@ class _ImageAnalysisPageState extends State<ImageAnalysisPage> { final ImagePicker _picker = ImagePicker(); + final Set<String> _selectedTags = {}; + final List<String> _availableTags = [ + 'Compositions', + 'Colours', + 'Texture', + 'Style', + 'Emotion', + 'Lighting', + 'Era', + 'Fonts', + 'Subject', + ]; + Future<void> _pickImage(ImageSource source) async { try { final XFile? image = await _picker.pickImage(source: source); @@ -31,8 +43,6 @@ class _ImageAnalysisPageState extends State<ImageAnalysisPage> { _analysisResult = null; _errorMessage = null; }); - // Auto-run analysis when a new image is picked - _runAnalysis(image.path); } } catch (e) { if (mounted) { @@ -44,7 +54,9 @@ class _ImageAnalysisPageState extends State<ImageAnalysisPage> { } } - Future<void> _runAnalysis(String sourcePath) async { + Future<void> _runAnalysis() async { + if (_selectedImage == null) return; + setState(() { _isAnalyzing = true; _errorMessage = null; @@ -52,12 +64,21 @@ class _ImageAnalysisPageState extends State<ImageAnalysisPage> { try { final appDir = await getApplicationDocumentsDirectory(); - final fileName = sourcePath.split('/').last; + final fileName = _selectedImage!.path.split('/').last; final targetPath = '${appDir.path}/$fileName'; - final file = File(sourcePath); + + final file = File(_selectedImage!.path); await file.copy(targetPath); - final result = await ImageAnalyzerService.analyzeFullSuite(targetPath); + Map<String, dynamic> result; + if (_selectedTags.isEmpty) { + result = await ImageAnalyzerService.analyzeFullSuite(targetPath); + } else { + result = await ImageAnalyzerService.analyzeSelected( + targetPath, + _selectedTags.toList(), + ); + } if (mounted) { setState(() { @@ -126,6 +147,7 @@ class _ImageAnalysisPageState extends State<ImageAnalysisPage> { @override Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; return Scaffold( backgroundColor: Colors.white, appBar: AppBar( @@ -135,16 +157,20 @@ class _ImageAnalysisPageState extends State<ImageAnalysisPage> { icon: const Icon(Icons.arrow_back, color: Colors.black), onPressed: () => Navigator.pop(context), ), - title: Text( + title: const Text( "Image Analysis", - style: Variables.headerStyle.copyWith(fontSize: 20), + style: TextStyle( + color: Colors.black, + fontFamily: 'GeneralSans', + fontWeight: FontWeight.bold, + ), ), actions: [ - if (_selectedImage != null) + if (_selectedImage != null && !_isAnalyzing) IconButton( - icon: const Icon(Icons.refresh, color: Colors.black), - tooltip: "Re-analyze", - onPressed: () => _runAnalysis(_selectedImage!.path), + icon: const Icon(Icons.play_arrow, color: Variables.textPrimary), + tooltip: "Run Analysis", + onPressed: _runAnalysis, ), ], ), @@ -159,7 +185,7 @@ class _ImageAnalysisPageState extends State<ImageAnalysisPage> { width: double.infinity, height: 250, decoration: BoxDecoration( - color: Variables.borderSubtle, + color: Colors.grey[100], borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( @@ -177,10 +203,23 @@ class _ImageAnalysisPageState extends State<ImageAnalysisPage> { if (_selectedImage != null) Image.file(_selectedImage!, fit: BoxFit.contain) else - const EmptyState( - icon: Icons.bug_report_outlined, - title: "No image selected", - subtitle: "Select an image to test full suite", + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.bug_report_outlined, + size: 48, + color: Colors.grey[400], + ), + const SizedBox(height: 12), + Text( + "Select image to analyze", + style: TextStyle( + fontFamily: 'GeneralSans', + color: Colors.grey[500], + ), + ), + ], ), if (_isAnalyzing) Container( @@ -198,6 +237,80 @@ class _ImageAnalysisPageState extends State<ImageAnalysisPage> { ), const SizedBox(height: 24), + // Tag Selector + Text( + "Select Analysis Modules", + style: Variables.headerStyle.copyWith(fontSize: 16), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: + _availableTags.map((tag) { + final isSelected = _selectedTags.contains(tag); + return GestureDetector( + onTap: () { + setState(() { + if (isSelected) { + _selectedTags.remove(tag); + } else { + _selectedTags.add(tag); + } + }); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: + isSelected + ? const Color(0xFFEEF0FF) + : Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: + isSelected + ? const Color(0xFF7C4DFF) + : Colors.grey[300]!, + width: 1.0, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isSelected) ...[ + const Icon( + Icons.check, + 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, + ), + ), + ], + ), + ), + ); + }).toList(), + ), + const SizedBox(height: 24), + // Error Message if (_errorMessage != null) Container( @@ -219,9 +332,13 @@ class _ImageAnalysisPageState extends State<ImageAnalysisPage> { // Raw JSON Result if (_analysisResult != null) ...[ - Text( + const Text( "Results", - style: Variables.headerStyle.copyWith(fontSize: 18), + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + fontFamily: 'GeneralSans', + ), ), const SizedBox(height: 12), _buildJsonViewer(_analysisResult!), @@ -232,8 +349,8 @@ class _ImageAnalysisPageState extends State<ImageAnalysisPage> { ), floatingActionButton: FloatingActionButton.extended( onPressed: _showSourceSelector, - backgroundColor: Variables.textPrimary, - foregroundColor: Colors.white, + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, icon: const Icon(Icons.add_photo_alternate), label: Text( _selectedImage == null ? "Select Image" : "Change Image", diff --git a/lib/ui/pages/project_detail_page.dart b/lib/ui/pages/project_detail_page.dart @@ -9,6 +9,8 @@ import 'package:creekui/services/project_service.dart'; import 'package:creekui/ui/styles/variables.dart'; import 'package:creekui/ui/widgets/empty_state.dart'; import 'package:creekui/ui/widgets/section_header.dart'; +import 'package:creekui/ui/pages/settings_page.dart'; +import 'package:creekui/ui/pages/home_page.dart'; import 'project_board_page.dart'; import 'stylesheet_page.dart'; import 'project_file_page.dart'; @@ -305,8 +307,26 @@ class _ProjectDetailPageState extends State<ProjectDetailPage> { title: Text(_project!.title, style: Variables.headerStyle), backgroundColor: const Color(0xFFFAFAFA), elevation: 0, - leadingWidth: 40, - titleSpacing: 4, + leadingWidth: 50, + titleSpacing: 0, + automaticallyImplyLeading: false, + leading: IconButton( + icon: const Icon( + Icons.arrow_back, + size: 20, + color: Variables.textPrimary, + ), + onPressed: () { + if (Navigator.canPop(context)) { + Navigator.pop(context); + } else { + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (_) => const HomePage()), + ); + } + }, + ), actions: [ IconButton( icon: SvgPicture.asset( @@ -318,7 +338,12 @@ class _ProjectDetailPageState extends State<ProjectDetailPage> { BlendMode.srcIn, ), ), - onPressed: () {}, + onPressed: () { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const SettingsPage()), + ); + }, ), ], ), diff --git a/lib/ui/pages/settings_page.dart b/lib/ui/pages/settings_page.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:creekui/ui/styles/variables.dart'; +import 'package:creekui/ui/pages/image_analysis_page.dart'; + +class SettingsPage extends StatelessWidget { + const SettingsPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + title: const Text( + "Settings", + style: TextStyle( + fontFamily: 'GeneralSans', + color: Variables.textPrimary, + ), + ), + backgroundColor: Colors.white, + elevation: 0, + iconTheme: const IconThemeData(color: Variables.textPrimary), + ), + body: ListView( + children: [ + ListTile( + title: const Text( + "Test Image Analysis", + style: TextStyle( + fontFamily: 'GeneralSans', + color: Variables.textPrimary, + ), + ), + trailing: const Icon( + Icons.chevron_right, + color: Variables.textSecondary, + ), + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const ImageAnalysisPage()), + ); + }, + ), + ], + ), + ); + } +} diff --git a/lib/ui/pages/share_to_file_page.dart b/lib/ui/pages/share_to_file_page.dart @@ -9,8 +9,8 @@ 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 'package:creekui/ui/widgets/empty_state.dart'; import 'create_file_page.dart'; import 'canvas_page.dart'; @@ -30,6 +30,8 @@ class _ShareToFilePageState extends State<ShareToFilePage> { List<FileModel> _allFiles = []; List<FileModel> _filteredFiles = []; List<FileModel> _recentFiles = []; + + // file.id -> { 'preview': path, 'dimensions': str } Map<String, Map<String, String>> _fileMetadata = {}; // Avoid repeated fetches @@ -69,7 +71,7 @@ class _ShareToFilePageState extends State<ShareToFilePage> { await _loadFileMetadata(files); setState(() => _isLoading = false); } catch (e) { - debugPrint('Error fetching files: $e'); + debugPrint('Error fetching files in ShareToFilePage: $e'); setState(() => _isLoading = false); } } @@ -90,7 +92,8 @@ class _ShareToFilePageState extends State<ShareToFilePage> { String dims = 'Unknown'; if (data is Map) { - if (data['preview_path'] != null) { + if (data['preview_path'] != null && + data['preview_path'].toString().isNotEmpty) { preview = data['preview_path'].toString(); // If preview is relative, try to resolve relative to JSON file if (!File(preview).existsSync()) { @@ -100,11 +103,15 @@ class _ShareToFilePageState extends State<ShareToFilePage> { } } if (data['width'] != null && data['height'] != null) { - dims = '${data['width']} x ${data['height']} px'; + final w = (data['width'] as num).toInt(); + final h = (data['height'] as num).toInt(); + dims = '$w x $h px'; } } meta[fmodel.id] = {'preview': preview, 'dimensions': dims}; - } catch (_) {} + } catch (e) { + debugPrint('Error parsing canvas json for ${fmodel.id}: $e'); + } } else { // Regular image file - assign path and try String dims = 'Unknown'; @@ -112,10 +119,14 @@ class _ShareToFilePageState extends State<ShareToFilePage> { final bytes = await f.readAsBytes(); final image = img.decodeImage(bytes); if (image != null) dims = '${image.width} x ${image.height} px'; - } catch (_) {} + } catch (_) { + // ignore + } meta[fmodel.id] = {'preview': fmodel.filePath, 'dimensions': dims}; } - } catch (_) {} + } catch (e) { + debugPrint('Error while loading metadata for ${fmodel.id}: $e'); + } } _fileMetadata = meta; } @@ -131,7 +142,8 @@ class _ShareToFilePageState extends State<ShareToFilePage> { _allFiles.where((file) { final nameMatch = file.name.toLowerCase().contains(q); final breadcrumb = _getProjectBreadcrumbSync(file).toLowerCase(); - return nameMatch || breadcrumb.contains(q); + final projectMatch = breadcrumb.contains(q); + return nameMatch || projectMatch; }).toList(); } }); @@ -154,36 +166,63 @@ class _ShareToFilePageState extends State<ShareToFilePage> { try { final p = await _projectService.getProjectById(file.projectId); if (p != null) _projectCache[file.projectId] = p; - } catch (_) {} + } catch (e) { + debugPrint('Project load failed for ${file.projectId}: $e'); + } } final project = _projectCache[file.projectId]; if (project == null) return "Unknown"; - if (project.parentId == null) return project.title; - if (!_projectCache.containsKey(project.parentId!)) { + if (project.parentId == null) { + return project.title; + } + + final parentId = project.parentId!; + if (!_projectCache.containsKey(parentId)) { try { - final parent = await _projectService.getProjectById(project.parentId!); - if (parent != null) _projectCache[project.parentId!] = parent; - } catch (_) {} + final parent = await _projectService.getProjectById(parentId); + if (parent != null) _projectCache[parentId] = parent; + } catch (e) { + debugPrint('Parent project load failed for $parentId: $e'); + } } - final parent = _projectCache[project.parentId]; - return parent == null - ? project.title - : "${parent.title} / ${project.title}"; + + 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), + ), + ); } void _onFileSelected(FileModel file) async { try { final f = File(file.filePath); - if (!await f.exists()) return; + if (!await f.exists()) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text("File not found"))); + return; + } + + double width = 1080; + double height = 1080; - double width = 1080, height = 1080; if (file.filePath.toLowerCase().endsWith('.json')) { final content = await f.readAsString(); final data = jsonDecode(content); - if (data is Map && data['width'] != null) { - width = (data['width'] as num).toDouble(); - height = (data['height'] as num).toDouble(); + + if (data is Map) { + if (data['width'] != null && data['height'] != null) { + width = (data['width'] as num).toDouble(); + height = (data['height'] as num).toDouble(); + } } } @@ -213,31 +252,54 @@ class _ShareToFilePageState extends State<ShareToFilePage> { return '${date.day}/${date.month}/${date.year}'; } + // Fallback to original filePath if preview path empty/missing + 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; + + if (file.filePath.toLowerCase().endsWith('.json')) { + 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: Variables.background, + backgroundColor: theme.scaffoldBackgroundColor, appBar: AppBar( - backgroundColor: Variables.background, + backgroundColor: theme.scaffoldBackgroundColor, elevation: 0, leading: IconButton( - icon: const Icon(Icons.arrow_back, color: Colors.black), + icon: Icon(Icons.arrow_back, color: theme.colorScheme.onSurface), onPressed: () => Navigator.pop(context), ), title: Text( 'Files', - style: Variables.headerStyle.copyWith(fontSize: 20), + style: Variables.headerStyle.copyWith( + color: theme.colorScheme.onSurface, + ), ), actions: [ IconButton( - icon: const Icon(Icons.add, color: Colors.black, size: 28), - onPressed: - () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => CreateFilePage(file: widget.sharedImage), - ), - ), + icon: Icon(Icons.add, color: theme.colorScheme.onSurface, size: 28), + onPressed: _onAddPressed, + tooltip: "Create New File", ), ], ), @@ -245,13 +307,10 @@ class _ShareToFilePageState extends State<ShareToFilePage> { _isLoading ? const Center(child: CircularProgressIndicator()) : _allFiles.isEmpty - ? const EmptyState( - icon: Icons.folder_open, - title: "No files yet", - subtitle: "Tap + to create your first file", - ) + ? _buildEmptyState() : Column( children: [ + // Search bar Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), child: CommonSearchBar( @@ -264,15 +323,24 @@ class _ShareToFilePageState extends State<ShareToFilePage> { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (_filteredFiles.isEmpty && _searchQuery.isNotEmpty) + const Padding( + padding: EdgeInsets.all(32.0), + child: EmptyState( + icon: Icons.search_off, + title: "No results found", + subtitle: "Try adjusting your search", + ), + ), + + // Recent Files if (_recentFiles.isNotEmpty && _searchQuery.isEmpty) ...[ const Padding( - padding: EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), + padding: EdgeInsets.symmetric(horizontal: 16), child: SectionHeader(title: "Recent Files"), ), + const SizedBox(height: 12), Padding( padding: const EdgeInsets.symmetric( horizontal: 16, @@ -286,29 +354,35 @@ class _ShareToFilePageState extends State<ShareToFilePage> { ), const SizedBox(height: 24), ], - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - child: SectionHeader( - title: - _searchQuery.isEmpty - ? "All Files" - : "Search Results", + + // All files header + if (_filteredFiles.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + child: SectionHeader( + title: + _searchQuery.isEmpty + ? "All Files" + : "Search Results", + ), ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: _filteredFiles.length, - itemBuilder: - (context, index) => - _buildFileItem(_filteredFiles[index]), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + child: ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: _filteredFiles.length, + itemBuilder: + (context, index) => + _buildFileCard(_filteredFiles[index]), + ), ), - ), + ], const SizedBox(height: 40), ], ), @@ -319,18 +393,42 @@ 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), + ), + const SizedBox(height: 8), + TextButton( + onPressed: _onAddPressed, + child: const Text("Create your first file"), + ), + ], + ), + ); + } + Widget _buildFileCard(FileModel file) { + final preview = _resolvePreviewPath(file); + final meta = _fileMetadata[file.id] ?? {}; + final dimensions = meta['dimensions'] ?? 'Unknown'; + 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'] ?? "", + dimensions: dimensions, + previewPath: preview, timeAgo: _formatDate(file.lastUpdated), onTap: () => _onFileSelected(file), onMenuAction: null, @@ -339,6 +437,4 @@ class _ShareToFilePageState extends State<ShareToFilePage> { }, ); } - - 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 @@ -1,38 +1,11 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:receive_sharing_intent/receive_sharing_intent.dart'; -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 'package:creekui/ui/widgets/project_selector.dart'; import 'image_save_page.dart'; -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 ShareToMoodboardPage extends StatefulWidget { final List<File> imageFiles; const ShareToMoodboardPage({super.key, required this.imageFiles}); @@ -42,118 +15,8 @@ class ShareToMoodboardPage extends StatefulWidget { } class _ShareToMoodboardPageState extends State<ShareToMoodboardPage> { - final ProjectRepo _projectRepo = ProjectRepo(); - final ImageRepo _imageRepo = ImageRepo(); final ProjectService _projectService = ProjectService(); - - 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 _imageRepo.getImages(projectId); - if (images.isNotEmpty) return images.first.filePath; - } catch (_) {} - return null; - } - - Future<void> _loadData() async { - setState(() => _isLoading = true); - - // 1. Fetch Raw Data - final recentItems = await _projectRepo.getRecentProjectsAndEvents(); - final allProjects = await _projectRepo.getAllProjects(); - - // 2. Build Recent View Models - final List<ProjectItemViewModel> recents = []; - for (var item in recentItems.take(3)) { - String? parentTitle; - if (item.parentId != null) { - final parent = await _projectRepo.getProjectById(item.parentId!); - parentTitle = parent?.title; - } - final cover = await _getProjectCover(item.id!); - recents.add( - ProjectItemViewModel( - item: item, - parentTitle: parentTitle, - coverPath: cover, - ), - ); - } - _recentViewModels = recents; - - // 3. Build Grouped Projects - final List<ProjectGroup> groups = []; - for (final p in allProjects) { - final rawEvents = await _projectRepo.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; - } - }); - } + Key _selectorKey = UniqueKey(); // Used to refresh list after creation Future<void> _createNewProject() async { final controller = TextEditingController(); @@ -161,7 +24,10 @@ class _ShareToMoodboardPageState extends State<ShareToMoodboardPage> { context: context, builder: (context) => AlertDialog( - title: const Text("New Project"), + title: const Text( + "New Project", + style: TextStyle(fontFamily: 'GeneralSans'), + ), content: TextField( controller: controller, decoration: const InputDecoration(hintText: "Project Title"), @@ -183,7 +49,9 @@ class _ShareToMoodboardPageState extends State<ShareToMoodboardPage> { if (title != null && title.isNotEmpty) { final newId = await _projectService.createProject(title); - await _loadData(); + setState(() { + _selectorKey = UniqueKey(); + }); _navigateToSavePage(newId, title); } } @@ -212,301 +80,36 @@ class _ShareToMoodboardPageState extends State<ShareToMoodboardPage> { @override Widget build(BuildContext context) { + final theme = Theme.of(context); + return Scaffold( - backgroundColor: Colors.white, + backgroundColor: theme.scaffoldBackgroundColor, appBar: AppBar( - backgroundColor: Colors.white, + backgroundColor: theme.scaffoldBackgroundColor, elevation: 0, leading: IconButton( - icon: const Icon(Icons.arrow_back, color: Colors.black), + icon: Icon(Icons.arrow_back, color: theme.colorScheme.onSurface), onPressed: () => Navigator.pop(context), ), title: Text( "MoodBoards", - style: Variables.headerStyle.copyWith(fontSize: 20), + style: Variables.headerStyle.copyWith( + color: theme.colorScheme.onSurface, + ), ), actions: [ IconButton( - icon: const Icon(Icons.add, color: Colors.black, size: 28), + icon: Icon(Icons.add, color: theme.colorScheme.onSurface, size: 28), onPressed: _createNewProject, tooltip: "Create New Project", ), ], ), - body: - _isLoading - ? const Center(child: CircularProgressIndicator()) - : Column( - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), - child: CommonSearchBar( - controller: _searchController, - onChanged: _filterProjects, - ), - ), - Expanded( - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (_searchQuery.isEmpty && - _recentViewModels.isNotEmpty) ...[ - const Padding( - padding: EdgeInsets.symmetric( - horizontal: 20, - vertical: 8, - ), - child: SectionHeader( - title: "Recent Projects/Events", - ), - ), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 20, - ), - child: Column( - children: - _recentViewModels - .map((vm) => _buildRecentItem(vm)) - .toList(), - ), - ), - const SizedBox(height: 24), - ], - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 8, - ), - child: SectionHeader( - title: - _searchQuery.isEmpty - ? "All Projects/Events" - : "Search Results", - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: _filteredGroupedProjects.length, - itemBuilder: - (context, index) => _buildProjectGroup( - _filteredGroupedProjects[index], - ), - ), - ), - const SizedBox(height: 40), - ], - ), - ), - ), - ], - ), - ); - } - - // Wrappers for consistent UI - Widget _buildRecentItem(ProjectItemViewModel vm) { - return Container( - margin: const EdgeInsets.only(bottom: 8), - child: InkWell( - onTap: - () => _navigateToSavePage( - vm.id, - vm.title, - parentProjectName: vm.parentTitle, - ), - 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: Variables.borderSubtle, width: 1), - ), - child: Row( - children: [ - // Cover Image - Container( - width: 56, - height: 56, - decoration: BoxDecoration( - color: Variables.surfaceSubtle, - 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) - Text( - vm.parentTitle!, - style: Variables.captionStyle.copyWith(fontSize: 12), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - Text( - vm.title, - style: Variables.bodyStyle.copyWith( - fontWeight: FontWeight.w500, - fontSize: 16, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - ], - ), - ), - ), - ); - } - - Widget _buildProjectGroup(ProjectGroup g) { - final project = g.project; - 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: Variables.borderSubtle), - ), - child: Column( - children: [ - // Parent Project - ListTile( - onTap: - () => - hasEvents - ? setState(() => g.isExpanded = !g.isExpanded) - : _navigateToSavePage(project.id!, 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( - project.title, - style: Variables.bodyStyle.copyWith( - fontWeight: FontWeight.w500, - fontSize: 16, - ), - ), - 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, - ), - - // Children (Events) - if (hasEvents) - AnimatedCrossFade( - firstChild: const SizedBox.shrink(), - secondChild: Container( - width: double.infinity, - color: Variables.surfaceSubtle, - child: Column( - children: - g.events.map((e) { - return ListTile( - onTap: () => _navigateToSavePage(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: Variables.bodyStyle.copyWith( - fontSize: 15, - fontWeight: FontWeight.w500, - ), - ), - ); - }).toList(), - ), - ), - crossFadeState: - g.isExpanded - ? CrossFadeState.showSecond - : CrossFadeState.showFirst, - duration: const Duration(milliseconds: 200), - ), - ], + body: ProjectSelector( + key: _selectorKey, + onProjectSelected: (id, title, parentTitle) { + _navigateToSavePage(id, title, parentProjectName: parentTitle); + }, ), ); } diff --git a/lib/ui/widgets/project_selector.dart b/lib/ui/widgets/project_selector.dart @@ -0,0 +1,525 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +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/ui/styles/variables.dart'; +import 'package:creekui/ui/widgets/search_bar.dart'; +import 'package:creekui/ui/widgets/section_header.dart'; +import 'package:creekui/ui/widgets/empty_state.dart'; + +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 ProjectSelector extends StatefulWidget { + final Function(int id, String title, String? parentTitle) onProjectSelected; + final String searchHint; + final ScrollController? scrollController; + + const ProjectSelector({ + super.key, + required this.onProjectSelected, + this.searchHint = "Search", + this.scrollController, + }); + + @override + State<ProjectSelector> createState() => _ProjectSelectorState(); +} + +class _ProjectSelectorState extends State<ProjectSelector> { + final ProjectRepo _projectRepo = ProjectRepo(); + final ImageRepo _imageRepo = ImageRepo(); + + final TextEditingController _searchController = TextEditingController(); + + List<ProjectItemViewModel> _recentViewModels = []; + List<ProjectGroup> _groupedProjects = []; + List<ProjectGroup> _filteredGroupedProjects = []; + + bool _isLoading = true; + String _searchQuery = ""; + + @override + void initState() { + super.initState(); + _loadData(); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + 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"); + } + return null; + } + + Future<void> _loadData() async { + if (!mounted) return; + setState(() => _isLoading = true); + + // 1. Fetch Raw Data + final recentItems = await _projectRepo.getRecentProjectsAndEvents(); + final allProjects = await _projectRepo.getAllProjects(); + + // 2. Build Recent View Models + final List<ProjectItemViewModel> recents = []; + for (var item in recentItems.take(3)) { + String? parentTitle; + if (item.parentId != null) { + final parent = await _projectRepo.getProjectById(item.parentId!); + parentTitle = parent?.title; + } + + final cover = await _getProjectCover(item.id!); + + recents.add( + ProjectItemViewModel( + item: item, + parentTitle: parentTitle, + coverPath: cover, + ), + ); + } + + // 3. Build Grouped Projects + final List<ProjectGroup> groups = []; + for (final p in allProjects) { + final rawEvents = await _projectRepo.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)); + } + + if (mounted) { + setState(() { + _recentViewModels = recents; + _groupedProjects = groups; + _filterProjects(_searchQuery); // Re-apply filter if any + _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; + } + }); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + return Column( + children: [ + // Search Bar + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: CommonSearchBar( + controller: _searchController, + onChanged: _filterProjects, + hintText: widget.searchHint, + ), + ), + + Expanded( + child: SingleChildScrollView( + controller: widget.scrollController, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Empty State + if (_filteredGroupedProjects.isEmpty && _searchQuery.isNotEmpty) + const Padding( + padding: EdgeInsets.all(32.0), + child: EmptyState( + icon: Icons.search_off, + title: "No results found", + subtitle: "Try adjusting your search", + ), + ), + + // Recents Section + if (_searchQuery.isEmpty && _recentViewModels.isNotEmpty) ...[ + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: SectionHeader(title: "Recent Projects/Events"), + ), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: + _recentViewModels + .map((vm) => _buildRecentItem(vm, theme, isDark)) + .toList(), + ), + ), + const SizedBox(height: 24), + ], + + // All Projects Section + if (_filteredGroupedProjects.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: SectionHeader( + title: + _searchQuery.isEmpty + ? "All Projects/Events" + : "Search Results", + ), + ), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: _filteredGroupedProjects.length, + itemBuilder: + (context, index) => _buildProjectGroup( + _filteredGroupedProjects[index], + theme, + isDark, + ), + ), + ), + ], + const SizedBox(height: 40), + ], + ), + ), + ), + ], + ); + } + + // Widgets + Widget _buildRecentItem( + ProjectItemViewModel vm, + ThemeData theme, + bool isDark, + ) { + return Container( + margin: const EdgeInsets.only(bottom: 8), + child: InkWell( + onTap: () => widget.onProjectSelected(vm.id, vm.title, vm.parentTitle), + borderRadius: BorderRadius.circular(Variables.radiusMedium), + child: Container( + padding: const EdgeInsets.fromLTRB(4, 4, 0, 4), + decoration: BoxDecoration( + color: theme.scaffoldBackgroundColor, + borderRadius: BorderRadius.circular(Variables.radiusMedium), + border: Border.all( + color: isDark ? Variables.borderDark : Variables.borderSubtle, + width: 1, + ), + ), + child: Row( + children: [ + // Cover Image + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: + isDark ? Variables.surfaceDark : Variables.surfaceSubtle, + borderRadius: BorderRadius.circular(Variables.radiusSmall), + image: + vm.coverPath != null + ? DecorationImage( + image: FileImage(File(vm.coverPath!)), + fit: BoxFit.cover, + ) + : null, + ), + child: + vm.coverPath == null + ? Icon( + Icons.image, + color: theme.colorScheme.onSurface.withValues( + alpha: 0.3, + ), + size: 28, + ) + : null, + ), + const SizedBox(width: 12), + // Text Content + 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: Variables.captionStyle.copyWith( + color: theme.colorScheme.onSurface.withValues( + alpha: 0.6, + ), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Text( + vm.title, + style: Variables.bodyStyle.copyWith( + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildProjectGroup(ProjectGroup g, ThemeData theme, bool isDark) { + final project = g.project; + final hasEvents = g.events.isNotEmpty; + + return Container( + margin: const EdgeInsets.only(bottom: 8), + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: theme.scaffoldBackgroundColor, + borderRadius: BorderRadius.circular(Variables.radiusMedium), + border: Border.all( + color: isDark ? Variables.borderDark : Variables.borderSubtle, + ), + ), + child: Column( + children: [ + // Parent Project + ListTile( + onTap: + () => + hasEvents + ? setState(() => g.isExpanded = !g.isExpanded) + : widget.onProjectSelected( + project.id!, + project.title, + null, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + visualDensity: VisualDensity.compact, + leading: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: isDark ? Variables.surfaceDark : Variables.surfaceSubtle, + borderRadius: BorderRadius.circular(Variables.radiusMedium), + image: + g.coverPath != null + ? DecorationImage( + image: FileImage(File(g.coverPath!)), + fit: BoxFit.cover, + ) + : null, + ), + child: + g.coverPath == null + ? Icon( + Icons.folder, + color: theme.colorScheme.onSurface.withValues( + alpha: 0.4, + ), + ) + : null, + ), + title: Text( + project.title, + style: Variables.bodyStyle.copyWith( + fontWeight: FontWeight.w600, + fontSize: 16, + color: theme.colorScheme.onSurface, + ), + ), + trailing: + hasEvents + ? IconButton( + icon: Icon( + g.isExpanded + ? Icons.keyboard_arrow_up + : Icons.keyboard_arrow_down, + color: theme.colorScheme.onSurface.withValues( + alpha: 0.6, + ), + ), + onPressed: () { + setState(() => g.isExpanded = !g.isExpanded); + }, + ) + : null, + ), + + // Children (Events) + if (hasEvents) + AnimatedCrossFade( + firstChild: const SizedBox.shrink(), + secondChild: Container( + width: double.infinity, + color: + isDark + ? Colors.black26 + : Variables.surfaceSubtle.withValues(alpha: 0.5), + child: Column( + children: + g.events.map((e) { + return ListTile( + onTap: + () => widget.onProjectSelected( + e.id, + e.title, + project.title, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 2, + ), + visualDensity: VisualDensity.compact, + leading: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: theme.cardColor, + borderRadius: BorderRadius.circular( + Variables.radiusSmall, + ), + border: Border.all( + color: + isDark + ? Variables.borderDark + : Variables.borderSubtle, + ), + image: + e.coverPath != null + ? DecorationImage( + image: FileImage(File(e.coverPath!)), + fit: BoxFit.cover, + ) + : null, + ), + child: + e.coverPath == null + ? Icon( + Icons.event, + size: 20, + color: theme.colorScheme.onSurface + .withValues(alpha: 0.4), + ) + : null, + ), + title: Text( + e.title, + style: Variables.bodyStyle.copyWith( + fontSize: 15, + fontWeight: FontWeight.w500, + color: theme.colorScheme.onSurface, + ), + ), + ); + }).toList(), + ), + ), + crossFadeState: + g.isExpanded + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + duration: const Duration(milliseconds: 200), + ), + ], + ), + ); + } +} diff --git a/lib/ui/widgets/section_header.dart b/lib/ui/widgets/section_header.dart @@ -1,51 +1,67 @@ import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:creekui/ui/styles/variables.dart'; class SectionHeader extends StatelessWidget { final String title; final VoidCallback? onTap; final Widget? trailing; + final bool showArrow; const SectionHeader({ super.key, required this.title, this.onTap, this.trailing, + this.showArrow = true, }); @override Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Row( - children: [ - Text( - title, - style: Variables.headerStyle.copyWith( - fontSize: 16, - color: theme.colorScheme.onSurface, + Widget content = Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + title, + style: const TextStyle( + fontFamily: 'GeneralSans', + fontSize: 16, + fontWeight: FontWeight.w500, + height: 24 / 16, + color: Variables.textPrimary, + ), + ), ), - ), - const Spacer(), - if (trailing != null) - trailing! - else if (onTap != null) - Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(20), - onTap: onTap, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Icon( - Icons.chevron_right, - size: 24, - color: theme.colorScheme.onSurface.withValues(alpha: 0.6), + if (trailing != null) + trailing! + else if (showArrow) + Transform.rotate( + angle: 3.14159, + child: SvgPicture.asset( + 'assets/icons/arrow-left-s-line.svg', + width: 24, + height: 24, + colorFilter: const ColorFilter.mode( + Variables.textPrimary, + BlendMode.srcIn, ), ), ), - ), - ], + ], + ), ); + + if (onTap != null) { + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: content, + ); + } + + return content; } } diff --git a/lib/ui/widgets/top_bar.dart b/lib/ui/widgets/top_bar.dart @@ -3,6 +3,8 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:creekui/data/models/project_model.dart'; import 'package:creekui/data/repos/project_repo.dart'; import 'package:creekui/ui/styles/variables.dart'; +import 'package:creekui/ui/pages/settings_page.dart'; +import 'package:creekui/ui/pages/home_page.dart'; class TopBar extends StatefulWidget implements PreferredSizeWidget { final int currentProjectId; @@ -70,7 +72,7 @@ class _TopBarState extends State<TopBar> { widget.currentProjectId, ); if (current == null) { - setState(() => _isLoading = false); + if (mounted) setState(() => _isLoading = false); return; } @@ -98,43 +100,69 @@ class _TopBarState extends State<TopBar> { } } catch (e) { debugPrint("TopBar Error: $e"); - setState(() => _isLoading = false); + if (mounted) setState(() => _isLoading = false); + } + } + + void _handleSafeBack() { + if (widget.onBack != null) { + widget.onBack!(); + return; + } + + if (Navigator.canPop(context)) { + Navigator.pop(context); + } else { + // Fallback: Go to HomePage to prevent app exit/crash + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (_) => const HomePage()), + ); + } + } + + void _openSettings() { + if (widget.onSettingsPressed != null) { + widget.onSettingsPressed!(); + } else { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const SettingsPage()), + ); } } @override Widget build(BuildContext context) { - final textScaler = MediaQuery.of(context).textScaler; return Container( - color: Colors.white, // White background + color: Colors.white, child: SafeArea( bottom: false, child: Column( mainAxisSize: MainAxisSize.min, children: [ - // First Row: Title and Settings + // Row 1: Title and Settings Container( - height: 48.0, // py-[12px] = 24px + 24px content + height: 48.0, padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Row( children: [ // Back Button - if (widget.onBack != null) ...[ - GestureDetector( - onTap: widget.onBack, - child: Container( - width: 24, - height: 24, - alignment: Alignment.center, - child: const Icon( - Icons.arrow_back, - size: 20, - color: Variables.textPrimary, - ), + GestureDetector( + onTap: _handleSafeBack, + child: Container( + width: 24, + height: 24, + alignment: Alignment.center, + child: const Icon( + Icons.arrow_back, + size: 20, + color: Variables.textPrimary, ), ), - const SizedBox(width: 8), - ], + ), + const SizedBox(width: 10), + // Title Expanded( child: @@ -145,7 +173,7 @@ class _TopBarState extends State<TopBar> { fontFamily: 'GeneralSans', fontSize: 20, fontWeight: FontWeight.w500, - height: 24 / 20, // line-height: 24px + height: 24 / 20, color: Variables.textPrimary, ), overflow: TextOverflow.ellipsis, @@ -166,10 +194,10 @@ class _TopBarState extends State<TopBar> { ) : const SizedBox(), ), - // Settings Icon (only show if second row is visible) + // Settings Icon if (!widget.hideSecondRow) GestureDetector( - onTap: widget.onSettingsPressed, + onTap: _openSettings, child: SvgPicture.asset( 'assets/icons/settings-line.svg', width: 24, @@ -183,10 +211,10 @@ class _TopBarState extends State<TopBar> { ], ), ), - // Second Row: Global Dropdown and Action Buttons + // Row 2: Global Dropdown and Action Buttons if (!widget.hideSecondRow) Container( - height: 40.0, // py-[8px] = 16px + 24px content + height: 40.0, padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Row( children: [ @@ -213,7 +241,7 @@ class _TopBarState extends State<TopBar> { vertical: 10, ), decoration: BoxDecoration( - color: Variables.borderSubtle, // #e4e4e7 + color: Variables.borderSubtle, borderRadius: BorderRadius.circular(1000), ), child: Row( @@ -265,7 +293,6 @@ class _TopBarState extends State<TopBar> { }).toList(); }, ), - // Layout Icon Button (Toggle between All Images/Categorized) or Edit Icon if (widget.onLayoutToggle != null || widget.onLayoutPressed != null) ...[ const SizedBox(width: 8), @@ -286,8 +313,7 @@ class _TopBarState extends State<TopBar> { ? (widget.isAlternateView == true ? Icons.dashboard : Icons.view_agenda_outlined) - : Icons - .edit, // Use edit icon when only onLayoutPressed is provided + : Icons.edit, size: 20, color: Variables.textPrimary, ), @@ -297,12 +323,12 @@ class _TopBarState extends State<TopBar> { ], ), const Spacer(), - // Right group: Filter and AI/Sparkle Buttons + // Right group: Filter and AI Buttons if (widget.onFilterPressed != null || widget.onAIPressed != null) Row( children: [ - // Filter Icon Button + // Filter Button if (widget.onFilterPressed != null) GestureDetector( onTap: widget.onFilterPressed, @@ -325,7 +351,7 @@ class _TopBarState extends State<TopBar> { if (widget.onFilterPressed != null && widget.onAIPressed != null) const SizedBox(width: 8), - // AI/Sparkle Icon Button + // AI Button if (widget.onAIPressed != null) GestureDetector( onTap: widget.onAIPressed,