creek
The AI Image Editor of 2030git clone https://github.com/maydayv7/creek
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}