Grain flutter app
1import 'dart:async';
2import 'dart:io';
3
4import 'package:flutter/foundation.dart';
5import 'package:grain/models/gallery_photo.dart';
6import 'package:grain/models/procedures/apply_alts_update.dart';
7import 'package:grain/models/procedures/procedures.dart';
8import 'package:grain/providers/profile_provider.dart';
9import 'package:image_picker/image_picker.dart';
10import 'package:riverpod_annotation/riverpod_annotation.dart';
11
12import '../api.dart';
13import '../app_logger.dart';
14import '../models/gallery.dart';
15import '../models/gallery_item.dart';
16import '../photo_manip.dart';
17import '../utils/exif_utils.dart';
18
19part 'gallery_cache_provider.g.dart';
20
21/// Holds a cache of galleries by URI.
22@Riverpod(keepAlive: true)
23class GalleryCache extends _$GalleryCache {
24 @override
25 Map<String, Gallery> build() => {};
26
27 void setGalleries(List<Gallery> galleries) {
28 final newMap = {for (final g in galleries) g.uri: g};
29 state = {...state, ...newMap};
30 }
31
32 void setGallery(Gallery gallery) {
33 state = {...state, gallery.uri: gallery};
34 }
35
36 void removeGallery(String uri) {
37 final newState = {...state}..remove(uri);
38 state = newState;
39 }
40
41 Gallery? getGallery(String uri) => state[uri];
42
43 void setGalleriesForActor(String did, List<Gallery> galleries) {
44 setGalleries(galleries);
45 }
46
47 Future<void> toggleFavorite(String uri) async {
48 // Fetch the latest gallery from the API to ensure up-to-date favorite state
49 final latestGallery = await apiService.getGallery(uri: uri);
50 if (latestGallery == null) return;
51 final isCurrentlyFav = latestGallery.viewer?.fav != null;
52 final isFav = !isCurrentlyFav;
53 bool success = false;
54 String? newFavUri;
55 if (isFav) {
56 final response = await apiService.createFavorite(
57 request: CreateFavoriteRequest(subject: latestGallery.uri),
58 );
59 newFavUri = response.favoriteUri;
60 success = true;
61 } else {
62 final deleteResponse = await apiService.deleteFavorite(
63 request: DeleteFavoriteRequest(uri: latestGallery.viewer?.fav ?? uri),
64 );
65 success = deleteResponse.success;
66 newFavUri = null;
67 }
68 if (success) {
69 final newCount = (latestGallery.favCount ?? 0) + (isFav ? 1 : -1);
70 final updatedViewer = latestGallery.viewer?.copyWith(fav: newFavUri);
71 final updatedGallery = latestGallery.copyWith(favCount: newCount, viewer: updatedViewer);
72 state = {...state, uri: updatedGallery};
73
74 // Push favorite change to profile provider favs
75 final profileProvider = ref.read(
76 profileNotifierProvider(apiService.currentUser!.did).notifier,
77 );
78 if (isFav) {
79 profileProvider.addFavorite(updatedGallery);
80 } else {
81 profileProvider.removeFavorite(updatedGallery.uri);
82 }
83 }
84 }
85
86 /// Removes a photo from a gallery in the cache by galleryItemUri and deletes it from the backend.
87 Future<void> removePhotoFromGallery(String galleryUri, String galleryItemUri) async {
88 final gallery = state[galleryUri];
89 if (gallery == null) return;
90 // Call backend to delete the gallery item
91 await apiService.deleteGalleryItem(request: DeleteGalleryItemRequest(uri: galleryItemUri));
92 // Remove by gallery item record URI, not photo URI
93 final updatedItems = gallery.items.where((p) => p.gallery?.item != galleryItemUri).toList();
94 final updatedGallery = gallery.copyWith(items: updatedItems);
95 state = {...state, galleryUri: updatedGallery};
96 }
97
98 Future<List<String>> uploadAndAddPhotosToGallery({
99 required String galleryUri,
100 required List<XFile> xfiles,
101 int? startPosition,
102 bool includeExif = true,
103 void Function(int imageIndex, double progress)? onProgress,
104 }) async {
105 // Fetch the latest gallery from the API to avoid stale state
106 final latestGallery = await apiService.getGallery(uri: galleryUri);
107 if (latestGallery != null) {
108 state = {...state, galleryUri: latestGallery};
109 }
110 final gallery = latestGallery ?? state[galleryUri];
111 final int initialCount = gallery?.items.length ?? 0;
112 final int positionOffset = startPosition ?? initialCount;
113 final List<String> photoUris = [];
114 int position = positionOffset;
115 for (int i = 0; i < xfiles.length; i++) {
116 final xfile = xfiles[i];
117 // Report progress if callback is provided
118 onProgress?.call(i, 0.0);
119
120 final file = File(xfile.path);
121 // Parse EXIF if requested
122 final exif = includeExif ? await parseAndNormalizeExif(file: file) : null;
123
124 // Simulate progress steps
125 for (int p = 1; p <= 10; p++) {
126 await Future.delayed(const Duration(milliseconds: 30));
127 onProgress?.call(i, p / 10.0);
128 }
129
130 // Resize the image
131 final resizedResult = await compute<File, ResizeResult>((f) => resizeImage(file: f), file);
132 // Upload the blob
133 final res = await apiService.uploadPhoto(resizedResult.file);
134 if (res.photoUri.isEmpty) continue;
135 // If EXIF data was found, create photo exif record
136 if (exif != null) {
137 await apiService.createExif(
138 request: CreateExifRequest(
139 photo: res.photoUri,
140 dateTimeOriginal: exif['dateTimeOriginal'],
141 exposureTime: exif['exposureTime'],
142 fNumber: exif['fNumber'],
143 flash: exif['flash'],
144 focalLengthIn35mmFormat: exif['focalLengthIn35mmFilm'],
145 iSO: exif['iSOSpeedRatings'],
146 lensMake: exif['lensMake'],
147 lensModel: exif['lensModel'],
148 make: exif['make'],
149 model: exif['model'],
150 ),
151 );
152 }
153
154 // Create the gallery item
155 await apiService.createGalleryItem(
156 request: CreateGalleryItemRequest(
157 galleryUri: galleryUri,
158 photoUri: res.photoUri,
159 position: position,
160 ),
161 );
162 photoUris.add(res.photoUri);
163 position++;
164 }
165 // Fetch the updated gallery and update the cache
166 final updatedGallery = await apiService.getGallery(uri: galleryUri);
167 if (updatedGallery != null) {
168 state = {...state, galleryUri: updatedGallery};
169 }
170 return photoUris;
171 }
172
173 /// Creates a new gallery, uploads photos, and adds them as gallery items.
174 /// Returns the new gallery URI and the list of new photoUris if successful, or null/empty list otherwise.
175 Future<(String?, List<String>)> createGalleryAndAddPhotos({
176 required String title,
177 required String description,
178 required List<XFile> xfiles,
179 bool includeExif = true,
180 void Function(int imageIndex, double progress)? onProgress,
181 }) async {
182 final res = await apiService.createGallery(
183 request: CreateGalleryRequest(title: title, description: description),
184 );
185 // Upload and add photos
186 final photoUris = await uploadAndAddPhotosToGallery(
187 galleryUri: res.galleryUri,
188 xfiles: xfiles,
189 includeExif: includeExif,
190 onProgress: onProgress,
191 );
192 return (res.galleryUri, photoUris);
193 }
194
195 /// Creates gallery items for existing photoUris and updates the cache.
196 /// Returns the list of new gallery item URIs if successful, or empty list otherwise.
197 Future<List<String>> addGalleryItemsToGallery({
198 required String galleryUri,
199 required List<String> photoUris,
200 int? startPosition,
201 }) async {
202 // Fetch the latest gallery from the API to avoid stale state
203 final latestGallery = await apiService.getGallery(uri: galleryUri);
204 if (latestGallery != null) {
205 state = {...state, galleryUri: latestGallery};
206 }
207 final gallery = latestGallery ?? state[galleryUri];
208 final int initialCount = gallery?.items.length ?? 0;
209 final int positionOffset = startPosition ?? initialCount;
210 final List<String> galleryItemUris = [];
211 int position = positionOffset;
212 for (final photoUri in photoUris) {
213 // Create the gallery item
214 final res = await apiService.createGalleryItem(
215 request: CreateGalleryItemRequest(
216 galleryUri: galleryUri,
217 photoUri: photoUri,
218 position: position,
219 ),
220 );
221 if (res.itemUri.isNotEmpty) {
222 galleryItemUris.add(res.itemUri);
223 position++;
224 }
225 }
226 // Fetch the updated gallery and update the cache
227 final updatedGallery = await apiService.getGallery(uri: galleryUri);
228 if (updatedGallery != null) {
229 state = {...state, galleryUri: updatedGallery};
230 }
231 return galleryItemUris;
232 }
233
234 /// Deletes a gallery from the backend and removes it from the cache.
235 Future<void> deleteGallery(String uri) async {
236 await apiService.deleteGallery(request: DeleteGalleryRequest(uri: uri));
237 removeGallery(uri);
238 }
239
240 /// Reorders gallery items and updates backend, then updates cache.
241 Future<void> reorderGalleryItems({
242 required String galleryUri,
243 required List<GalleryPhoto> newOrder,
244 }) async {
245 final gallery = state[galleryUri];
246 if (gallery == null) return;
247 final orderedItems = newOrder.map((photo) {
248 // Map GalleryPhoto to GalleryItem
249 return GalleryItem(
250 uri: photo.gallery?.item ?? '',
251 gallery: galleryUri,
252 item: photo.uri,
253 createdAt: photo.gallery?.itemCreatedAt ?? '',
254 position: photo.gallery?.itemPosition ?? 0,
255 );
256 }).toList();
257 // Optionally update positions if needed
258 for (int i = 0; i < orderedItems.length; i++) {
259 orderedItems[i] = orderedItems[i].copyWith(position: i);
260 }
261 final res = await apiService.applySort(
262 request: ApplySortRequest(
263 writes: orderedItems
264 .map((item) => ApplySortUpdate(itemUri: item.uri, position: item.position))
265 .toList(),
266 ),
267 );
268 if (!res.success) {
269 appLogger.w('Failed to reorder gallery items for $galleryUri');
270 return;
271 }
272 // Update cache with new order
273 final updatedPhotos = orderedItems
274 .where((item) => gallery.items.any((p) => p.uri == item.item))
275 .map((item) {
276 final photo = gallery.items.firstWhere((p) => p.uri == item.item);
277 return photo.copyWith(gallery: photo.gallery?.copyWith(itemPosition: item.position));
278 })
279 .toList();
280 final updatedGallery = gallery.copyWith(items: updatedPhotos);
281 state = {...state, galleryUri: updatedGallery};
282 }
283
284 /// Updates gallery details (title, description) and updates cache.
285 Future<bool> updateGalleryDetails({
286 required String galleryUri,
287 required String title,
288 required String description,
289 required String createdAt,
290 }) async {
291 try {
292 await apiService.updateGallery(
293 request: UpdateGalleryRequest(
294 galleryUri: galleryUri,
295 title: title,
296 description: description,
297 ),
298 );
299 } catch (e, st) {
300 appLogger.e('Failed to update gallery details: $e', stackTrace: st);
301 return false;
302 }
303 final updatedGallery = await apiService.getGallery(uri: galleryUri);
304 if (updatedGallery != null) {
305 state = {...state, galleryUri: updatedGallery};
306 }
307 return true;
308 }
309
310 /// Fetches timeline galleries from the API and updates the cache.
311 /// Returns the list of galleries.
312 Future<List<Gallery>> fetchTimeline({String? algorithm}) async {
313 final galleries = await apiService.getTimeline(algorithm: algorithm);
314 setGalleries(galleries);
315 return galleries;
316 }
317
318 /// Updates alt text for multiple photos by calling apiService.updatePhotos, then updates the gallery cache state manually.
319 /// [galleryUri]: The URI of the gallery containing the photos.
320 /// [altUpdates]: List of maps with keys: photoUri, alt (and optionally aspectRatio, createdAt, photo).
321 Future<bool> updatePhotoAltTexts({
322 required String galleryUri,
323 required List<Map<String, dynamic>> altUpdates,
324 }) async {
325 final res = await apiService.applyAlts(
326 request: ApplyAltsRequest(
327 writes: altUpdates.map((update) {
328 return ApplyAltsUpdate(
329 photoUri: update['photoUri'] as String,
330 alt: update['alt'] as String,
331 );
332 }).toList(),
333 ),
334 );
335 if (!res.success) return false;
336
337 // Update the gallery photos' alt text in the cache manually
338 final gallery = state[galleryUri];
339 if (gallery == null) return false;
340
341 // Build a map of photoUri to new alt text
342 final altMap = {for (final update in altUpdates) update['photoUri'] as String: update['alt']};
343
344 final updatedPhotos = gallery.items.map((photo) {
345 final newAlt = altMap[photo.uri];
346 if (newAlt != null) {
347 return photo.copyWith(alt: newAlt);
348 }
349 return photo;
350 }).toList();
351
352 final updatedGallery = gallery.copyWith(items: updatedPhotos);
353 state = {...state, galleryUri: updatedGallery};
354 return true;
355 }
356}