+119
lib/api.dart
+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
+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
+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
+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
+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;