creek

The AI Image Editor of 2030

lib/ui/pages/project_board_page.dart (11865B)


  1import 'dart:io';
  2import 'package:flutter/material.dart';
  3import 'package:image_picker/image_picker.dart';
  4import 'package:creekui/ui/styles/variables.dart';
  5import 'package:creekui/ui/widgets/top_bar.dart';
  6import 'package:creekui/ui/widgets/bottom_bar.dart';
  7import 'package:creekui/data/models/project_model.dart';
  8import 'package:creekui/data/models/image_model.dart';
  9import 'package:creekui/data/repos/project_repo.dart';
 10import 'package:creekui/data/repos/note_repo.dart';
 11import 'package:creekui/data/repos/image_repo.dart';
 12import 'package:creekui/ui/widgets/image_context_menu.dart';
 13import 'package:creekui/ui/widgets/empty_state.dart';
 14import 'project_tag_page.dart';
 15import 'project_board_page_alternate.dart';
 16import 'image_save_page.dart';
 17import 'image_details_page.dart';
 18import 'stylesheet_page.dart';
 19
 20class ProjectBoardPage extends StatefulWidget {
 21  final int projectId;
 22  final bool? initialShowAlternateView;
 23
 24  const ProjectBoardPage({
 25    super.key,
 26    required this.projectId,
 27    this.initialShowAlternateView,
 28  });
 29
 30  @override
 31  State<ProjectBoardPage> createState() => _ProjectBoardPageState();
 32}
 33
 34class _ProjectBoardPageState extends State<ProjectBoardPage> {
 35  final _projectRepo = ProjectRepo();
 36  final _imageRepo = ImageRepo();
 37  final _noteRepo = NoteRepo();
 38  final ImagePicker _picker = ImagePicker();
 39
 40  final GlobalKey<ProjectBoardPageAlternateState> _alternatePageKey =
 41      GlobalKey();
 42
 43  ProjectModel? _currentProject;
 44  Map<String, List<ImageModel>> _categorizedImages = {};
 45  bool _isLoading = true;
 46  bool _showAlternateView = false;
 47
 48  @override
 49  void initState() {
 50    super.initState();
 51    // Set initial view, default to grid view
 52    _showAlternateView = widget.initialShowAlternateView ?? true;
 53    _initData();
 54  }
 55
 56  Future<void> _initData() async {
 57    try {
 58      final project = await _projectRepo.getProjectById(widget.projectId);
 59      if (project != null) {
 60        if (mounted) {
 61          setState(() {
 62            _currentProject = project;
 63          });
 64          await _loadImagesForSelected();
 65        }
 66      }
 67    } catch (e) {
 68      debugPrint("Error loading board: $e");
 69      if (mounted) setState(() => _isLoading = false);
 70    }
 71  }
 72
 73  Future<void> _loadImagesForSelected() async {
 74    if (_currentProject?.id == null) return;
 75    if (!_showAlternateView) {
 76      setState(() => _isLoading = true);
 77
 78      try {
 79        final images = await _imageRepo.getImages(_currentProject!.id!);
 80        final Map<String, List<ImageModel>> tempMap = {};
 81        await Future.wait(
 82          images.map((img) async {
 83            final Set<String> distinctCategories = {...img.tags};
 84            final notes = await _noteRepo.getNotesForImage(img.id);
 85            for (var note in notes) {
 86              if (note.category.isNotEmpty) {
 87                distinctCategories.add(note.category);
 88              }
 89            }
 90            if (distinctCategories.isEmpty) {
 91              tempMap.putIfAbsent('Uncategorized', () => []).add(img);
 92            } else {
 93              for (var category in distinctCategories) {
 94                final list = tempMap.putIfAbsent(category, () => []);
 95                if (!list.any((i) => i.id == img.id)) {
 96                  list.add(img);
 97                }
 98              }
 99            }
100          }),
101        );
102
103        _categorizedImages = tempMap;
104      } catch (e) {
105        debugPrint("Error loading images and note categories: $e");
106      }
107      if (mounted) setState(() => _isLoading = false);
108    }
109  }
110
111  void _onProjectChanged(ProjectModel newProject) {
112    if (newProject.id != _currentProject?.id) {
113      setState(() {
114        _currentProject = newProject;
115      });
116      if (!_showAlternateView) {
117        _loadImagesForSelected();
118      } else {
119        WidgetsBinding.instance.addPostFrameCallback((_) {
120          _alternatePageKey.currentState?.refreshData();
121        });
122      }
123    }
124  }
125
126  Future<void> _pickAndRedirect() async {
127    try {
128      final List<XFile> pickedFiles = await _picker.pickMultiImage();
129
130      if (pickedFiles.isNotEmpty && _currentProject != null) {
131        if (!mounted) return;
132
133        Navigator.push(
134          context,
135          MaterialPageRoute(
136            builder:
137                (_) => ImageSavePage(
138                  imagePaths: pickedFiles.map((e) => e.path).toList(),
139                  projectId: _currentProject!.id!,
140                  projectName: _currentProject!.title,
141                  isFromShare: false,
142                ),
143          ),
144        ).then((_) {
145          if (!_showAlternateView) {
146            _loadImagesForSelected();
147          } else {
148            _alternatePageKey.currentState?.refreshData();
149          }
150        });
151      }
152    } catch (e) {
153      debugPrint("Error picking images: $e");
154    }
155  }
156
157  @override
158  Widget build(BuildContext context) {
159    if (_currentProject == null) {
160      return const Scaffold(
161        backgroundColor: Variables.background,
162        body: Center(child: CircularProgressIndicator()),
163      );
164    }
165
166    return Scaffold(
167      backgroundColor: Variables.background,
168      appBar: TopBar(
169        currentProjectId: _currentProject!.id!,
170        onBack: () => Navigator.pop(context),
171        onProjectChanged: _onProjectChanged,
172        isAlternateView: _showAlternateView,
173        onLayoutToggle: () {
174          setState(() {
175            _showAlternateView = !_showAlternateView;
176            if (!_showAlternateView) _loadImagesForSelected();
177          });
178        },
179        onAIPressed: () {
180          Navigator.push(
181            context,
182            MaterialPageRoute(
183              builder:
184                  (_) => StylesheetPage(
185                    projectId: _currentProject!.id!,
186                    autoGenerate: true,
187                  ),
188            ),
189          );
190        },
191      ),
192      bottomNavigationBar: BottomBar(
193        currentTab: BottomBarItem.moodboard,
194        projectId: _currentProject!.id!,
195      ),
196      floatingActionButton: FloatingActionButton(
197        onPressed: _pickAndRedirect,
198        backgroundColor: Variables.textPrimary,
199        foregroundColor: Variables.background,
200        child: const Icon(Icons.add_photo_alternate_outlined),
201      ),
202      body: Column(
203        children: [
204          const SizedBox(height: 16),
205          Expanded(
206            child:
207                _showAlternateView
208                    ? ProjectBoardPageAlternate(
209                      key: _alternatePageKey,
210                      projectId: _currentProject!.id!,
211                    )
212                    : _buildCategorizedView(),
213          ),
214        ],
215      ),
216    );
217  }
218
219  Widget _buildCategorizedView() {
220    if (_isLoading) return const Center(child: CircularProgressIndicator());
221    if (_categorizedImages.isEmpty) {
222      return const EmptyState(
223        icon: Icons.image_not_supported_outlined,
224        title: "No images found",
225        subtitle: "Try adding new images",
226      );
227    }
228
229    return ListView.builder(
230      padding: const EdgeInsets.fromLTRB(16, 0, 16, 80),
231      itemCount: _categorizedImages.keys.length,
232      itemBuilder: (context, index) {
233        final category = _categorizedImages.keys.elementAt(index);
234        final images = _categorizedImages[category]!;
235
236        return Container(
237          margin: const EdgeInsets.only(bottom: 16),
238          // Keep this clipping so the scrolling list cuts off cleanly at the border
239          clipBehavior: Clip.antiAlias,
240          decoration: BoxDecoration(
241            color: Colors.white,
242            border: Border.all(color: Variables.borderSubtle),
243            borderRadius: BorderRadius.circular(16),
244          ),
245          child: Column(
246            crossAxisAlignment: CrossAxisAlignment.start,
247            children: [
248              InkWell(
249                onTap: () {
250                  if (_currentProject?.id != null) {
251                    Navigator.push(
252                      context,
253                      MaterialPageRoute(
254                        builder:
255                            (_) => ProjectTagPage(
256                              projectId: _currentProject!.id!,
257                              tag: category,
258                            ),
259                      ),
260                    );
261                  }
262                },
263                child: Padding(
264                  padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
265                  child: Row(
266                    mainAxisAlignment: MainAxisAlignment.spaceBetween,
267                    children: [
268                      Text(
269                        category.toUpperCase(),
270                        style: Variables.headerStyle.copyWith(
271                          fontSize: 14,
272                          letterSpacing: 1.0,
273                        ),
274                      ),
275                      const Icon(
276                        Icons.arrow_forward,
277                        size: 16,
278                        color: Variables.textSecondary,
279                      ),
280                    ],
281                  ),
282                ),
283              ),
284              SizedBox(
285                height: 140,
286                child: ListView.separated(
287                  clipBehavior: Clip.none,
288                  padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
289                  scrollDirection: Axis.horizontal,
290                  itemCount: images.length,
291                  separatorBuilder: (_, __) => const SizedBox(width: 8),
292                  itemBuilder: (context, imgIndex) {
293                    final image = images[imgIndex];
294                    return ImageContextMenu(
295                      image: image,
296                      onImageDeleted: () => _loadImagesForSelected(),
297                      child: GestureDetector(
298                        onTap: () {
299                          Navigator.push(
300                            context,
301                            MaterialPageRoute(
302                              builder:
303                                  (_) => ProjectTagPage(
304                                    projectId: _currentProject!.id!,
305                                    tag: category,
306                                  ),
307                            ),
308                          );
309                        },
310                        child: Container(
311                          width: 120,
312                          decoration: BoxDecoration(
313                            borderRadius: BorderRadius.circular(12),
314                            color: Variables.surfaceSubtle,
315                            border: Border.all(color: Variables.borderSubtle),
316                          ),
317                          // Explicitly clip image to border radius
318                          child: ClipRRect(
319                            borderRadius: BorderRadius.circular(12),
320                            child: Stack(
321                              fit: StackFit.expand,
322                              children: [
323                                Image.file(
324                                  File(image.filePath),
325                                  fit: BoxFit.cover,
326                                  errorBuilder:
327                                      (_, __, ___) => Container(
328                                        color: Variables.surfaceSubtle,
329                                        child: const Icon(
330                                          Icons.broken_image,
331                                          color: Variables.textDisabled,
332                                        ),
333                                      ),
334                                ),
335                              ],
336                            ),
337                          ),
338                        ),
339                      ),
340                    );
341                  },
342                ),
343              ),
344            ],
345          ),
346        );
347      },
348    );
349  }
350}