this repo has no description

feat: Add functionality to fetch actor photos and update gallery details

+412 -31
+70
lib/api.dart
··· 17 17 import 'models/follows_result.dart'; 18 18 import 'models/gallery.dart'; 19 19 import 'models/gallery_item.dart'; 20 + import 'models/gallery_photo.dart'; 20 21 import 'models/gallery_thread.dart'; 21 22 import 'models/notification.dart' as grain; 22 23 import 'models/profile.dart'; 23 24 24 25 class ApiService { 26 + // ...existing code... 25 27 static const _storage = FlutterSecureStorage(); 26 28 String? _accessToken; 27 29 Profile? currentUser; ··· 110 112 galleries = 111 113 (json['items'] as List<dynamic>?)?.map((item) => Gallery.fromJson(item)).toList() ?? []; 112 114 return galleries; 115 + } 116 + 117 + Future<List<GalleryPhoto>> fetchActorPhotos({required String did}) async { 118 + appLogger.i('Fetching photos for actor did: $did'); 119 + final response = await http.get( 120 + Uri.parse('$_apiUrl/xrpc/social.grain.photo.getActorPhotos?actor=$did'), 121 + headers: {'Content-Type': 'application/json'}, 122 + ); 123 + if (response.statusCode != 200) { 124 + appLogger.w('Failed to fetch photos: ${response.statusCode} ${response.body}'); 125 + return []; 126 + } 127 + final json = jsonDecode(response.body); 128 + return (json['items'] as List<dynamic>?)?.map((item) => GalleryPhoto.fromJson(item)).toList() ?? 129 + []; 113 130 } 114 131 115 132 Future<List<Gallery>> getTimeline({String? algorithm}) async { ··· 783 800 return false; 784 801 } 785 802 appLogger.i('Gallery sort order updated successfully'); 803 + return true; 804 + } 805 + 806 + /// Updates a gallery's title and description. 807 + /// Returns true on success, false on failure. 808 + Future<bool> updateGallery({ 809 + required String galleryUri, 810 + required String title, 811 + required String description, 812 + required String createdAt, 813 + }) async { 814 + final session = await auth.getValidSession(); 815 + if (session == null) { 816 + appLogger.w('No valid session for updateGallery'); 817 + return false; 818 + } 819 + final dpopClient = DpopHttpClient(dpopKey: session.dpopJwk); 820 + final issuer = session.issuer; 821 + final did = session.subject; 822 + final url = Uri.parse('$issuer/xrpc/com.atproto.repo.putRecord'); 823 + // Extract rkey from galleryUri 824 + String rkey = ''; 825 + try { 826 + rkey = AtUri.parse(galleryUri).rkey; 827 + } catch (_) {} 828 + if (rkey.isEmpty) { 829 + appLogger.w('No rkey found in galleryUri: $galleryUri'); 830 + return false; 831 + } 832 + final record = { 833 + 'collection': 'social.grain.gallery', 834 + 'repo': did, 835 + 'rkey': rkey, 836 + 'record': { 837 + 'title': title, 838 + 'description': description, 839 + 'updatedAt': DateTime.now().toUtc().toIso8601String(), 840 + 'createdAt': createdAt, 841 + }, 842 + }; 843 + appLogger.i('Updating gallery: $record'); 844 + final response = await dpopClient.send( 845 + method: 'POST', 846 + url: url, 847 + accessToken: session.accessToken, 848 + headers: {'Content-Type': 'application/json'}, 849 + body: jsonEncode(record), 850 + ); 851 + if (response.statusCode != 200 && response.statusCode != 201) { 852 + appLogger.w('Failed to update gallery: ${response.statusCode} ${response.body}'); 853 + return false; 854 + } 855 + appLogger.i('Gallery updated successfully'); 786 856 return true; 787 857 } 788 858 }
+75
lib/providers/gallery_cache_provider.dart
··· 148 148 return (galleryUri, photoUris); 149 149 } 150 150 151 + /// Creates gallery items for existing photoUris, polls for updated gallery items, and updates the cache. 152 + /// Returns the list of new gallery item URIs if successful, or empty list otherwise. 153 + Future<List<String>> addGalleryItemsToGallery({ 154 + required String galleryUri, 155 + required List<String> photoUris, 156 + int? startPosition, 157 + }) async { 158 + // Fetch the latest gallery from the API to avoid stale state 159 + final latestGallery = await apiService.getGallery(uri: galleryUri); 160 + if (latestGallery != null) { 161 + state = {...state, galleryUri: latestGallery}; 162 + } 163 + final gallery = latestGallery ?? state[galleryUri]; 164 + final int initialCount = gallery?.items.length ?? 0; 165 + final int positionOffset = startPosition ?? initialCount; 166 + final List<String> galleryItemUris = []; 167 + int position = positionOffset; 168 + for (final photoUri in photoUris) { 169 + // Create the gallery item 170 + final itemUri = await apiService.createGalleryItem( 171 + galleryUri: galleryUri, 172 + photoUri: photoUri, 173 + position: position, 174 + ); 175 + if (itemUri != null) { 176 + galleryItemUris.add(itemUri); 177 + position++; 178 + } 179 + } 180 + // Poll for updated gallery items 181 + final expectedCount = (gallery?.items.length ?? 0) + galleryItemUris.length; 182 + await apiService.pollGalleryItems(galleryUri: galleryUri, expectedCount: expectedCount); 183 + // Fetch the updated gallery and update the cache 184 + final updatedGallery = await apiService.getGallery(uri: galleryUri); 185 + if (updatedGallery != null) { 186 + state = {...state, galleryUri: updatedGallery}; 187 + } 188 + return galleryItemUris; 189 + } 190 + 151 191 /// Deletes a gallery from the backend and removes it from the cache. 152 192 Future<void> deleteGallery(String uri) async { 153 193 await apiService.deleteRecord(uri); ··· 186 226 .toList(); 187 227 final updatedGallery = gallery.copyWith(items: updatedPhotos); 188 228 state = {...state, galleryUri: updatedGallery}; 229 + } 230 + 231 + /// Updates gallery details (title, description) and updates cache if successful. 232 + /// Updates gallery details (title, description, createdAt), polls for cid change, and updates cache. 233 + Future<bool> updateGalleryDetails({ 234 + required String galleryUri, 235 + required String title, 236 + required String description, 237 + required String createdAt, 238 + }) async { 239 + final prevGallery = state[galleryUri]; 240 + final prevCid = prevGallery?.cid; 241 + final success = await apiService.updateGallery( 242 + galleryUri: galleryUri, 243 + title: title, 244 + description: description, 245 + createdAt: createdAt, 246 + ); 247 + if (success) { 248 + final start = DateTime.now(); 249 + const timeout = Duration(seconds: 20); 250 + const pollInterval = Duration(milliseconds: 1000); 251 + Gallery? updatedGallery; 252 + while (DateTime.now().difference(start) < timeout) { 253 + updatedGallery = await apiService.getGallery(uri: galleryUri); 254 + if (updatedGallery != null && updatedGallery.cid != prevCid) { 255 + break; 256 + } 257 + await Future.delayed(pollInterval); 258 + } 259 + if (updatedGallery != null) { 260 + state = {...state, galleryUri: updatedGallery}; 261 + } 262 + } 263 + return success; 189 264 } 190 265 }
+1 -1
lib/providers/gallery_cache_provider.g.dart
··· 6 6 // RiverpodGenerator 7 7 // ************************************************************************** 8 8 9 - String _$galleryCacheHash() => r'd4561d30bcba16bf3a32f456e45c73f4c36da2e7'; 9 + String _$galleryCacheHash() => r'87470ecbbe2528ec77f1df7062bd1a29780e6732'; 10 10 11 11 /// Holds a cache of galleries by URI. 12 12 ///
+17 -1
lib/screens/gallery_edit_photos_sheet.dart
··· 2 2 import 'package:flutter/material.dart'; 3 3 import 'package:flutter/services.dart'; 4 4 import 'package:flutter_riverpod/flutter_riverpod.dart'; 5 + import 'package:grain/api.dart'; 5 6 import 'package:grain/models/gallery_photo.dart'; 6 7 import 'package:grain/providers/gallery_cache_provider.dart'; 7 8 import 'package:grain/widgets/app_button.dart'; 8 9 import 'package:grain/widgets/app_image.dart'; 9 10 import 'package:image_picker/image_picker.dart'; 11 + 12 + import 'library_photos_select_sheet.dart'; 10 13 11 14 class GalleryEditPhotosSheet extends ConsumerStatefulWidget { 12 15 final String galleryUri; ··· 37 40 @override 38 41 Widget build(BuildContext context) { 39 42 final theme = Theme.of(context); 43 + // ...existing code... 40 44 return CupertinoPageScaffold( 41 45 backgroundColor: theme.colorScheme.surface, 42 46 navigationBar: CupertinoNavigationBar( ··· 183 187 label: 'Add from library', 184 188 variant: AppButtonVariant.secondary, 185 189 onPressed: () async { 186 - // TODO: Implement add from library action 190 + if (_loading) return; 191 + final actorDid = apiService.currentUser?.did; 192 + if (actorDid == null) return; 193 + await showLibraryPhotosSelectSheet( 194 + context, 195 + actorDid: actorDid, 196 + galleryUri: widget.galleryUri, 197 + onSelect: (photos) { 198 + setState(() { 199 + _photos.addAll(photos); 200 + }); 201 + }, 202 + ); 187 203 }, 188 204 ), 189 205 ),
+28 -8
lib/screens/gallery_sort_order_sheet.dart
··· 17 17 18 18 class _GallerySortOrderSheetState extends State<GallerySortOrderSheet> { 19 19 late List<GalleryPhoto> _photos; 20 + bool _saving = false; 20 21 21 22 @override 22 23 void initState() { ··· 38 39 ), 39 40 leading: CupertinoButton( 40 41 padding: EdgeInsets.zero, 41 - onPressed: () => Navigator.of(context).maybePop(), 42 + onPressed: _saving ? null : () => Navigator.of(context).maybePop(), 42 43 child: Text( 43 44 'Cancel', 44 - style: TextStyle(color: theme.colorScheme.primary, fontWeight: FontWeight.w600), 45 + style: TextStyle( 46 + color: _saving ? Colors.grey : theme.colorScheme.primary, 47 + fontWeight: FontWeight.w600, 48 + ), 45 49 ), 46 50 ), 47 51 trailing: CupertinoButton( 48 52 padding: EdgeInsets.zero, 49 - onPressed: () { 50 - widget.onReorderDone(_photos, context); 51 - }, 52 - child: Text( 53 - 'Save', 54 - style: TextStyle(color: theme.colorScheme.primary, fontWeight: FontWeight.w600), 53 + onPressed: _saving 54 + ? null 55 + : () { 56 + setState(() => _saving = true); 57 + widget.onReorderDone(_photos, context); 58 + if (mounted) setState(() => _saving = false); 59 + }, 60 + child: Row( 61 + mainAxisSize: MainAxisSize.min, 62 + children: [ 63 + Text( 64 + 'Save', 65 + style: TextStyle( 66 + color: _saving ? Colors.grey : theme.colorScheme.primary, 67 + fontWeight: FontWeight.w600, 68 + ), 69 + ), 70 + if (_saving) ...[ 71 + const SizedBox(width: 8), 72 + SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)), 73 + ], 74 + ], 55 75 ), 56 76 ), 57 77 ),
+199
lib/screens/library_photos_select_sheet.dart
··· 1 + import 'package:flutter/cupertino.dart'; 2 + import 'package:flutter/material.dart'; 3 + import 'package:flutter_riverpod/flutter_riverpod.dart'; 4 + import 'package:font_awesome_flutter/font_awesome_flutter.dart'; 5 + import 'package:grain/api.dart'; 6 + import 'package:grain/models/gallery_photo.dart'; 7 + import 'package:grain/providers/gallery_cache_provider.dart'; 8 + import 'package:grain/widgets/app_button.dart'; 9 + import 'package:grain/widgets/app_image.dart'; 10 + 11 + class LibraryPhotosSelectSheet extends ConsumerStatefulWidget { 12 + final String actorDid; 13 + final String galleryUri; 14 + final void Function(List<GalleryPhoto>) onSelect; 15 + 16 + const LibraryPhotosSelectSheet({ 17 + super.key, 18 + required this.actorDid, 19 + required this.galleryUri, 20 + required this.onSelect, 21 + }); 22 + 23 + @override 24 + ConsumerState<LibraryPhotosSelectSheet> createState() => _LibraryPhotosSelectSheetState(); 25 + } 26 + 27 + class _LibraryPhotosSelectSheetState extends ConsumerState<LibraryPhotosSelectSheet> { 28 + List<GalleryPhoto> _photos = []; 29 + final Set<int> _selectedIndexes = {}; 30 + bool _loading = true; 31 + bool _addingItems = false; 32 + 33 + @override 34 + void initState() { 35 + super.initState(); 36 + _fetchPhotos(); 37 + } 38 + 39 + Future<void> _fetchPhotos() async { 40 + setState(() => _loading = true); 41 + final photos = await apiService.fetchActorPhotos(did: widget.actorDid); 42 + if (mounted) { 43 + setState(() { 44 + _photos = photos; 45 + _loading = false; 46 + }); 47 + } 48 + } 49 + 50 + @override 51 + Widget build(BuildContext context) { 52 + final theme = Theme.of(context); 53 + return CupertinoPageScaffold( 54 + backgroundColor: theme.colorScheme.surface, 55 + navigationBar: CupertinoNavigationBar( 56 + backgroundColor: theme.colorScheme.surface, 57 + border: Border(bottom: BorderSide(color: theme.dividerColor, width: 1)), 58 + middle: Text( 59 + 'Select Photos from Library', 60 + style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600), 61 + ), 62 + leading: CupertinoButton( 63 + padding: EdgeInsets.zero, 64 + onPressed: (_loading || _addingItems) ? null : () => Navigator.of(context).maybePop(), 65 + child: Text( 66 + 'Cancel', 67 + style: TextStyle( 68 + color: _addingItems ? Colors.grey : theme.colorScheme.primary, 69 + fontWeight: FontWeight.w600, 70 + ), 71 + ), 72 + ), 73 + ), 74 + child: SafeArea( 75 + bottom: false, 76 + child: Padding( 77 + padding: const EdgeInsets.only(top: 8, left: 16, right: 16, bottom: 24), 78 + child: Column( 79 + children: [ 80 + Expanded( 81 + child: _loading 82 + ? Center(child: CircularProgressIndicator()) 83 + : GridView.builder( 84 + itemCount: _photos.length, 85 + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( 86 + crossAxisCount: 3, 87 + crossAxisSpacing: 8, 88 + mainAxisSpacing: 8, 89 + childAspectRatio: 1, 90 + ), 91 + itemBuilder: (context, index) { 92 + final photo = _photos[index]; 93 + final selected = _selectedIndexes.contains(index); 94 + return GestureDetector( 95 + onTap: () { 96 + setState(() { 97 + if (selected) { 98 + _selectedIndexes.remove(index); 99 + } else { 100 + _selectedIndexes.add(index); 101 + } 102 + }); 103 + }, 104 + child: Stack( 105 + fit: StackFit.expand, 106 + children: [ 107 + Container( 108 + decoration: BoxDecoration( 109 + borderRadius: BorderRadius.circular(8), 110 + color: Colors.grey[200], 111 + // Remove border highlight for selected 112 + // borderRadius already specified above 113 + ), 114 + clipBehavior: Clip.antiAlias, 115 + child: Stack( 116 + fit: StackFit.expand, 117 + children: [ 118 + photo.thumb != null && photo.thumb!.isNotEmpty 119 + ? AppImage(url: photo.thumb!, fit: BoxFit.cover) 120 + : const Icon(Icons.photo, size: 48), 121 + if (selected) ...[ 122 + Container( 123 + decoration: BoxDecoration( 124 + color: Colors.black.withOpacity(0.25), 125 + borderRadius: BorderRadius.circular(8), 126 + ), 127 + ), 128 + Positioned( 129 + top: 8, 130 + right: 8, 131 + child: FaIcon( 132 + FontAwesomeIcons.checkCircle, 133 + color: Colors.white, 134 + size: 24, 135 + ), 136 + ), 137 + ], 138 + ], 139 + ), 140 + ), 141 + // ...existing code... 142 + ], 143 + ), 144 + ); 145 + }, 146 + ), 147 + ), 148 + const SizedBox(height: 16), 149 + AppButton( 150 + label: _selectedIndexes.isEmpty 151 + ? 'Add Selected Photos' 152 + : 'Add Selected (${_selectedIndexes.length}) Photos', 153 + loading: _addingItems, 154 + onPressed: _loading || _addingItems || _selectedIndexes.isEmpty 155 + ? null 156 + : () async { 157 + setState(() => _addingItems = true); 158 + final selectedPhotos = _selectedIndexes.map((i) => _photos[i]).toList(); 159 + final photoUris = selectedPhotos.map((p) => p.uri).toList(); 160 + // Call provider to add gallery items 161 + await ref 162 + .read(galleryCacheProvider.notifier) 163 + .addGalleryItemsToGallery( 164 + galleryUri: widget.galleryUri, 165 + photoUris: photoUris, 166 + ); 167 + widget.onSelect(selectedPhotos); 168 + if (mounted) setState(() => _addingItems = false); 169 + if (!context.mounted) return; 170 + Navigator.of(context).maybePop(); 171 + }, 172 + ), 173 + ], 174 + ), 175 + ), 176 + ), 177 + ); 178 + } 179 + } 180 + 181 + Future<void> showLibraryPhotosSelectSheet( 182 + BuildContext context, { 183 + required String actorDid, 184 + required String galleryUri, 185 + required void Function(List<GalleryPhoto>) onSelect, 186 + }) async { 187 + await showCupertinoSheet( 188 + context: context, 189 + useNestedNavigation: true, 190 + pageBuilder: (context) => Material( 191 + type: MaterialType.transparency, 192 + child: LibraryPhotosSelectSheet( 193 + actorDid: actorDid, 194 + galleryUri: galleryUri, 195 + onSelect: onSelect, 196 + ), 197 + ), 198 + ); 199 + }