feat: Implement functionality to update alt text for multiple photos and add editing interface

+119
lib/api.dart
··· 923 923 appLogger.i('Created photo exif record result: $result'); 924 924 return result['uri'] as String?; 925 925 } 926 + 927 + /// Updates multiple photo records in the social.grain.photo collection using applyWrites. 928 + /// Each photo in [updates] should have: photoUri, photo, aspectRatio, alt, createdAt 929 + /// Returns true on success, false on failure. 930 + Future<bool> updatePhotos(List<Map<String, dynamic>> updates) async { 931 + final session = await auth.getValidSession(); 932 + if (session == null) { 933 + appLogger.w('No valid session for updatePhotosBatch'); 934 + return false; 935 + } 936 + final dpopClient = DpopHttpClient(dpopKey: session.dpopJwk); 937 + final issuer = session.issuer; 938 + final did = session.subject; 939 + final url = Uri.parse('$issuer/xrpc/com.atproto.repo.applyWrites'); 940 + 941 + // Fetch current photo records for all photos 942 + final photoRecords = await fetchPhotoRecords(); 943 + 944 + final writes = <Map<String, dynamic>>[]; 945 + for (final update in updates) { 946 + String rkey = ''; 947 + try { 948 + rkey = AtUri.parse(update['photoUri'] as String).rkey; 949 + } catch (_) {} 950 + if (rkey.isEmpty) { 951 + appLogger.w('No rkey found in photoUri: ${update['photoUri']}'); 952 + continue; 953 + } 954 + 955 + // Get the full photo record for this photoUri 956 + final record = photoRecords[update['photoUri']]; 957 + if (record == null) { 958 + appLogger.w('No photo record found for photoUri: ${update['photoUri']}'); 959 + continue; 960 + } 961 + 962 + // Use provided values or fallback to the record's values 963 + final photoBlobRef = update['photo'] ?? record['photo']; 964 + final aspectRatio = update['aspectRatio'] ?? record['aspectRatio']; 965 + final createdAt = update['createdAt'] ?? record['createdAt']; 966 + 967 + if (photoBlobRef == null) { 968 + appLogger.w('No blobRef found for photoUri: ${update['photoUri']}'); 969 + continue; 970 + } 971 + 972 + writes.add({ 973 + '\$type': 'com.atproto.repo.applyWrites#update', 974 + 'collection': 'social.grain.photo', 975 + 'rkey': rkey, 976 + 'value': { 977 + 'photo': photoBlobRef, 978 + 'aspectRatio': aspectRatio, 979 + 'alt': update['alt'] ?? '', 980 + 'createdAt': createdAt, 981 + }, 982 + }); 983 + } 984 + if (writes.isEmpty) { 985 + appLogger.w('No valid photo updates to apply'); 986 + return false; 987 + } 988 + final payload = {'repo': did, 'validate': false, 'writes': writes}; 989 + appLogger.i('Applying batch photo updates: $payload'); 990 + final response = await dpopClient.send( 991 + method: 'POST', 992 + url: url, 993 + accessToken: session.accessToken, 994 + headers: {'Content-Type': 'application/json'}, 995 + body: jsonEncode(payload), 996 + ); 997 + if (response.statusCode != 200 && response.statusCode != 201) { 998 + appLogger.w('Failed to apply batch photo updates: ${response.statusCode} ${response.body}'); 999 + return false; 1000 + } 1001 + appLogger.i('Batch photo updates applied successfully'); 1002 + return true; 1003 + } 1004 + 1005 + /// Fetches the full photo record for each photo in social.grain.photo. 1006 + /// Returns a map of photoUri -> photo record (Map`<`String, dynamic`>`). 1007 + Future<Map<String, dynamic>> fetchPhotoRecords() async { 1008 + final session = await auth.getValidSession(); 1009 + if (session == null) { 1010 + appLogger.w('No valid session for fetchPhotoRecords'); 1011 + return {}; 1012 + } 1013 + final dpopClient = DpopHttpClient(dpopKey: session.dpopJwk); 1014 + final issuer = session.issuer; 1015 + final did = session.subject; 1016 + final url = Uri.parse( 1017 + '$issuer/xrpc/com.atproto.repo.listRecords?repo=$did&collection=social.grain.photo', 1018 + ); 1019 + 1020 + final response = await dpopClient.send( 1021 + method: 'GET', 1022 + url: url, 1023 + accessToken: session.accessToken, 1024 + headers: {'Content-Type': 'application/json'}, 1025 + ); 1026 + 1027 + if (response.statusCode != 200) { 1028 + appLogger.w('Failed to list photo records: ${response.statusCode} ${response.body}'); 1029 + return {}; 1030 + } 1031 + 1032 + final json = jsonDecode(response.body) as Map<String, dynamic>; 1033 + final records = json['records'] as List<dynamic>? ?? []; 1034 + final photoRecords = <String, dynamic>{}; 1035 + 1036 + for (final record in records) { 1037 + final uri = record['uri'] as String?; 1038 + final value = record['value'] as Map<String, dynamic>?; 1039 + if (uri != null && value != null) { 1040 + photoRecords[uri] = value; 1041 + } 1042 + } 1043 + return photoRecords; 1044 + } 926 1045 } 927 1046 928 1047 final apiService = ApiService();
+30
lib/providers/gallery_cache_provider.dart
··· 327 327 setGalleries(galleries); 328 328 return galleries; 329 329 } 330 + 331 + /// Updates alt text for multiple photos by calling apiService.updatePhotos, then updates the gallery cache state manually. 332 + /// [galleryUri]: The URI of the gallery containing the photos. 333 + /// [altUpdates]: List of maps with keys: photoUri, alt (and optionally aspectRatio, createdAt, photo). 334 + Future<bool> updatePhotoAltTexts({ 335 + required String galleryUri, 336 + required List<Map<String, dynamic>> altUpdates, 337 + }) async { 338 + final success = await apiService.updatePhotos(altUpdates); 339 + if (!success) return false; 340 + 341 + // Update the gallery photos' alt text in the cache manually 342 + final gallery = state[galleryUri]; 343 + if (gallery == null) return false; 344 + 345 + // Build a map of photoUri to new alt text 346 + final altMap = {for (final update in altUpdates) update['photoUri'] as String: update['alt']}; 347 + 348 + final updatedPhotos = gallery.items.map((photo) { 349 + final newAlt = altMap[photo.uri]; 350 + if (newAlt != null) { 351 + return photo.copyWith(alt: newAlt); 352 + } 353 + return photo; 354 + }).toList(); 355 + 356 + final updatedGallery = gallery.copyWith(items: updatedPhotos); 357 + state = {...state, galleryUri: updatedGallery}; 358 + return true; 359 + } 330 360 }
+136
lib/screens/edit_alt_text_sheet.dart
··· 1 + import 'package:flutter/cupertino.dart'; 2 + import 'package:flutter/material.dart'; 3 + import 'package:flutter/services.dart'; 4 + import 'package:grain/models/gallery_photo.dart'; 5 + import 'package:grain/widgets/app_image.dart'; 6 + 7 + class EditAltTextSheet extends StatefulWidget { 8 + final List<GalleryPhoto> photos; 9 + final void Function(Map<String, String?>) onSave; 10 + 11 + const EditAltTextSheet({super.key, required this.photos, required this.onSave}); 12 + 13 + @override 14 + State<EditAltTextSheet> createState() => _EditAltTextSheetState(); 15 + } 16 + 17 + class _EditAltTextSheetState extends State<EditAltTextSheet> { 18 + late Map<String, TextEditingController> _controllers; 19 + 20 + @override 21 + void initState() { 22 + super.initState(); 23 + _controllers = { 24 + for (final photo in widget.photos) photo.uri: TextEditingController(text: photo.alt ?? ''), 25 + }; 26 + } 27 + 28 + @override 29 + void dispose() { 30 + for (final c in _controllers.values) { 31 + c.dispose(); 32 + } 33 + super.dispose(); 34 + } 35 + 36 + void _onSave() { 37 + final altTexts = {for (final photo in widget.photos) photo.uri: _controllers[photo.uri]?.text}; 38 + widget.onSave(altTexts); 39 + Navigator.of(context).pop(); 40 + } 41 + 42 + @override 43 + Widget build(BuildContext context) { 44 + final theme = Theme.of(context); 45 + return CupertinoPageScaffold( 46 + backgroundColor: theme.colorScheme.surface, 47 + navigationBar: CupertinoNavigationBar( 48 + backgroundColor: theme.colorScheme.surface, 49 + middle: Text( 50 + 'Edit alt text', 51 + style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600), 52 + ), 53 + leading: CupertinoButton( 54 + padding: EdgeInsets.zero, 55 + child: const Text('Cancel'), 56 + onPressed: () => Navigator.of(context).pop(), 57 + ), 58 + trailing: CupertinoButton( 59 + padding: EdgeInsets.zero, 60 + onPressed: _onSave, 61 + child: const Text('Save'), 62 + ), 63 + ), 64 + child: SafeArea( 65 + child: ListView.separated( 66 + padding: const EdgeInsets.all(16), 67 + itemCount: widget.photos.length, 68 + separatorBuilder: (_, __) => const SizedBox(height: 12), 69 + itemBuilder: (context, index) { 70 + final theme = Theme.of(context); 71 + final photo = widget.photos[index]; 72 + final width = photo.aspectRatio?.width; 73 + final height = photo.aspectRatio?.height; 74 + return Row( 75 + crossAxisAlignment: CrossAxisAlignment.center, 76 + children: [ 77 + SizedBox( 78 + width: 64, 79 + child: AspectRatio( 80 + aspectRatio: (width != null && height != null && width > 0 && height > 0) 81 + ? width / height 82 + : 1.0, 83 + child: AppImage( 84 + url: photo.thumb ?? photo.fullsize, 85 + borderRadius: BorderRadius.circular(8), 86 + ), 87 + ), 88 + ), 89 + const SizedBox(width: 16), 90 + Expanded( 91 + child: TextField( 92 + controller: _controllers[photo.uri], 93 + decoration: InputDecoration( 94 + hintText: 'Enter alt text', 95 + border: InputBorder.none, 96 + filled: false, 97 + contentPadding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12), 98 + hintStyle: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor), 99 + ), 100 + style: theme.textTheme.bodyMedium, 101 + cursorColor: theme.colorScheme.primary, 102 + minLines: 1, 103 + maxLines: 6, 104 + textAlignVertical: TextAlignVertical.top, 105 + scrollPhysics: const AlwaysScrollableScrollPhysics(), 106 + keyboardType: TextInputType.multiline, 107 + ), 108 + ), 109 + ], 110 + ); 111 + }, 112 + ), 113 + ), 114 + ); 115 + } 116 + } 117 + 118 + Future<void> showEditAltTextSheet( 119 + BuildContext context, { 120 + required List<GalleryPhoto> photos, 121 + required void Function(Map<String, String?>) onSave, 122 + }) async { 123 + final theme = Theme.of(context); 124 + await showCupertinoSheet( 125 + context: context, 126 + useNestedNavigation: false, 127 + pageBuilder: (context) => Material( 128 + type: MaterialType.transparency, 129 + child: EditAltTextSheet(photos: photos, onSave: onSave), 130 + ), 131 + ); 132 + // Restore status bar style or any other cleanup 133 + SystemChrome.setSystemUIOverlayStyle( 134 + theme.brightness == Brightness.dark ? SystemUiOverlayStyle.light : SystemUiOverlayStyle.dark, 135 + ); 136 + }
+10
lib/screens/gallery_action_sheet.dart
··· 7 7 final VoidCallback? onChangeSortOrder; 8 8 final Future<void> Function(BuildContext parentContext)? onDeleteGallery; 9 9 final BuildContext parentContext; 10 + final VoidCallback? onEditAltText; 10 11 11 12 const GalleryActionSheet({ 12 13 super.key, 13 14 required this.parentContext, 14 15 this.onEditDetails, 15 16 this.onEditPhotos, 17 + this.onEditAltText, 16 18 this.onChangeSortOrder, 17 19 this.onDeleteGallery, 18 20 }); ··· 37 39 onTap: () { 38 40 Navigator.of(context).pop(); 39 41 if (onEditPhotos != null) onEditPhotos!(); 42 + }, 43 + ), 44 + ListTile( 45 + leading: Icon(AppIcons.edit), 46 + title: const Text('Edit alt text'), 47 + onTap: () { 48 + Navigator.of(context).pop(); 49 + if (onEditAltText != null) onEditAltText!(); 40 50 }, 41 51 ), 42 52 ListTile(
+19 -1
lib/screens/gallery_page.dart
··· 7 7 import 'package:grain/providers/profile_provider.dart'; 8 8 import 'package:grain/screens/comments_page.dart'; 9 9 import 'package:grain/screens/create_gallery_page.dart'; 10 + import 'package:grain/screens/edit_alt_text_sheet.dart'; 10 11 import 'package:grain/screens/gallery_action_sheet.dart'; 11 12 import 'package:grain/screens/gallery_edit_photos_sheet.dart'; 12 13 import 'package:grain/screens/gallery_sort_order_sheet.dart'; ··· 141 142 }, 142 143 ); 143 144 }, 145 + onEditAltText: () { 146 + showEditAltTextSheet( 147 + context, 148 + photos: gallery.items, 149 + onSave: (altTexts) async { 150 + // altTexts: Map<String, String?> (photoUri -> alt) 151 + final altUpdates = altTexts.entries 152 + .map((e) => {'photoUri': e.key, 'alt': e.value}) 153 + .toList(); 154 + await ref 155 + .read(galleryCacheProvider.notifier) 156 + .updatePhotoAltTexts( 157 + galleryUri: gallery.uri, 158 + altUpdates: altUpdates, 159 + ); 160 + }, 161 + ); 162 + }, 144 163 onChangeSortOrder: () { 145 164 showGallerySortOrderSheet( 146 165 context, ··· 149 168 await ref 150 169 .read(galleryCacheProvider.notifier) 151 170 .reorderGalleryItems(galleryUri: gallery.uri, newOrder: newOrder); 152 - await _maybeFetchGallery(forceRefresh: true); 153 171 if (!sheetContext.mounted) return; 154 172 Navigator.of(sheetContext).pop(); 155 173 if (!mounted) return;