creek

The AI Image Editor of 2030
commit a77485574f710776324dee39cfa23fb5a087095e
parent 113bf2aacbb84de7e90a89ef44090d27534a161d
Author: Sanjeebani Parida <83271316+sanjeebani14@users.noreply.github.com>
Date:   Tue,  2 Dec 2025 01:22:33 +0530

Merge pull request #29 from nilotpal-n7/sanjeebani

Sanjeebani
Diffstat:
Mlib/data/repos/file_repo.dart | 52++++++++++++++++++++++++++++++++++++++++++----------
Mlib/services/file_service.dart | 22++++++++++++++++++++++
Mlib/ui/pages/canvas_board_page.dart | 36++++++++++++++++++++++++++++++++++++
Mlib/ui/pages/create_file_page.dart | 710++++++++++++++++++++++++++-----------------------------------------------------
Mlib/ui/pages/project_file_page.dart | 849+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Mlib/ui/pages/share_to_file_page.dart | 468++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
6 files changed, 1197 insertions(+), 940 deletions(-)

diff --git a/lib/data/repos/file_repo.dart b/lib/data/repos/file_repo.dart @@ -8,6 +8,25 @@ class FileRepo { await db.insert('files', file.toMap()); } + // New method: Gets all files, regardless of project ID, ordered by last_updated + Future<List<FileModel>> getAllFiles() async { + final db = await AppDatabase.db; + final res = await db.query('files', orderBy: 'last_updated DESC'); + return res.map((e) => FileModel.fromMap(e)).toList(); + } + + // New method: Gets the most recent files across all projects, capped by limit + Future<List<FileModel>> getRecentFiles({int limit = 10}) async { + final db = await AppDatabase.db; + final res = await db.query( + 'files', + orderBy: 'last_updated DESC', + limit: limit, + ); + return res.map((e) => FileModel.fromMap(e)).toList(); + } + + // Retained original function for fetching files by a specific project ID Future<List<FileModel>> getFiles(int projectId) async { final db = await AppDatabase.db; final res = await db.query( @@ -70,25 +89,38 @@ class FileRepo { return res.map((e) => e['file_path'] as String).toList(); } - Future<FileModel?> getByFilePath(String path) async { + Future<List<FileModel>> getFilesForProjectAndEvents(int projectId) async { final db = await AppDatabase.db; + + // Fetch events of this project + final eventRows = await db.query( + 'projects', + where: 'parent_id = ?', + whereArgs: [projectId], + ); + + final eventIds = eventRows.map((e) => e['id'] as int).toList(); + final allIds = [projectId, ...eventIds]; + final res = await db.query( 'files', - where: 'file_path = ?', - whereArgs: [path], - limit: 1, + where: 'project_id IN (${List.filled(allIds.length, '?').join(',')})', + whereArgs: allIds, + orderBy: 'last_updated DESC', ); - if (res.isNotEmpty) return FileModel.fromMap(res.first); - return null; + + return res.map((e) => FileModel.fromMap(e)).toList(); } - Future<List<FileModel>> getRecentFiles({int limit = 10}) async { + Future<FileModel?> getByFilePath(String path) async { final db = await AppDatabase.db; final res = await db.query( 'files', - orderBy: 'last_updated DESC', - limit: limit, + where: 'file_path = ?', + whereArgs: [path], + limit: 1, ); - return res.map((e) => FileModel.fromMap(e)).toList(); + if (res.isNotEmpty) return FileModel.fromMap(res.first); + return null; } } diff --git a/lib/services/file_service.dart b/lib/services/file_service.dart @@ -37,10 +37,26 @@ class FileService { return id; } + // New method to get ALL files + Future<List<FileModel>> getAllFiles() async { + return await _repo.getAllFiles(); + } + + // New method to get recent files globally (limit 3 for the UI) + Future<List<FileModel>> getRecentFiles({int limit = 10}) async { + return await _repo.getRecentFiles(limit: limit); + } + + // Retained for backwards compatibility, though generally deprecated for a global file list view. + @Deprecated('Use getAllFiles() or getRecentFiles() for the file list page.') Future<List<FileModel>> getFiles(int projectId) async { return await _repo.getFiles(projectId); } + Future<List<FileModel>> getFilesForProjectAndEvents(int projectId) { + return _repo.getFilesForProjectAndEvents(projectId); + } + Future<void> openFile(String id) async { await _repo.touchFile(id); } @@ -67,4 +83,10 @@ class FileService { await _repo.deleteFile(id); } } + + Future<void> renameFile(String id, String newName) async { + await _repo.updateDetails(id, name: newName); + } + + } diff --git a/lib/ui/pages/canvas_board_page.dart b/lib/ui/pages/canvas_board_page.dart @@ -87,6 +87,7 @@ class CanvasBoardPage extends StatefulWidget { final double height; final File? initialImage; final FileModel? existingFile; + final File? injectedMedia; const CanvasBoardPage({ super.key, @@ -95,6 +96,7 @@ class CanvasBoardPage extends StatefulWidget { required this.height, this.initialImage, this.existingFile, + this.injectedMedia, }); @override @@ -172,6 +174,26 @@ class _CanvasBoardPageState extends State<CanvasBoardPage> { }); _hasUnsavedChanges = true; } + + // Inject shared image EXACTLY like gallery images + WidgetsBinding.instance.addPostFrameCallback((_) { + if (widget.injectedMedia != null && widget.existingFile != null) { + final oldState = _getCurrentState(); + setState(() { + elements.add({ + 'id': 'shared_${DateTime.now().millisecondsSinceEpoch}', + 'type': 'file_image', + 'content': widget.injectedMedia!.path, + 'position': const Offset(50, 50), + 'size': const Size(150, 150), // EXACT match + 'rotation': 0.0, + }); + _hasUnsavedChanges = true; + }); + _recordChange(oldState); + } + }); + } @override @@ -1469,6 +1491,20 @@ class _CanvasBoardPageState extends State<CanvasBoardPage> { } _hasUnsavedChanges = false; }); + if (widget.injectedMedia != null) { + final oldState = _getCurrentState(); + setState(() { + elements.add({ + 'id': 'shared_${DateTime.now().millisecondsSinceEpoch}', + 'type': 'file_image', + 'content': widget.injectedMedia!.path, + 'position': const Offset(50, 50), + 'size': const Size(150, 150), + 'rotation': 0.0, + }); + }); + _recordChange(oldState); + } } } catch (e) { debugPrint("Error loading canvas: $e"); diff --git a/lib/ui/pages/create_file_page.dart b/lib/ui/pages/create_file_page.dart @@ -12,9 +12,10 @@ import '../../services/image_service.dart'; import '../../data/models/project_model.dart'; class CreateFilePage extends StatefulWidget { - final File? file; // Made optional for blank canvas creation - final int projectId; - const CreateFilePage({super.key, this.file, this.projectId = 0}); + final File? file; + final int? projectId; // ⭐ MAKE NULLABLE + + const CreateFilePage({super.key, this.file, this.projectId}); @override State<CreateFilePage> createState() => _CreateFilePageState(); @@ -23,11 +24,11 @@ class CreateFilePage extends StatefulWidget { class _CreateFilePageState extends State<CreateFilePage> { final TextEditingController _searchController = TextEditingController(); - // Project Selection State + // Project Selection int? _selectedProjectId; String _selectedProjectTitle = "Select Project"; - // Master list of presets + // Presets final List<CanvasPreset> _allPresets = [ CanvasPreset( name: 'Custom', @@ -95,7 +96,7 @@ class _CreateFilePageState extends State<CreateFilePage> { super.initState(); _filteredPresets = _allPresets; - // Initialize from passed project ID + // ⭐ If an existing projectId was passed, lock to that project _selectedProjectId = widget.projectId; if (_selectedProjectId != null) { _selectedProjectTitle = "Current Project"; @@ -108,55 +109,76 @@ class _CreateFilePageState extends State<CreateFilePage> { super.dispose(); } - void _runFilter(String enteredKeyword) { - List<CanvasPreset> results = []; - if (enteredKeyword.isEmpty) { - results = _allPresets; - } else { - results = + void _runFilter(String keyword) { + if (keyword.isEmpty) { + setState(() => _filteredPresets = _allPresets); + return; + } + + setState(() { + _filteredPresets = _allPresets .where( - (preset) => preset.name.toLowerCase().contains( - enteredKeyword.toLowerCase(), - ), + (p) => p.name.toLowerCase().contains(keyword.toLowerCase()), ) .toList(); - } - setState(() { - _filteredPresets = results; }); } + // ⭐ Select project (only for ShareToFilePage flow) 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); - }, - ), - ), - ), + isScrollControlled: true, + builder: (_) { + return DraggableScrollableSheet( + initialChildSize: 0.85, + maxChildSize: 0.95, + minChildSize: 0.5, + builder: (_, controller) { + return 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); + }, + ), + ); + }, + ); + }, + ); + } + + // ⭐ Go to canvas with selected project + void _navigateToEditor(int width, int height) { + if (_selectedProjectId == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("Please select a destination project")), + ); + return; + } + + Navigator.push( + context, + MaterialPageRoute( + builder: + (_) => CanvasBoardPage( + projectId: _selectedProjectId!, // ⭐ use selected project + width: width.toDouble(), + height: height.toDouble(), + initialImage: widget.file, + ), + ), ); } @@ -174,27 +196,28 @@ class _CreateFilePageState extends State<CreateFilePage> { title: const Text( 'Create Files', style: TextStyle( - color: Colors.black, - fontWeight: FontWeight.w600, fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.black, ), ), + + // ⭐ Show project chooser ONLY when projectId was NOT passed actions: [ - // ONLY show selection button if projectId was NOT passed in if (widget.projectId == null) Padding( - padding: const EdgeInsets.only(right: 16.0), + padding: const EdgeInsets.only(right: 16), 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, ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), ), icon: Icon( _selectedProjectId == null @@ -206,21 +229,20 @@ class _CreateFilePageState extends State<CreateFilePage> { label: Text( _selectedProjectTitle, style: const TextStyle( - color: Colors.black, fontSize: 12, fontWeight: FontWeight.w500, + color: Colors.black, ), - maxLines: 1, - overflow: TextOverflow.ellipsis, ), ), ), ], ), + body: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Search Bar + // Search Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), child: Container( @@ -233,7 +255,6 @@ class _CreateFilePageState extends State<CreateFilePage> { onChanged: _runFilter, decoration: InputDecoration( hintText: 'Search sizes', - hintStyle: TextStyle(color: Colors.grey[400], fontSize: 14), prefixIcon: Icon( Icons.search, color: Colors.grey[400], @@ -263,7 +284,6 @@ class _CreateFilePageState extends State<CreateFilePage> { ), ), - // Label const Padding( padding: EdgeInsets.fromLTRB(16, 0, 16, 8), child: Text( @@ -276,27 +296,6 @@ class _CreateFilePageState extends State<CreateFilePage> { ), ), - // Tabs - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - _buildTab('All', isSelected: true), - const SizedBox(width: 12), - _buildTab('Saved', isSelected: false), - const SizedBox(width: 12), - _buildTab('Photo', isSelected: false), - const SizedBox(width: 12), - _buildTab('Print', isSelected: false), - ], - ), - ), - ), - - const SizedBox(height: 16), - // Grid Expanded( child: GridView.builder( @@ -308,9 +307,7 @@ class _CreateFilePageState extends State<CreateFilePage> { childAspectRatio: 0.75, ), itemCount: _filteredPresets.length, - itemBuilder: (context, index) { - return _buildPresetCard(_filteredPresets[index]); - }, + itemBuilder: (_, i) => _buildPresetCard(_filteredPresets[i]), ), ), ], @@ -318,34 +315,16 @@ class _CreateFilePageState extends State<CreateFilePage> { ); } - Widget _buildTab(String label, {required bool isSelected}) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), - decoration: BoxDecoration( - color: isSelected ? Colors.white : Colors.transparent, - borderRadius: BorderRadius.circular(20), - ), - child: Text( - label, - style: TextStyle( - color: isSelected ? Colors.black : Colors.grey, - fontSize: 13, - fontWeight: isSelected ? FontWeight.w500 : FontWeight.normal, - ), - ), - ); - } - + // Card widget Widget _buildPresetCard(CanvasPreset preset) { final bool isCustom = preset.name == 'Custom'; return InkWell( onTap: () { - if (isCustom) { - _navigateToEditor(1000, 1000); - } else { - _navigateToEditor(preset.width, preset.height); - } + _navigateToEditor( + isCustom ? 1000 : preset.width, + isCustom ? 1000 : preset.height, + ); }, borderRadius: BorderRadius.circular(12), child: Container( @@ -355,12 +334,10 @@ class _CreateFilePageState extends State<CreateFilePage> { ), padding: const EdgeInsets.all(8), child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Container( - width: double.infinity, decoration: BoxDecoration( color: const Color(0xFFE0E7FF), borderRadius: BorderRadius.circular(8), @@ -368,40 +345,30 @@ class _CreateFilePageState extends State<CreateFilePage> { child: Center( child: isCustom - ? const Icon(Icons.add, size: 30, color: Colors.blue) + ? const Icon(Icons.add, color: Colors.blue, size: 30) : Padding( - padding: const EdgeInsets.all(12.0), + padding: const EdgeInsets.all(12), child: AspectRatio( - aspectRatio: - (preset.width > 0 && preset.height > 0) - ? preset.width / preset.height - : 1.0, + aspectRatio: preset.width / preset.height, child: Container( decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(4), boxShadow: [ BoxShadow( - color: Colors.black.withValues( - alpha: 0.05, - ), + color: Colors.black.withOpacity(0.05), blurRadius: 4, offset: const Offset(0, 2), ), ], ), - child: Center( - child: - preset.svgPath != null - ? Padding( - padding: const EdgeInsets.all(4.0), - child: SvgPicture.asset( - preset.svgPath!, - fit: BoxFit.contain, - ), - ) - : null, - ), + child: + preset.svgPath != null + ? SvgPicture.asset( + preset.svgPath!, + fit: BoxFit.contain, + ) + : null, ), ), ), @@ -409,56 +376,25 @@ class _CreateFilePageState extends State<CreateFilePage> { ), ), const SizedBox(height: 8), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - preset.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontWeight: FontWeight.w600, - fontSize: 11, - color: Colors.black, - ), - ), - const SizedBox(height: 2), - Text( - preset.displaySize, - style: const TextStyle(color: Colors.grey, fontSize: 9), - ), - ], + Text( + preset.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 11, + color: Colors.black, + ), + ), + Text( + preset.displaySize, + style: const TextStyle(color: Colors.grey, fontSize: 9), ), ], ), ), ); } - - 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( - projectId: widget.projectId, - // Pass the dimensions from the preset - width: width.toDouble(), - height: height.toDouble(), - initialImage: widget.file, - ), - ), - ); - } } class CanvasPreset { @@ -478,7 +414,7 @@ class CanvasPreset { } // -------------------------------------------------------------------------- -// --- PROJECT SELECTION MODAL --- +// --- PROJECT SELECTION MODAL ---------------------------------------------- // -------------------------------------------------------------------------- class ProjectItemViewModel { @@ -531,6 +467,7 @@ class _ProjectSelectionModalState extends State<ProjectSelectionModal> { bool _isLoading = true; String _searchQuery = ""; + final TextEditingController _searchController = TextEditingController(); @override @@ -551,21 +488,19 @@ class _ProjectSelectionModalState extends State<ProjectSelectionModal> { if (images.isNotEmpty) { return images.first.filePath; } - } catch (e) { - debugPrint("Error fetching cover for project $projectId: $e"); - } + } catch (_) {} return null; } Future<void> _loadData() async { setState(() => _isLoading = true); - final recentItems = await _projectService.getRecentProjectsAndEvents(); + final recent = await _projectService.getRecentProjectsAndEvents(); final allProjects = await _projectService.getAllProjects(); - // Build Recent View Models + // Build Recent final List<ProjectItemViewModel> recents = []; - for (var item in recentItems.take(3)) { + for (var item in recent.take(3)) { String? parentTitle; if (item.parentId != null) { final parent = await _projectService.getProjectById(item.parentId!); @@ -582,17 +517,17 @@ class _ProjectSelectionModalState extends State<ProjectSelectionModal> { } _recentViewModels = recents; - // Build Grouped Projects + // Build groups 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 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 pCover = await _getProjectCover(p.id!); - groups.add(ProjectGroup(project: p, events: eventVMs, coverPath: pCover)); + final cover = await _getProjectCover(p.id!); + groups.add(ProjectGroup(project: p, events: events, coverPath: cover)); } _groupedProjects = groups; @@ -604,60 +539,53 @@ class _ProjectSelectionModalState extends State<ProjectSelectionModal> { 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, - ), - ); - } + 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; } + + _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 - ), - ), + MaterialPageRoute(builder: (_) => const DefineBrandPage(projectName: "")), ); - // 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); - } + widget.onProjectSelected(result["id"], result["title"]); } } @@ -665,12 +593,11 @@ class _ProjectSelectionModalState extends State<ProjectSelectionModal> { Widget build(BuildContext context) { return Column( children: [ - // Header handle Center( child: Container( - margin: const EdgeInsets.only(top: 12, bottom: 8), width: 40, height: 4, + margin: const EdgeInsets.only(top: 12, bottom: 8), decoration: BoxDecoration( color: Colors.grey[300], borderRadius: BorderRadius.circular(2), @@ -678,109 +605,72 @@ class _ProjectSelectionModalState extends State<ProjectSelectionModal> { ), ), - // Header Row + // Header row Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text( "Select Destination", - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - fontFamily: 'GeneralSans', - ), + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), IconButton( icon: const Icon(Icons.add), onPressed: _createNewProject, - tooltip: "Create New Project", ), ], ), ), - // Search Bar + // 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, - ), + child: TextField( + controller: _searchController, + onChanged: _filterProjects, + decoration: InputDecoration( + hintText: "Search Projects", + prefixIcon: const Icon(Icons.search, size: 20), + filled: true, + fillColor: const Color(0xFFE4E4E7), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, ), ), ), ), - Divider(color: Colors.grey[200]), + Divider(color: Colors.grey[300]), - // 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) ...[ + if (_searchQuery.isEmpty && _recentViewModels.isNotEmpty) const Padding( - padding: EdgeInsets.only(bottom: 8, top: 8), + padding: EdgeInsets.only(left: 16, bottom: 8), child: Text( "Recent Projects/Events", - style: TextStyle( - fontSize: 14, - fontFamily: 'GeneralSans', - color: Color(0xFF27272A), - fontWeight: FontWeight.w400, - ), + style: TextStyle(fontSize: 14), ), ), - ..._recentViewModels.map((vm) => _buildRecentItem(vm)), - const SizedBox(height: 16), - ], + if (_searchQuery.isEmpty) + ..._recentViewModels.map(_buildRecentItem), - Padding( - padding: const EdgeInsets.only(bottom: 8), + const Padding( + padding: EdgeInsets.only(left: 16, top: 8, bottom: 8), child: Text( - _searchQuery.isEmpty - ? "All Projects/Events" - : "Search Results", - style: const TextStyle( - fontSize: 14, - fontFamily: 'GeneralSans', - color: Color(0xFF27272A), - fontWeight: FontWeight.w400, - ), + "All Projects/Events", + style: TextStyle(fontSize: 14), ), ), - ..._filteredGroupedProjects.map( - (g) => _buildProjectGroup(g), - ), - const SizedBox(height: 40), + + ..._filteredGroupedProjects.map(_buildProjectGroup), ], ), ), @@ -789,211 +679,79 @@ class _ProjectSelectionModalState extends State<ProjectSelectionModal> { } 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( - 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, - ), - ], - ), - ), - ], - ), - ), + 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), - 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), - ), - ), + onTap: () { + if (hasEvents) { + setState(() => g.isExpanded = !g.isExpanded); + } else { + widget.onProjectSelected(g.project.id!, g.project.title); + } + }, + leading: _thumbnail(g.coverPath), + title: Text(g.project.title), 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); - }, + ? Icon( + g.isExpanded + ? Icons.keyboard_arrow_up + : Icons.keyboard_arrow_down, ) : 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(), - ), + 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(), ), - crossFadeState: - g.isExpanded - ? CrossFadeState.showSecond - : CrossFadeState.showFirst, - duration: const Duration(milliseconds: 200), ), ], ), ); } } + diff --git a/lib/ui/pages/project_file_page.dart b/lib/ui/pages/project_file_page.dart @@ -1,12 +1,17 @@ import 'dart:io'; +import 'dart:convert'; import 'package:flutter/material.dart'; -import 'package:image_picker/image_picker.dart'; import 'package:intl/intl.dart'; + import '../../data/models/file_model.dart'; +import '../../data/models/project_model.dart'; import '../../services/file_service.dart'; +import '../../data/repos/project_repo.dart'; import '../widgets/bottom_bar.dart'; import 'create_file_page.dart'; -import 'canvas_board_page.dart'; // [IMPORTANT] Import this to open the canvas +import 'canvas_board_page.dart'; + +import 'package:image/image.dart' as img; class ProjectFilePage extends StatefulWidget { final int projectId; @@ -19,273 +24,529 @@ class ProjectFilePage extends StatefulWidget { class _ProjectFilePageState extends State<ProjectFilePage> { final _fileService = FileService(); - final _imagePicker = ImagePicker(); + final _projectRepo = ProjectRepo(); + + List<FileModel> _allFiles = []; + List<ProjectModel> _events = []; + List<FileModel> _eventFiles = []; + + Map<String, Map<String, String>> _fileMetadata = {}; - List<FileModel> _files = []; + ProjectModel? _selectedEvent; bool _isLoading = true; + String _search = ''; @override void initState() { super.initState(); - _loadData(); + _loadEverything(); } - Future<void> _loadData() async { + // --------------------------------------- + // LOAD EVERYTHING + // --------------------------------------- + Future<void> _loadEverything() async { setState(() => _isLoading = true); try { - final files = await _fileService.getFiles(widget.projectId); - if (mounted) { - setState(() { - _files = files; - _isLoading = false; - }); + _events = await _projectRepo.getEvents(widget.projectId); + + _allFiles = await _fileService.getFilesForProjectAndEvents( + widget.projectId, + ); + + await _loadMetadata(_allFiles); + + if (_events.isNotEmpty) { + _selectedEvent = _events.first; + _eventFiles = await _fileService.getFiles(_selectedEvent!.id!); + await _loadMetadata(_eventFiles); } } catch (e) { - debugPrint('Error loading files: $e'); - if (mounted) setState(() => _isLoading = false); + debugPrint("Error loading project page: $e"); } + + if (mounted) setState(() => _isLoading = false); } - // --- ACTIONS --- + // --------------------------------------- + // LOAD METADATA (Like HomePage) + // --------------------------------------- + Future<void> _loadMetadata(List<FileModel> list) async { + for (final file in list) { + try { + final f = File(file.filePath); + if (!await f.exists()) continue; - void _navigateToCreateFile() { + if (file.filePath.toLowerCase().endsWith(".json")) { + final content = await f.readAsString(); + final data = jsonDecode(content); + + String dims = "Unknown"; + String preview = ""; + + if (data is Map) { + if (data["width"] != null && data["height"] != null) { + dims = "${data["width"]} x ${data["height"]} px"; + } + if (data["preview_path"] != null) { + preview = data["preview_path"]; + } + } + + // Fix relative preview path + if (preview.isNotEmpty && !File(preview).existsSync()) { + final base = f.parent.path; + final candidate = "$base/$preview"; + if (File(candidate).existsSync()) preview = candidate; + } + + _fileMetadata[file.id] = {"preview": preview, "dimensions": dims}; + } else { + final bytes = await f.readAsBytes(); + final decoded = img.decodeImage(bytes); + + String dims = "Unknown"; + if (decoded != null) { + dims = "${decoded.width} x ${decoded.height} px"; + } + + _fileMetadata[file.id] = { + "preview": file.filePath, + "dimensions": dims, + }; + } + } catch (_) {} + } + } + + // --------------------------------------- + // SELECT EVENT + // --------------------------------------- + Future<void> _onSelectEvent(ProjectModel event) async { + setState(() => _selectedEvent = event); + _eventFiles = await _fileService.getFiles(event.id!); + await _loadMetadata(_eventFiles); + setState(() {}); + } + + // --------------------------------------- + // OPEN FILE + // --------------------------------------- + void _openFile(FileModel file) { Navigator.push( context, MaterialPageRoute( builder: - (_) => CreateFilePage( - // Pass the current projectId so the file is saved to THIS project - projectId: widget.projectId, + (_) => CanvasBoardPage( + projectId: file.projectId, + width: 1080, + height: 1920, + existingFile: file, ), ), - ).then((_) => _loadData()); - } - - void _openFile(FileModel file) { - // Check if it's a Canvas file (JSON) - if (file.filePath.toLowerCase().endsWith('.json')) { - Navigator.push( - context, - MaterialPageRoute( - builder: - (context) => CanvasBoardPage( - projectId: widget.projectId, - // Use default dimensions for viewing; the canvas content defines the actual size - width: 1080, - height: 1920, - existingFile: file, // [KEY FIX] Loads the saved canvas - ), - ), - ).then((_) => _loadData()); - } else { - // It's a regular image - // You can add navigation to an Image Viewer page here if you have one - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text("Image preview coming soon")), - ); - } + ); } - Future<void> _pickAndAddFile() async { - try { - final XFile? pickedFile = await _imagePicker.pickImage( - source: ImageSource.gallery, - ); + void _handleFileMenuAction(FileModel file, String action) { + switch (action) { + case "open": + _openFile(file); + break; - if (pickedFile != null && mounted) { - // Navigate to CreateFilePage with the selected image - await Navigator.push( - context, - MaterialPageRoute( - builder: - (_) => CreateFilePage( - file: File(pickedFile.path), - projectId: widget.projectId, // PASS PROJECT ID - ), - ), - ); + case "rename": + _renameFile(file); + break; - // Refresh the list after returning from the creation flow - if (mounted) { - _loadData(); - } - } - } catch (e) { - debugPrint("Error picking file: $e"); + case "delete": + _deleteFile(file); + break; } } - Future<void> _showAddFileDialog(File file) async { - final nameController = TextEditingController( - text: file.path.split('/').last, - ); - final descriptionController = TextEditingController(); - - if (!mounted) return; + Future<void> _renameFile(FileModel file) async { + final controller = TextEditingController(text: file.name); - await showDialog( + final newName = await showDialog<String>( context: context, - builder: - (context) => AlertDialog( - title: const Text( - "Save File", - style: TextStyle( - fontFamily: 'GeneralSans', - fontWeight: FontWeight.w600, - ), + builder: (context) { + return AlertDialog( + title: const Text("Rename File"), + content: TextField( + controller: controller, + autofocus: true, + decoration: const InputDecoration( + labelText: "New file name", + border: OutlineInputBorder(), ), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: nameController, - decoration: const InputDecoration( - labelText: "File Name", - border: OutlineInputBorder(), - ), - style: const TextStyle(fontFamily: 'GeneralSans'), - ), - const SizedBox(height: 16), - TextField( - controller: descriptionController, - decoration: const InputDecoration( - labelText: "Description (optional)", - border: OutlineInputBorder(), - ), - maxLines: 2, - style: const TextStyle(fontFamily: 'GeneralSans'), - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text("Cancel"), - ), - ElevatedButton( - onPressed: () async { - if (nameController.text.trim().isNotEmpty) { - try { - await _fileService.saveFile( - file, - widget.projectId, - name: nameController.text.trim(), - description: - descriptionController.text.trim().isEmpty - ? null - : descriptionController.text.trim(), - ); - if (context.mounted) { - Navigator.pop(context); - _loadData(); - } - } catch (e) { - debugPrint("Error saving file: $e"); - } - } - }, - child: const Text("Save"), - ), - ], ), + actions: [ + TextButton( + child: const Text("Cancel"), + onPressed: () => Navigator.pop(context), + ), + FilledButton( + child: const Text("Save"), + onPressed: () => Navigator.pop(context, controller.text.trim()), + ), + ], + ); + }, ); + + if (newName == null || newName.isEmpty) return; + + // ✔ Update DB + await _fileService.renameFile(file.id, newName); + + // ✔ Reload UI + await _loadEverything(); } - Future<void> _deleteFile(String fileId) async { + + Future<void> _deleteFile(FileModel file) async { final confirm = await showDialog<bool>( context: context, builder: - (context) => AlertDialog( + (_) => AlertDialog( title: const Text("Delete File?"), - content: const Text("This action cannot be undone."), + content: Text("This will permanently remove ${file.name}."), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), child: const Text("Cancel"), ), - TextButton( + FilledButton( onPressed: () => Navigator.pop(context, true), - style: TextButton.styleFrom(foregroundColor: Colors.red), child: const Text("Delete"), ), ], ), ); - if (confirm == true) { - await _fileService.deleteFile(fileId); - _loadData(); + if (confirm != true) return; + + try { + await _fileService.deleteFile(file.id!); + + final disk = File(file.filePath); + if (await disk.exists()) await disk.delete(); + + setState(() { + _allFiles.removeWhere((f) => f.id == file.id); + _eventFiles.removeWhere((f) => f.id == file.id); + }); + + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text("File deleted"))); + } catch (e) { + debugPrint("Delete error: $e"); } } + + // --------------------------------------- + // CREATE FILE + // --------------------------------------- + void _navigateToCreateFile() { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => CreateFilePage(projectId: widget.projectId), + ), + ).then((_) => _loadEverything()); + } + + // --------------------------------------- + // FILE CARD (Same UI but thumbnail updated) + // --------------------------------------- + Widget _fileCard(FileModel file) { + final date = DateFormat.yMMMd().format(file.lastUpdated); + final meta = _fileMetadata[file.id] ?? {}; + final preview = meta["preview"] ?? ""; + final realPreview = + preview.isNotEmpty && File(preview).existsSync() + ? preview + : file.filePath; + + return GestureDetector( + onTap: () => _openFile(file), + child: Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE4E4E7)), + ), + child: Row( + children: [ + // --------------------------------------- + // ✔ NEW THUMBNAIL SIZE (HomePage style) + // --------------------------------------- + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Colors.grey[300], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.file( + File(realPreview), + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => const Icon(Icons.image), + ), + ), + ), + + const SizedBox(width: 12), + + // --------------------------------------- + // TEXT INFO (unchanged UI) + // --------------------------------------- + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _breadcrumbFor(file), + style: TextStyle( + fontSize: 11, + color: Colors.grey[600], + fontFamily: 'GeneralSans', + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + + const SizedBox(height: 2), + + Text( + file.name, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + fontFamily: 'GeneralSans', + ), + ), + + const SizedBox(height: 2), + + Text( + "Edited $date", + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + fontFamily: 'GeneralSans', + ), + ), + ], + ), + ), + + const SizedBox(width: 8), + PopupMenuButton<String>( + onSelected: (value) => _handleFileMenuAction(file, value), + itemBuilder: + (context) => [ + const PopupMenuItem(value: "open", child: Text("Open")), + const PopupMenuItem(value: "rename", child: Text("Rename")), + const PopupMenuItem(value: "delete", child: Text("Delete")), + ], + icon: const Icon(Icons.more_vert, size: 20), + ), + + ], + ), + ), + ); + } + + // --------------------------------------- + // BREADCRUMB + // --------------------------------------- + String _breadcrumbFor(FileModel file) { + final event = _events.firstWhere( + (e) => e.id == file.projectId, + orElse: + () => ProjectModel( + id: widget.projectId, + title: "", + lastAccessedAt: DateTime.now(), + createdAt: DateTime.now(), + ), + ); + + if (event.parentId == null) return ""; + + final parent = _events.firstWhere( + (e) => e.id == event.parentId, + orElse: + () => ProjectModel( + id: widget.projectId, + title: "", + lastAccessedAt: DateTime.now(), + createdAt: DateTime.now(), + ), + ); + + return "${parent.title} / ${event.title}"; + } + + // --------------------------------------- + // UI + // --------------------------------------- @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final isDark = theme.brightness == Brightness.dark; - return Scaffold( - backgroundColor: theme.scaffoldBackgroundColor, + backgroundColor: const Color(0xFFF7F7F8), appBar: AppBar( - title: const Text("Project Files"), - backgroundColor: theme.appBarTheme.backgroundColor, - actions: [ - IconButton( - icon: const Icon(Icons.add), - tooltip: "Create New File", - onPressed: _navigateToCreateFile, + backgroundColor: const Color(0xFFF7F7F8), + elevation: 0, + title: const Text( + "Project Files", + style: TextStyle( + fontFamily: 'GeneralSans', + fontWeight: FontWeight.w500, + color: Color(0xFF27272A), ), - ], + ), ), + bottomNavigationBar: BottomBar( currentTab: BottomBarItem.files, projectId: widget.projectId, ), - floatingActionButton: FloatingActionButton( - onPressed: _pickAndAddFile, - child: const Icon(Icons.upload_file), + + floatingActionButton: GestureDetector( + onTap: _navigateToCreateFile, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + decoration: BoxDecoration( + color: const Color(0xFF27272A), + borderRadius: BorderRadius.circular(50), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: const [ + Text( + "Create File", + style: TextStyle(color: Colors.white, fontSize: 14), + ), + SizedBox(width: 8), + Icon(Icons.add, color: Colors.white), + ], + ), + ), ), + body: _isLoading ? const Center(child: CircularProgressIndicator()) : RefreshIndicator( - onRefresh: _loadData, + onRefresh: _loadEverything, child: SingleChildScrollView( physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.symmetric(horizontal: 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - _buildSectionHeader("Files", theme), - Text( - "${_files.length} items", - style: TextStyle( - color: theme.colorScheme.onSurface.withOpacity( - 0.6, - ), - fontFamily: 'GeneralSans', + const SizedBox(height: 12), + + _buildSearchBar(), + + const SizedBox(height: 24), + + // ------------------------- + // ALL FILES + // ------------------------- + const Text( + "All Files", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + fontFamily: 'GeneralSans', + color: Color(0xFF27272A), + ), + ), + + const SizedBox(height: 12), + + if (_filteredAllFiles().isEmpty) + Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Text( + "No files found", + style: TextStyle(color: Colors.grey[500]), ), ), - ], + ) + else + Column( + children: + _filteredAllFiles() + .map((f) => _fileCard(f)) + .toList(), + ), + + const SizedBox(height: 32), + + // ------------------------- + // FILES FOR EVENTS + // ------------------------- + const Text( + "Files for Events", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + fontFamily: 'GeneralSans', + color: Color(0xFF27272A), + ), ), - const SizedBox(height: 16), - if (_files.isEmpty) - _buildEmptyState(isDark) + + const SizedBox(height: 12), + + if (_events.isEmpty) + Container( + padding: const EdgeInsets.all(32), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(12), + ), + child: Text( + "No events yet", + style: TextStyle(color: Colors.grey[500]), + ), + ) else - ListView.separated( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: _files.length, - separatorBuilder: - (_, __) => const SizedBox(height: 12), - itemBuilder: (context, index) { - final file = _files[index]; - return _buildFileCard(file, theme, isDark); - }, + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ----------------------- + // DROPDOWN ONLY + // ----------------------- + _buildEventDropdown(), + + const SizedBox(height: 16), + + _eventFiles.isEmpty + ? Text( + "No files in this event yet", + style: TextStyle(color: Colors.grey[500]), + ) + : Column( + children: + _eventFiles + .map((f) => _fileCard(f)) + .toList(), + ), + ], ), - const SizedBox(height: 40), + + const SizedBox(height: 100), ], ), ), @@ -293,168 +554,72 @@ class _ProjectFilePageState extends State<ProjectFilePage> { ); } - Widget _buildEmptyState(bool isDark) { - return Container( - width: double.infinity, - padding: const EdgeInsets.all(32), - decoration: BoxDecoration( - color: isDark ? Colors.grey[850] : Colors.grey[100], - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: isDark ? Colors.grey[800]! : Colors.grey[300]!, + // --------------------------------------- + // SEARCH + // --------------------------------------- + Widget _buildSearchBar() { + return TextField( + onChanged: (v) => setState(() => _search = v.trim()), + decoration: InputDecoration( + filled: true, + fillColor: Colors.grey[200], + hintText: "Search your files", + prefixIcon: Icon(Icons.search, color: Colors.grey[600]), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, ), ), - child: Column( - children: [ - Icon(Icons.folder_open, size: 48, color: Colors.grey[400]), - const SizedBox(height: 8), - Text("No files added yet", style: TextStyle(color: Colors.grey[500])), - ], - ), ); } - Widget _buildSectionHeader(String title, ThemeData theme) { - return Text( - title, - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - fontFamily: 'GeneralSans', - color: theme.colorScheme.onSurface, + // --------------------------------------- + // DROPDOWN (ONLY — add event removed) + // --------------------------------------- + Widget _buildEventDropdown() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: Colors.grey[200], + borderRadius: BorderRadius.circular(6), ), - ); - } - - Widget _buildFileCard(FileModel file, ThemeData theme, bool isDark) { - final dateStr = DateFormat.yMMMd().format(file.lastUpdated); - final isCanvas = file.filePath.toLowerCase().endsWith('.json'); - - return InkWell( - onTap: () => _openFile(file), // [KEY FIX] Make the card clickable - borderRadius: BorderRadius.circular(16), - child: Container( - decoration: BoxDecoration( - color: isDark ? Colors.grey[850] : Colors.white, - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: isDark ? Colors.grey[700]! : Colors.grey[300]!, - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.03), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], - ), - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Icon based on type - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: (isCanvas - ? Colors.orange - : theme.colorScheme.primary) - .withOpacity(0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Icon( - isCanvas ? Icons.brush : Icons.insert_drive_file, - color: isCanvas ? Colors.orange : theme.colorScheme.primary, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - file.name, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - fontFamily: 'GeneralSans', - ), + child: DropdownButton<ProjectModel>( + value: _selectedEvent, + items: + _events + .map( + (e) => DropdownMenuItem( + value: e, + child: Text( + e.title, + style: const TextStyle( + fontFamily: 'GeneralSans', + fontSize: 14, ), - const SizedBox(height: 4), - Text( - "Updated $dateStr", - style: TextStyle( - fontSize: 12, - color: theme.colorScheme.onSurface.withOpacity(0.5), - fontFamily: 'GeneralSans', - ), - ), - ], + ), ), - ), - PopupMenuButton<String>( - icon: const Icon(Icons.more_vert), - onSelected: (value) { - if (value == 'delete') { - _deleteFile(file.id); - } else if (value == 'open') { - _openFile(file); - } - }, - itemBuilder: - (context) => [ - const PopupMenuItem(value: 'open', child: Text("Open")), - const PopupMenuItem( - value: 'delete', - child: Text( - "Delete", - style: TextStyle(color: Colors.red), - ), - ), - ], - ), - ], - ), - if (file.description != null && file.description!.isNotEmpty) ...[ - const SizedBox(height: 12), - Text( - file.description!, - style: TextStyle( - fontSize: 13, - color: theme.colorScheme.onSurface.withOpacity(0.7), - fontFamily: 'GeneralSans', - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ], - if (file.tags.isNotEmpty) ...[ - const SizedBox(height: 12), - Wrap( - spacing: 8, - children: - file.tags - .map( - (tag) => Chip( - label: Text( - tag, - style: const TextStyle(fontSize: 10), - ), - materialTapTargetSize: - MaterialTapTargetSize.shrinkWrap, - visualDensity: VisualDensity.compact, - ), - ) - .toList(), - ), - ], - ], - ), + ) + .toList(), + onChanged: (e) { + if (e != null) _onSelectEvent(e); + }, + underline: const SizedBox(), + isExpanded: true, ), ); } + + // --------------------------------------- + // FILTER + // --------------------------------------- + List<FileModel> _filteredAllFiles() { + if (_search.isEmpty) return _allFiles; + return _allFiles + .where( + (f) => + f.name.toLowerCase().contains(_search.toLowerCase()) || + _breadcrumbFor(f).toLowerCase().contains(_search.toLowerCase()), + ) + .toList(); + } } diff --git a/lib/ui/pages/share_to_file_page.dart b/lib/ui/pages/share_to_file_page.dart @@ -1,12 +1,18 @@ import 'dart:io'; +import 'dart:convert'; + import 'package:flutter/material.dart'; +import 'package:image/image.dart' as img; + import '../../services/file_service.dart'; +import '../../services/project_service.dart'; import '../../data/models/file_model.dart'; -import 'create_file_page.dart'; // Import the new creation page +import '../../data/models/project_model.dart'; +import 'create_file_page.dart'; +import 'canvas_board_page.dart'; class ShareToFilePage extends StatefulWidget { - final File sharedImage; // This is the image passed from ShareHandler - + final File sharedImage; const ShareToFilePage({super.key, required this.sharedImage}); @override @@ -15,11 +21,20 @@ class ShareToFilePage extends StatefulWidget { class _ShareToFilePageState extends State<ShareToFilePage> { final FileService _fileService = FileService(); + final ProjectService _projectService = ProjectService(); + final TextEditingController _searchController = TextEditingController(); List<FileModel> _allFiles = []; List<FileModel> _filteredFiles = []; List<FileModel> _recentFiles = []; + + // file.id -> { 'preview': path, 'dimensions': str } + Map<String, Map<String, String>> _fileMetadata = {}; + + // project cache to avoid repeated fetches + final Map<int, ProjectModel> _projectCache = {}; + bool _isLoading = true; String _searchQuery = ""; @@ -36,44 +51,159 @@ class _ShareToFilePageState extends State<ShareToFilePage> { } Future<void> _fetchFiles() async { + setState(() => _isLoading = true); try { - setState(() => _isLoading = true); - - // Assuming projectId 0 is your 'Inbox' or default folder - final files = await _fileService.getFiles(0); + // Using service-style API: fetch all files and recent files + // If your FileService has different method names, adapt them here. + final List<FileModel> files = await _fileService.getAllFiles(); + final List<FileModel> recent = await _fileService.getRecentFiles( + limit: 10, + ); - // Sort by last updated to get recent files + // Sort by lastUpdated descending files.sort((a, b) => b.lastUpdated.compareTo(a.lastUpdated)); + recent.sort((a, b) => b.lastUpdated.compareTo(a.lastUpdated)); + + _allFiles = files; + _filteredFiles = List.from(files); + _recentFiles = recent.take(3).toList(); - setState(() { - _allFiles = files; - _filteredFiles = files; - _recentFiles = files.take(3).toList(); // Get 3 most recent files - _isLoading = false; - }); + // Load metadata for files (previews, dimensions) + await _loadFileMetadata(files); + + setState(() => _isLoading = false); } catch (e) { - debugPrint("Error fetching files: $e"); + debugPrint('Error fetching files in ShareToFilePage: $e'); setState(() => _isLoading = false); } } + Future<void> _loadFileMetadata(List<FileModel> files) async { + final Map<String, Map<String, String>> meta = {}; + + for (final fmodel in files) { + try { + final f = File(fmodel.filePath); + if (!await f.exists()) { + // skip if disk file missing + continue; + } + + if (fmodel.filePath.toLowerCase().endsWith('.json')) { + // Canvas JSON - attempt to parse preview_path, width/height + try { + final content = await f.readAsString(); + final data = jsonDecode(content); + String preview = ''; + String dims = 'Unknown'; + + if (data is Map) { + 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()) { + final parentDir = f.parent.path; + final candidate = File('$parentDir/$preview'); + if (candidate.existsSync()) preview = candidate.path; + } + } + + if (data['width'] != null && data['height'] != null) { + final w = (data['width'] as num).toInt(); + final h = (data['height'] as num).toInt(); + dims = '$w x $h px'; + } + } + + meta[fmodel.id] = {'preview': preview, 'dimensions': dims}; + } catch (e) { + debugPrint('Error parsing canvas json for ${fmodel.id}: $e'); + } + } else { + // Regular image file - assign path and try to get dims + String dims = 'Unknown'; + try { + final bytes = await f.readAsBytes(); + final image = img.decodeImage(bytes); + if (image != null) dims = '${image.width} x ${image.height} px'; + } catch (_) { + // ignore + } + meta[fmodel.id] = {'preview': fmodel.filePath, 'dimensions': dims}; + } + } catch (e) { + debugPrint('Error while loading metadata for ${fmodel.id}: $e'); + } + } + + _fileMetadata = meta; + } + void _filterFiles(String query) { setState(() { _searchQuery = query; - if (query.isEmpty) { - _filteredFiles = _allFiles; + if (query.trim().isEmpty) { + _filteredFiles = List.from(_allFiles); } else { final q = query.toLowerCase(); _filteredFiles = - _allFiles - .where((file) => file.name.toLowerCase().contains(q)) - .toList(); + _allFiles.where((file) { + final nameMatch = file.name.toLowerCase().contains(q); + final breadcrumb = _getProjectBreadcrumbSync(file).toLowerCase(); + final projectMatch = breadcrumb.contains(q); + return nameMatch || projectMatch; + }).toList(); } }); } + // Synchronous breadcrumb using cached project; returns empty if not cached + String _getProjectBreadcrumbSync(FileModel file) { + final proj = _projectCache[file.projectId]; + if (proj == null) return ''; + if (proj.parentId != null) { + final parent = _projectCache[proj.parentId!]; + if (parent != null) return '${parent.title} / ${proj.title}'; + } + return proj.title; + } + + // Async breadcrumb loader that fetches projects into cache as needed + Future<String> _getProjectEventLabel(FileModel file) async { + // load file.projectId + if (!_projectCache.containsKey(file.projectId)) { + try { + final p = await _projectService.getProjectById(file.projectId); + if (p != null) _projectCache[file.projectId] = p; + } catch (e) { + debugPrint('Project load failed for ${file.projectId}: $e'); + } + } + + final project = _projectCache[file.projectId]; + if (project == null) return "Unknown"; + + if (project.parentId == null) { + return project.title; + } + + final parentId = project.parentId!; + if (!_projectCache.containsKey(parentId)) { + try { + final parent = await _projectService.getProjectById(parentId); + if (parent != null) _projectCache[parentId] = parent; + } catch (e) { + debugPrint('Parent project load failed for $parentId: $e'); + } + } + + final parentProject = _projectCache[parentId]; + if (parentProject == null) return project.title; + return "${parentProject.title} / ${project.title}"; + } + void _onAddPressed() { - // Redirect to the CreateFilePage (Canvas Selection) with the shared image Navigator.push( context, MaterialPageRoute( @@ -82,16 +212,90 @@ class _ShareToFilePageState extends State<ShareToFilePage> { ); } - void _onFileSelected(FileModel file) { - // TODO: Handle file selection - maybe open editor with this file - // For now, just show a snackbar - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Selected: ${file.name}'))); + void _onFileSelected(FileModel file) async { + try { + final f = File(file.filePath); + if (!await f.exists()) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text("File not found"))); + return; + } + + double width = 1080; + double height = 1080; + + if (file.filePath.toLowerCase().endsWith('.json')) { + final content = await f.readAsString(); + final data = jsonDecode(content); + + if (data is Map) { + if (data['width'] != null && data['height'] != null) { + width = (data['width'] as num).toDouble(); + height = (data['height'] as num).toDouble(); + } + } + } + + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => CanvasBoardPage( + projectId: file.projectId, + width: width, + height: height, + existingFile: file, + injectedMedia: widget.sharedImage, // 🔥 THIS IS THE FIX + ), + ), + ); + } catch (e) { + debugPrint("Error opening file: $e"); + } + } + + + + String _formatDate(DateTime date) { + final now = DateTime.now(); + final diff = now.difference(date); + if (diff.inDays == 0) return 'Today'; + if (diff.inDays == 1) return 'Yesterday'; + if (diff.inDays < 7) return '${diff.inDays} days ago'; + return '${date.day}/${date.month}/${date.year}'; + } + + // Resolve preview path; if empty or missing, fallback to original filePath + String _resolvePreviewPath(FileModel file) { + final meta = _fileMetadata[file.id]; + if (meta == null) return file.filePath; + final preview = meta['preview'] ?? ''; + if (preview.isNotEmpty && File(preview).existsSync()) return preview; + // fallback: if file is json and has no preview, return placeholder or file.path + if (file.filePath.toLowerCase().endsWith('.json')) { + // attempt to find PNG/JPG sibling in same folder with same base name + final f = File(file.filePath); + final base = f.uri.pathSegments.last; + final nameWithoutExt = base.split('.').first; + final parent = f.parent; + final candidates = [ + '${parent.path}/$nameWithoutExt.png', + '${parent.path}/$nameWithoutExt.jpg', + '${parent.path}/preview_$nameWithoutExt.png', + ]; + for (final c in candidates) { + if (File(c).existsSync()) return c; + } + } + if (File(file.filePath).existsSync()) return file.filePath; + return ''; } @override Widget build(BuildContext context) { + final theme = Theme.of(context); + return Scaffold( backgroundColor: Colors.white, appBar: AppBar( @@ -102,7 +306,7 @@ class _ShareToFilePageState extends State<ShareToFilePage> { onPressed: () => Navigator.pop(context), ), title: const Text( - 'Your Files', + 'Files', style: TextStyle( color: Color(0xFF27272A), fontFamily: 'GeneralSans', @@ -126,7 +330,7 @@ class _ShareToFilePageState extends State<ShareToFilePage> { ? _buildEmptyState() : Column( children: [ - // --- Search Bar --- + // Search bar Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), child: SizedBox( @@ -175,7 +379,7 @@ class _ShareToFilePageState extends State<ShareToFilePage> { padding: EdgeInsets.zero, onPressed: () { _searchController.clear(); - _filterFiles(""); + _filterFiles(''); }, ) : null, @@ -189,9 +393,9 @@ class _ShareToFilePageState extends State<ShareToFilePage> { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // --- RECENT FILES SECTION --- - if (_searchQuery.isEmpty && - _recentFiles.isNotEmpty) ...[ + // Recent Files (UI like screenshot) + if (_recentFiles.isNotEmpty && + _searchQuery.isEmpty) ...[ const Padding( padding: EdgeInsets.symmetric( horizontal: 20, @@ -224,7 +428,7 @@ class _ShareToFilePageState extends State<ShareToFilePage> { const SizedBox(height: 24), ], - // --- ALL FILES SECTION --- + // All files header Padding( padding: const EdgeInsets.symmetric( horizontal: 20, @@ -243,6 +447,8 @@ class _ShareToFilePageState extends State<ShareToFilePage> { ), ), ), + + // All files list Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: ListView.builder( @@ -286,6 +492,8 @@ class _ShareToFilePageState extends State<ShareToFilePage> { } Widget _buildRecentFileItem(FileModel file) { + final previewPath = _resolvePreviewPath(file); + return Container( margin: const EdgeInsets.only(bottom: 8), child: InkWell( @@ -300,42 +508,66 @@ class _ShareToFilePageState extends State<ShareToFilePage> { ), child: Row( children: [ - // File Thumbnail - Container( - width: 56, - height: 56, - decoration: BoxDecoration( - color: const Color(0xFFFAFAFA), - borderRadius: BorderRadius.circular(8), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: - File(file.filePath).existsSync() - ? Image.file( - File(file.filePath), - fit: BoxFit.cover, - errorBuilder: - (_, __, ___) => Icon( - Icons.image, - color: Colors.grey[400], - size: 28, - ), - ) - : Icon( - Icons.image, - color: Colors.grey[400], - size: 28, - ), + // thumbnail + SizedBox( + width: 72, + height: 72, + child: Center( + child: Container( + width: 66, + height: 66, + decoration: BoxDecoration( + color: Colors.grey[200], + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.08), + blurRadius: 6, + offset: Offset(0, 3), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: + previewPath.isNotEmpty && + File(previewPath).existsSync() + ? Image.file( + File(previewPath), + fit: BoxFit.cover, + errorBuilder: (c, e, s) => _placeholderIcon(), + ) + : _placeholderIcon(), + ), + ), ), ), - const SizedBox(width: 10), - // File Info + + const SizedBox(width: 12), + + // info Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, children: [ + FutureBuilder<String>( + future: _getProjectEventLabel(file), + builder: (context, snapshot) { + final label = snapshot.data ?? ""; + return Text( + label, + style: const TextStyle( + fontFamily: 'GeneralSans', + fontSize: 12, + fontWeight: FontWeight.w400, + color: Color(0xFF71717B), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); + }, + ), + const SizedBox(height: 6), Text( file.name, style: const TextStyle( @@ -347,20 +579,18 @@ class _ShareToFilePageState extends State<ShareToFilePage> { maxLines: 1, overflow: TextOverflow.ellipsis, ), - const SizedBox(height: 2), + const SizedBox(height: 6), Text( _formatDate(file.lastUpdated), style: const TextStyle( fontFamily: 'GeneralSans', fontSize: 12, color: Color(0xFF71717B), - fontWeight: FontWeight.w400, ), ), ], ), ), - const SizedBox(width: 12), ], ), ), @@ -369,6 +599,8 @@ class _ShareToFilePageState extends State<ShareToFilePage> { } Widget _buildFileItem(FileModel file) { + final previewPath = _resolvePreviewPath(file); + return Container( margin: const EdgeInsets.only(bottom: 8), child: InkWell( @@ -383,42 +615,64 @@ class _ShareToFilePageState extends State<ShareToFilePage> { ), child: Row( children: [ - // File Thumbnail - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: const Color(0xFFFAFAFA), - borderRadius: BorderRadius.circular(8), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: - File(file.filePath).existsSync() - ? Image.file( - File(file.filePath), - fit: BoxFit.cover, - errorBuilder: - (_, __, ___) => Icon( - Icons.description, - color: Colors.grey[400], - size: 24, - ), - ) - : Icon( - Icons.description, - color: Colors.grey[400], - size: 24, - ), + SizedBox( + width: 72, + height: 72, + child: Center( + child: Container( + width: 66, + height: 66, + decoration: BoxDecoration( + color: Colors.grey[200], + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.08), + blurRadius: 6, + offset: Offset(0, 3), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: + previewPath.isNotEmpty && + File(previewPath).existsSync() + ? Image.file( + File(previewPath), + fit: BoxFit.cover, + errorBuilder: (c, e, s) => _placeholderIcon(), + ) + : _placeholderIcon(), + ), + ), ), ), + const SizedBox(width: 12), - // File Info + Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, children: [ + FutureBuilder<String>( + future: _getProjectEventLabel(file), + builder: (context, snapshot) { + final label = snapshot.data ?? ""; + return Text( + label, + style: const TextStyle( + fontFamily: 'GeneralSans', + fontSize: 12, + fontWeight: FontWeight.w400, + color: Color(0xFF71717B), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); + }, + ), + const SizedBox(height: 4), Text( file.name, style: const TextStyle( @@ -430,20 +684,18 @@ class _ShareToFilePageState extends State<ShareToFilePage> { maxLines: 1, overflow: TextOverflow.ellipsis, ), - const SizedBox(height: 2), + const SizedBox(height: 4), Text( _formatDate(file.lastUpdated), style: const TextStyle( fontFamily: 'GeneralSans', fontSize: 12, color: Color(0xFF71717B), - fontWeight: FontWeight.w400, ), ), ], ), ), - const SizedBox(width: 12), ], ), ), @@ -451,18 +703,10 @@ class _ShareToFilePageState extends State<ShareToFilePage> { ); } - String _formatDate(DateTime date) { - final now = DateTime.now(); - final difference = now.difference(date); - - if (difference.inDays == 0) { - return "Today"; - } else if (difference.inDays == 1) { - return "Yesterday"; - } else if (difference.inDays < 7) { - return "${difference.inDays} days ago"; - } else { - return "${date.day}/${date.month}/${date.year}"; - } + Widget _placeholderIcon() { + return Container( + color: Colors.grey[200], + child: Icon(Icons.image, size: 28, color: Colors.grey[400]), + ); } }