creek
The AI Image Editor of 2030git clone https://github.com/maydayv7/creek
lib/ui/pages/project_file_page.dart (14661B)
1import 'dart:io';
2import 'dart:convert';
3import 'package:flutter/material.dart';
4import 'package:image/image.dart' as img;
5import 'package:creekui/data/models/file_model.dart';
6import 'package:creekui/data/models/project_model.dart';
7import 'package:creekui/services/file_service.dart';
8import 'package:creekui/data/repos/project_repo.dart';
9import 'package:creekui/ui/widgets/top_bar.dart';
10import 'package:creekui/ui/widgets/bottom_bar.dart';
11import 'package:creekui/ui/styles/variables.dart';
12import 'package:creekui/ui/widgets/search_bar.dart';
13import 'package:creekui/ui/widgets/file_card.dart';
14import 'package:creekui/ui/widgets/empty_state.dart';
15import 'package:creekui/ui/widgets/dialog.dart';
16import 'package:creekui/ui/widgets/text_field.dart';
17import 'create_file_page.dart';
18import 'canvas_page.dart';
19
20class ProjectFilePage extends StatefulWidget {
21 final int projectId;
22
23 const ProjectFilePage({super.key, required this.projectId});
24
25 @override
26 State<ProjectFilePage> createState() => _ProjectFilePageState();
27}
28
29class _ProjectFilePageState extends State<ProjectFilePage> {
30 final _fileService = FileService();
31 final _projectRepo = ProjectRepo();
32 final TextEditingController _searchController = TextEditingController();
33
34 List<FileModel> _allFiles = [];
35 List<ProjectModel> _events = [];
36 List<FileModel> _eventFiles = [];
37
38 Map<String, Map<String, String>> _fileMetadata = {};
39
40 ProjectModel? _selectedEvent;
41 bool _isLoading = true;
42 String _search = '';
43
44 @override
45 void initState() {
46 super.initState();
47 _loadEverything();
48 }
49
50 @override
51 void dispose() {
52 _searchController.dispose();
53 super.dispose();
54 }
55
56 // Load data
57 Future<void> _loadEverything() async {
58 setState(() => _isLoading = true);
59 try {
60 _events = await _projectRepo.getEvents(widget.projectId);
61
62 _allFiles = await _fileService.getFilesForProjectAndEvents(
63 widget.projectId,
64 );
65
66 await _loadMetadata(_allFiles);
67
68 if (_events.isNotEmpty) {
69 _selectedEvent = _events.first;
70 _eventFiles = await _fileService.getFiles(_selectedEvent!.id!);
71 await _loadMetadata(_eventFiles);
72 }
73 } catch (e) {
74 debugPrint("Error loading project page: $e");
75 }
76
77 if (mounted) setState(() => _isLoading = false);
78 }
79
80 // File Metadata
81 Future<void> _loadMetadata(List<FileModel> list) async {
82 for (final file in list) {
83 final meta = await _fileService.getFileMetadata(file.filePath);
84 _fileMetadata[file.id] = {
85 "preview": meta.previewPath ?? "",
86 "dimensions": meta.dimensions,
87 };
88 }
89 }
90
91 Future<void> _onSelectEvent(ProjectModel event) async {
92 setState(() => _selectedEvent = event);
93 _eventFiles = await _fileService.getFiles(event.id!);
94 await _loadMetadata(_eventFiles);
95 setState(() {});
96 }
97
98 void _openFile(FileModel file) async {
99 // Default values
100 double width = 1080;
101 double height = 1920;
102 final meta = await _fileService.getFileMetadata(file.filePath);
103 if (meta.width > 0 && meta.height > 0) {
104 width = meta.width;
105 height = meta.height;
106 }
107
108 if (mounted) {
109 Navigator.push(
110 context,
111 MaterialPageRoute(
112 builder:
113 (_) => CanvasPage(
114 projectId: file.projectId,
115 width: width,
116 height: height,
117 existingFile: file,
118 ),
119 ),
120 );
121 }
122 }
123
124 void _handleFileMenuAction(FileModel file, String action) {
125 switch (action) {
126 case "open":
127 _openFile(file);
128 break;
129
130 case "rename":
131 _renameFile(file);
132 break;
133
134 case "delete":
135 _deleteFile(file);
136 break;
137 }
138 }
139
140 Future<void> _renameFile(FileModel file) async {
141 final controller = TextEditingController(text: file.name);
142
143 await ShowDialog.show(
144 context,
145 title: "Rename File",
146 primaryButtonText: "Save",
147 content: CommonTextField(
148 hintText: "New file name",
149 controller: controller,
150 autoFocus: true,
151 ),
152 onPrimaryPressed: () async {
153 final newName = controller.text.trim();
154 if (newName.isNotEmpty) {
155 await _fileService.renameFile(file.id, newName);
156 await _loadEverything();
157 if (mounted) Navigator.pop(context);
158 }
159 },
160 );
161 }
162
163 Future<void> _deleteFile(FileModel file) async {
164 await ShowDialog.show(
165 context,
166 title: "Delete File?",
167 description: "This will permanently remove ${file.name}.",
168 primaryButtonText: "Delete",
169 isDestructive: true,
170 onPrimaryPressed: () async {
171 try {
172 await _fileService.deleteFile(file.id!);
173 final disk = File(file.filePath);
174 if (await disk.exists()) await disk.delete();
175
176 setState(() {
177 _allFiles.removeWhere((f) => f.id == file.id);
178 _eventFiles.removeWhere((f) => f.id == file.id);
179 });
180
181 if (mounted) {
182 Navigator.pop(context);
183 ScaffoldMessenger.of(
184 context,
185 ).showSnackBar(const SnackBar(content: Text("File deleted")));
186 }
187 } catch (e) {
188 debugPrint("Delete error: $e");
189 }
190 },
191 );
192 }
193
194 void _navigateToCreateFile() {
195 Navigator.push(
196 context,
197 MaterialPageRoute(
198 builder: (_) => CreateFilePage(projectId: widget.projectId),
199 ),
200 ).then((_) => _loadEverything());
201 }
202
203 String _formatRelative(DateTime date) {
204 final now = DateTime.now();
205 final diff = now.difference(date);
206
207 if (diff.inDays > 0) {
208 return "${diff.inDays} day${diff.inDays > 1 ? 's' : ''} ago";
209 }
210 if (diff.inHours > 0) {
211 return "${diff.inHours} hour${diff.inHours > 1 ? 's' : ''} ago";
212 }
213 if (diff.inMinutes > 0) {
214 return "${diff.inMinutes} min ago";
215 }
216 return "just now";
217 }
218
219 Widget _buildFileCard(FileModel file) {
220 final meta = _fileMetadata[file.id] ?? {};
221 final preview = meta["preview"] ?? "";
222 final dimensions = meta["dimensions"] ?? "Unknown";
223
224 return Padding(
225 padding: const EdgeInsets.only(bottom: 12),
226 child: FileCard(
227 file: file,
228 breadcrumb: _breadcrumbFor(file),
229 dimensions: dimensions,
230 previewPath: preview,
231 timeAgo: "Last edited • ${_formatRelative(file.lastUpdated)}",
232 onTap: () => _openFile(file),
233 onMenuAction: (value) => _handleFileMenuAction(file, value),
234 ),
235 );
236 }
237
238 // Breadcrumb
239 String _breadcrumbFor(FileModel file) {
240 final event = _events.firstWhere(
241 (e) => e.id == file.projectId,
242 orElse:
243 () => ProjectModel(
244 id: widget.projectId,
245 title: "",
246 lastAccessedAt: DateTime.now(),
247 createdAt: DateTime.now(),
248 ),
249 );
250
251 if (event.parentId == null) return "";
252
253 final parent = _events.firstWhere(
254 (e) => e.id == event.parentId,
255 orElse:
256 () => ProjectModel(
257 id: widget.projectId,
258 title: "",
259 lastAccessedAt: DateTime.now(),
260 createdAt: DateTime.now(),
261 ),
262 );
263
264 return "${parent.title} / ${event.title}";
265 }
266
267 // UI Builder
268 @override
269 Widget build(BuildContext context) {
270 return Scaffold(
271 backgroundColor: Variables.surfaceSubtle,
272 appBar: TopBar(
273 currentProjectId: widget.projectId,
274 onBack: () => Navigator.pop(context),
275 titleOverride: "Project Files",
276 onProjectChanged: (project) {
277 // When user switches project from dropdown, refresh page with new ID
278 Navigator.pushReplacement(
279 context,
280 MaterialPageRoute(
281 builder: (_) => ProjectFilePage(projectId: project.id!),
282 ),
283 );
284 },
285 hideSecondRow: true,
286 onLayoutToggle: () {},
287 isAlternateView: false,
288 ),
289
290 bottomNavigationBar: BottomBar(
291 currentTab: BottomBarItem.files,
292 projectId: widget.projectId,
293 ),
294
295 floatingActionButton: GestureDetector(
296 onTap: _navigateToCreateFile,
297 child: Container(
298 padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
299 decoration: BoxDecoration(
300 color: Variables.textPrimary,
301 borderRadius: BorderRadius.circular(50),
302 ),
303 child: Row(
304 mainAxisSize: MainAxisSize.min,
305 children: const [
306 Text(
307 "Create File",
308 style: TextStyle(color: Colors.white, fontSize: 14),
309 ),
310 SizedBox(width: 8),
311 Icon(Icons.add, color: Colors.white),
312 ],
313 ),
314 ),
315 ),
316
317 body:
318 _isLoading
319 ? const Center(child: CircularProgressIndicator())
320 : RefreshIndicator(
321 onRefresh: _loadEverything,
322 child: SingleChildScrollView(
323 physics: const AlwaysScrollableScrollPhysics(),
324 padding: const EdgeInsets.symmetric(horizontal: 16),
325 child: Column(
326 crossAxisAlignment: CrossAxisAlignment.start,
327 children: [
328 const SizedBox(height: 12),
329 // Search Bar
330 CommonSearchBar(
331 controller: _searchController,
332 hintText: "Search your files",
333 onChanged: (v) => setState(() => _search = v.trim()),
334 backgroundColor: Variables.background,
335 ),
336 const SizedBox(height: 24),
337 // All Files
338 Text(
339 "All Files",
340 style: Variables.bodyStyle.copyWith(
341 fontSize: 16,
342 fontWeight: FontWeight.w600,
343 ),
344 ),
345 const SizedBox(height: 12),
346 if (_filteredAllFiles().isEmpty)
347 EmptyState(
348 icon: Icons.folder_outlined,
349 title:
350 _search.isEmpty
351 ? "No files yet"
352 : "No files found",
353 subtitle:
354 _search.isEmpty
355 ? "Create your first file to get started"
356 : "Try a different search term",
357 )
358 else
359 Column(
360 children:
361 _filteredAllFiles()
362 .map((f) => _buildFileCard(f))
363 .toList(),
364 ),
365 const SizedBox(height: 32),
366 // Files for Events
367 Text(
368 "Files for Events",
369 style: Variables.bodyStyle.copyWith(
370 fontSize: 16,
371 fontWeight: FontWeight.w600,
372 ),
373 ),
374 const SizedBox(height: 12),
375 if (_events.isEmpty)
376 const EmptyState(
377 icon: Icons.event_outlined,
378 title: "No events yet",
379 subtitle: "Create an event to organize your files",
380 )
381 else
382 Column(
383 crossAxisAlignment: CrossAxisAlignment.start,
384 children: [
385 _buildEventDropdown(),
386 const SizedBox(height: 16),
387 _eventFiles.isEmpty
388 ? const EmptyState(
389 icon: Icons.insert_drive_file_outlined,
390 title: "No files in this event yet",
391 subtitle:
392 "Create a file to add it to this event",
393 )
394 : Column(
395 children:
396 _eventFiles
397 .map((f) => _buildFileCard(f))
398 .toList(),
399 ),
400 ],
401 ),
402 const SizedBox(height: 100),
403 ],
404 ),
405 ),
406 ),
407 );
408 }
409
410 // Dropdown
411 Widget _buildEventDropdown() {
412 return Container(
413 height: 36,
414 padding: const EdgeInsets.symmetric(horizontal: 12),
415 decoration: BoxDecoration(
416 color: const Color(0xFFE4E4E7),
417 borderRadius: BorderRadius.circular(Variables.radiusSmall),
418 ),
419 child: DropdownButton<ProjectModel>(
420 value: _selectedEvent,
421 isExpanded: false,
422 underline: const SizedBox(),
423 icon: const Icon(
424 Icons.keyboard_arrow_down_rounded,
425 size: 18,
426 color: Color(0xFF27272A),
427 ),
428 style: const TextStyle(
429 fontFamily: "GeneralSans",
430 fontSize: 14,
431 fontWeight: FontWeight.w500,
432 color: Color(0xFF27272A),
433 ),
434 dropdownColor: Colors.white,
435 borderRadius: BorderRadius.circular(8),
436 items:
437 _events
438 .map(
439 (e) => DropdownMenuItem(
440 value: e,
441 child: Text(
442 e.title,
443 style: const TextStyle(
444 fontFamily: "GeneralSans",
445 fontSize: 14,
446 fontWeight: FontWeight.w500,
447 color: Color(0xFF27272A),
448 ),
449 ),
450 ),
451 )
452 .toList(),
453 onChanged: (value) {
454 if (value != null) _onSelectEvent(value);
455 },
456 ),
457 );
458 }
459
460 // Filter
461 List<FileModel> _filteredAllFiles() {
462 if (_search.isEmpty) return _allFiles;
463 return _allFiles
464 .where(
465 (f) =>
466 f.name.toLowerCase().contains(_search.toLowerCase()) ||
467 _breadcrumbFor(f).toLowerCase().contains(_search.toLowerCase()),
468 )
469 .toList();
470 }
471}