creek

The AI Image Editor of 2030

lib/ui/widgets/project_selector.dart (16254B)


  1import 'dart:io';
  2import 'package:flutter/material.dart';
  3import 'package:creekui/data/models/project_model.dart';
  4import 'package:creekui/data/repos/project_repo.dart';
  5import 'package:creekui/data/repos/image_repo.dart';
  6import 'package:creekui/ui/styles/variables.dart';
  7import 'package:creekui/ui/widgets/search_bar.dart';
  8import 'package:creekui/ui/widgets/section_header.dart';
  9import 'package:creekui/ui/widgets/empty_state.dart';
 10
 11class ProjectItemViewModel {
 12  final ProjectModel item;
 13  final String? parentTitle;
 14  final String? coverPath;
 15
 16  ProjectItemViewModel({required this.item, this.parentTitle, this.coverPath});
 17
 18  String get title => item.title;
 19  bool get isEvent => item.isEvent;
 20  int get id => item.id!;
 21}
 22
 23class ProjectGroup {
 24  final ProjectModel project;
 25  final List<ProjectItemViewModel> events;
 26  final String? coverPath;
 27  bool isExpanded;
 28
 29  ProjectGroup({
 30    required this.project,
 31    this.events = const [],
 32    this.coverPath,
 33    this.isExpanded = false,
 34  });
 35}
 36
 37class ProjectSelector extends StatefulWidget {
 38  final Function(int id, String title, String? parentTitle) onProjectSelected;
 39  final String searchHint;
 40  final ScrollController? scrollController;
 41
 42  const ProjectSelector({
 43    super.key,
 44    required this.onProjectSelected,
 45    this.searchHint = "Search",
 46    this.scrollController,
 47  });
 48
 49  @override
 50  State<ProjectSelector> createState() => _ProjectSelectorState();
 51}
 52
 53class _ProjectSelectorState extends State<ProjectSelector> {
 54  final ProjectRepo _projectRepo = ProjectRepo();
 55  final ImageRepo _imageRepo = ImageRepo();
 56
 57  final TextEditingController _searchController = TextEditingController();
 58
 59  List<ProjectItemViewModel> _recentViewModels = [];
 60  List<ProjectGroup> _groupedProjects = [];
 61  List<ProjectGroup> _filteredGroupedProjects = [];
 62
 63  bool _isLoading = true;
 64  String _searchQuery = "";
 65
 66  @override
 67  void initState() {
 68    super.initState();
 69    _loadData();
 70  }
 71
 72  @override
 73  void dispose() {
 74    _searchController.dispose();
 75    super.dispose();
 76  }
 77
 78  Future<String?> _getProjectCover(int projectId) async {
 79    try {
 80      final images = await _imageRepo.getImages(projectId);
 81      if (images.isNotEmpty) {
 82        return images.first.filePath;
 83      }
 84    } catch (e) {
 85      debugPrint("Error fetching cover for project $projectId: $e");
 86    }
 87    return null;
 88  }
 89
 90  Future<void> _loadData() async {
 91    if (!mounted) return;
 92    setState(() => _isLoading = true);
 93
 94    // 1. Fetch Raw Data
 95    final recentItems = await _projectRepo.getRecentProjectsAndEvents();
 96    final allProjects = await _projectRepo.getAllProjects();
 97
 98    // 2. Build Recent View Models
 99    final List<ProjectItemViewModel> recents = [];
100    for (var item in recentItems.take(3)) {
101      String? parentTitle;
102      if (item.parentId != null) {
103        final parent = await _projectRepo.getProjectById(item.parentId!);
104        parentTitle = parent?.title;
105      }
106      final cover = await _getProjectCover(item.id!);
107      recents.add(
108        ProjectItemViewModel(
109          item: item,
110          parentTitle: parentTitle,
111          coverPath: cover,
112        ),
113      );
114    }
115
116    // 3. Build Grouped Projects
117    final List<ProjectGroup> groups = [];
118    for (final p in allProjects) {
119      final rawEvents = await _projectRepo.getEvents(p.id!);
120      final List<ProjectItemViewModel> eventVMs = [];
121      for (final e in rawEvents) {
122        final eCover = await _getProjectCover(e.id!);
123        eventVMs.add(ProjectItemViewModel(item: e, coverPath: eCover));
124      }
125      final pCover = await _getProjectCover(p.id!);
126      groups.add(ProjectGroup(project: p, events: eventVMs, coverPath: pCover));
127    }
128
129    if (mounted) {
130      setState(() {
131        _recentViewModels = recents;
132        _groupedProjects = groups;
133        _filterProjects(_searchQuery); // Re-apply filter if any
134        _isLoading = false;
135      });
136    }
137  }
138
139  void _filterProjects(String query) {
140    setState(() {
141      _searchQuery = query;
142      if (query.isEmpty) {
143        _filteredGroupedProjects = _groupedProjects;
144      } else {
145        final q = query.toLowerCase();
146        final List<ProjectGroup> filtered = [];
147        for (final g in _groupedProjects) {
148          final projectMatch = g.project.title.toLowerCase().contains(q);
149          final matchingEvents =
150              g.events.where((e) => e.title.toLowerCase().contains(q)).toList();
151
152          if (projectMatch) {
153            filtered.add(
154              ProjectGroup(
155                project: g.project,
156                events: g.events,
157                isExpanded: true,
158                coverPath: g.coverPath,
159              ),
160            );
161          } else if (matchingEvents.isNotEmpty) {
162            filtered.add(
163              ProjectGroup(
164                project: g.project,
165                events: matchingEvents,
166                isExpanded: true,
167                coverPath: g.coverPath,
168              ),
169            );
170          }
171        }
172        _filteredGroupedProjects = filtered;
173      }
174    });
175  }
176
177  @override
178  Widget build(BuildContext context) {
179    if (_isLoading) {
180      return const Center(child: CircularProgressIndicator());
181    }
182
183    return Column(
184      children: [
185        // Search Bar
186        Padding(
187          padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
188          child: CommonSearchBar(
189            controller: _searchController,
190            onChanged: _filterProjects,
191            hintText: widget.searchHint,
192          ),
193        ),
194        Expanded(
195          child: SingleChildScrollView(
196            controller: widget.scrollController,
197            child: Column(
198              crossAxisAlignment: CrossAxisAlignment.start,
199              children: [
200                // Empty State
201                if (_filteredGroupedProjects.isEmpty && _searchQuery.isNotEmpty)
202                  const Padding(
203                    padding: EdgeInsets.all(32.0),
204                    child: EmptyState(
205                      icon: Icons.search_off,
206                      title: "No results found",
207                      subtitle: "Try adjusting your search",
208                    ),
209                  ),
210
211                // Recents Section
212                if (_searchQuery.isEmpty && _recentViewModels.isNotEmpty) ...[
213                  const Padding(
214                    padding: EdgeInsets.symmetric(horizontal: 16),
215                    child: SectionHeader(title: "Recent Projects/Events"),
216                  ),
217                  const SizedBox(height: 12),
218                  Padding(
219                    padding: const EdgeInsets.symmetric(horizontal: 16),
220                    child: Column(
221                      children:
222                          _recentViewModels
223                              .map((vm) => _buildRecentItem(vm))
224                              .toList(),
225                    ),
226                  ),
227                  const SizedBox(height: 24),
228                ],
229
230                // All Projects Section
231                if (_filteredGroupedProjects.isNotEmpty) ...[
232                  Padding(
233                    padding: const EdgeInsets.symmetric(horizontal: 16),
234                    child: SectionHeader(
235                      title:
236                          _searchQuery.isEmpty
237                              ? "All Projects/Events"
238                              : "Search Results",
239                    ),
240                  ),
241                  const SizedBox(height: 12),
242                  Padding(
243                    padding: const EdgeInsets.symmetric(horizontal: 16),
244                    child: ListView.builder(
245                      shrinkWrap: true,
246                      physics: const NeverScrollableScrollPhysics(),
247                      itemCount: _filteredGroupedProjects.length,
248                      itemBuilder:
249                          (context, index) => _buildProjectGroup(
250                            _filteredGroupedProjects[index],
251                          ),
252                    ),
253                  ),
254                ],
255                const SizedBox(height: 40),
256              ],
257            ),
258          ),
259        ),
260      ],
261    );
262  }
263
264  Widget _buildRecentItem(ProjectItemViewModel vm) {
265    return Container(
266      margin: const EdgeInsets.only(bottom: 8),
267      child: InkWell(
268        onTap: () => widget.onProjectSelected(vm.id, vm.title, vm.parentTitle),
269        borderRadius: BorderRadius.circular(Variables.radiusMedium),
270        child: Container(
271          padding: const EdgeInsets.fromLTRB(4, 4, 0, 4),
272          decoration: BoxDecoration(
273            color: Variables.background,
274            borderRadius: BorderRadius.circular(Variables.radiusMedium),
275            border: Border.all(color: Variables.borderSubtle, width: 1),
276          ),
277          child: Row(
278            children: [
279              // Cover Image
280              Container(
281                width: 56,
282                height: 56,
283                decoration: BoxDecoration(
284                  color: Variables.surfaceSubtle,
285                  borderRadius: BorderRadius.circular(Variables.radiusSmall),
286                  image:
287                      vm.coverPath != null
288                          ? DecorationImage(
289                            image: FileImage(File(vm.coverPath!)),
290                            fit: BoxFit.cover,
291                          )
292                          : null,
293                ),
294                child:
295                    vm.coverPath == null
296                        ? const Icon(
297                          Icons.image,
298                          color: Variables.textDisabled,
299                          size: 28,
300                        )
301                        : null,
302              ),
303              const SizedBox(width: 12),
304              // Text Content
305              Expanded(
306                child: Column(
307                  crossAxisAlignment: CrossAxisAlignment.start,
308                  mainAxisAlignment: MainAxisAlignment.center,
309                  children: [
310                    if (vm.isEvent && vm.parentTitle != null)
311                      Padding(
312                        padding: const EdgeInsets.only(bottom: 2),
313                        child: Text(
314                          vm.parentTitle!,
315                          style: Variables.captionStyle,
316                          maxLines: 1,
317                          overflow: TextOverflow.ellipsis,
318                        ),
319                      ),
320                    Text(
321                      vm.title,
322                      style: Variables.bodyStyle.copyWith(
323                        fontWeight: FontWeight.w600,
324                      ),
325                      maxLines: 1,
326                      overflow: TextOverflow.ellipsis,
327                    ),
328                  ],
329                ),
330              ),
331            ],
332          ),
333        ),
334      ),
335    );
336  }
337
338  Widget _buildProjectGroup(ProjectGroup g) {
339    final project = g.project;
340    final hasEvents = g.events.isNotEmpty;
341
342    return Container(
343      margin: const EdgeInsets.only(bottom: 8),
344      clipBehavior: Clip.antiAlias,
345      decoration: BoxDecoration(
346        color: Variables.background,
347        borderRadius: BorderRadius.circular(Variables.radiusMedium),
348        border: Border.all(color: Variables.borderSubtle),
349      ),
350      child: Column(
351        children: [
352          // Parent Project
353          ListTile(
354            onTap:
355                () =>
356                    hasEvents
357                        ? setState(() => g.isExpanded = !g.isExpanded)
358                        : widget.onProjectSelected(
359                          project.id!,
360                          project.title,
361                          null,
362                        ),
363            contentPadding: const EdgeInsets.symmetric(
364              horizontal: 16,
365              vertical: 4,
366            ),
367            visualDensity: VisualDensity.compact,
368            leading: Container(
369              width: 48,
370              height: 48,
371              decoration: BoxDecoration(
372                color: Variables.surfaceSubtle,
373                borderRadius: BorderRadius.circular(Variables.radiusMedium),
374                image:
375                    g.coverPath != null
376                        ? DecorationImage(
377                          image: FileImage(File(g.coverPath!)),
378                          fit: BoxFit.cover,
379                        )
380                        : null,
381              ),
382              child:
383                  g.coverPath == null
384                      ? const Icon(Icons.folder, color: Variables.textDisabled)
385                      : null,
386            ),
387            title: Text(
388              project.title,
389              style: Variables.bodyStyle.copyWith(
390                fontWeight: FontWeight.w600,
391                fontSize: 16,
392              ),
393            ),
394            trailing:
395                hasEvents
396                    ? IconButton(
397                      icon: Icon(
398                        g.isExpanded
399                            ? Icons.keyboard_arrow_up
400                            : Icons.keyboard_arrow_down,
401                        color: Variables.textSecondary,
402                      ),
403                      onPressed: () {
404                        setState(() => g.isExpanded = !g.isExpanded);
405                      },
406                    )
407                    : null,
408          ),
409
410          // Children (Events)
411          if (hasEvents)
412            AnimatedCrossFade(
413              firstChild: const SizedBox.shrink(),
414              secondChild: Container(
415                width: double.infinity,
416                color: Variables.surfaceSubtle.withOpacity(0.5),
417                child: Column(
418                  children:
419                      g.events.map((e) {
420                        return ListTile(
421                          onTap:
422                              () => widget.onProjectSelected(
423                                e.id,
424                                e.title,
425                                project.title,
426                              ),
427                          contentPadding: const EdgeInsets.symmetric(
428                            horizontal: 24,
429                            vertical: 2,
430                          ),
431                          visualDensity: VisualDensity.compact,
432                          leading: Container(
433                            width: 40,
434                            height: 40,
435                            decoration: BoxDecoration(
436                              color: Variables.background,
437                              borderRadius: BorderRadius.circular(
438                                Variables.radiusSmall,
439                              ),
440                              border: Border.all(color: Variables.borderSubtle),
441                              image:
442                                  e.coverPath != null
443                                      ? DecorationImage(
444                                        image: FileImage(File(e.coverPath!)),
445                                        fit: BoxFit.cover,
446                                      )
447                                      : null,
448                            ),
449                            child:
450                                e.coverPath == null
451                                    ? const Icon(
452                                      Icons.event,
453                                      size: 20,
454                                      color: Variables.textDisabled,
455                                    )
456                                    : null,
457                          ),
458                          title: Text(
459                            e.title,
460                            style: Variables.bodyStyle.copyWith(
461                              fontSize: 15,
462                              fontWeight: FontWeight.w500,
463                            ),
464                          ),
465                        );
466                      }).toList(),
467                ),
468              ),
469              crossFadeState:
470                  g.isExpanded
471                      ? CrossFadeState.showSecond
472                      : CrossFadeState.showFirst,
473              duration: const Duration(milliseconds: 200),
474            ),
475        ],
476      ),
477    );
478  }
479}