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