Grain flutter app
at main 356 lines 13 kB view raw
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}