creek

The AI Image Editor of 2030

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}