feat: Implement gallery item sorting and update functionality

- Added `updateGallerySortOrder` method in `ApiService` to update the sort order of gallery items.
- Introduced `GalleryItem` model to represent individual items in a gallery.
- Updated `GalleryCache` provider to handle reordering of gallery items and syncing with the backend.
- Modified `GallerySortOrderSheet` to pass context during reorder completion.
- Enhanced `ProfileWithGalleries` model to include favorite galleries.
- Refactored `ProfilePage` to directly use favorite galleries from the profile state.
- Updated JSON serialization for new fields in models.
- Improved error handling and state management in various components.

+63
lib/api.dart
··· 16 16 import 'models/followers_result.dart'; 17 17 import 'models/follows_result.dart'; 18 18 import 'models/gallery.dart'; 19 + import 'models/gallery_item.dart'; 19 20 import 'models/gallery_thread.dart'; 20 21 import 'models/notification.dart' as grain; 21 22 import 'models/profile.dart'; ··· 721 722 } 722 723 final json = jsonDecode(response.body); 723 724 return FollowsResult.fromJson(json); 725 + } 726 + 727 + /// Updates the sort order of gallery items using com.atproto.repo.applyWrites 728 + /// [galleryUri]: The URI of the gallery (at://did/social.grain.gallery/rkey) 729 + /// [sortedItemUris]: List of item URIs in the desired order 730 + /// [itemsMeta]: List of GalleryItem meta objects (must include gallery, item, createdAt, uri) 731 + /// Returns true on success, false on failure 732 + Future<bool> updateGallerySortOrder({ 733 + required String galleryUri, 734 + required List<GalleryItem> orderedItems, 735 + }) async { 736 + final session = await auth.getValidSession(); 737 + if (session == null) { 738 + appLogger.w('No valid session for updateGallerySortOrder'); 739 + return false; 740 + } 741 + final dpopClient = DpopHttpClient(dpopKey: session.dpopJwk); 742 + final issuer = session.issuer; 743 + final did = session.subject; 744 + final url = Uri.parse('$issuer/xrpc/com.atproto.repo.applyWrites'); 745 + 746 + final updates = <Map<String, dynamic>>[]; 747 + int position = 0; 748 + for (final item in orderedItems) { 749 + String rkey = ''; 750 + try { 751 + rkey = AtUri.parse(item.uri).rkey; 752 + } catch (_) {} 753 + updates.add({ 754 + '\$type': 'com.atproto.repo.applyWrites#update', 755 + 'collection': 'social.grain.gallery.item', 756 + 'rkey': rkey, 757 + 'value': { 758 + 'gallery': item.gallery, 759 + 'item': item.item, 760 + 'createdAt': item.createdAt, 761 + 'position': position, 762 + }, 763 + }); 764 + position++; 765 + } 766 + if (updates.isEmpty) { 767 + appLogger.w('No updates to apply for gallery sort order'); 768 + return false; 769 + } 770 + final payload = {'repo': did, 'validate': false, 'writes': updates}; 771 + appLogger.i('Applying gallery sort order updates: $payload'); 772 + final response = await dpopClient.send( 773 + method: 'POST', 774 + url: url, 775 + accessToken: session.accessToken, 776 + headers: {'Content-Type': 'application/json'}, 777 + body: jsonEncode(payload), 778 + ); 779 + if (response.statusCode != 200 && response.statusCode != 201) { 780 + appLogger.w( 781 + 'Failed to apply gallery sort order: \\${response.statusCode} \\${response.body}', 782 + ); 783 + return false; 784 + } 785 + appLogger.i('Gallery sort order updated successfully'); 786 + return true; 724 787 } 725 788 } 726 789
+17
lib/models/gallery_item.dart
··· 1 + import 'package:freezed_annotation/freezed_annotation.dart'; 2 + 3 + part 'gallery_item.freezed.dart'; 4 + part 'gallery_item.g.dart'; 5 + 6 + @freezed 7 + class GalleryItem with _$GalleryItem { 8 + const factory GalleryItem({ 9 + required String uri, 10 + required String gallery, 11 + required String item, 12 + required String createdAt, 13 + required int position, 14 + }) = _GalleryItem; 15 + 16 + factory GalleryItem.fromJson(Map<String, dynamic> json) => _$GalleryItemFromJson(json); 17 + }
+262
lib/models/gallery_item.freezed.dart
··· 1 + // coverage:ignore-file 2 + // GENERATED CODE - DO NOT MODIFY BY HAND 3 + // ignore_for_file: type=lint 4 + // ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark 5 + 6 + part of 'gallery_item.dart'; 7 + 8 + // ************************************************************************** 9 + // FreezedGenerator 10 + // ************************************************************************** 11 + 12 + T _$identity<T>(T value) => value; 13 + 14 + final _privateConstructorUsedError = UnsupportedError( 15 + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', 16 + ); 17 + 18 + GalleryItem _$GalleryItemFromJson(Map<String, dynamic> json) { 19 + return _GalleryItem.fromJson(json); 20 + } 21 + 22 + /// @nodoc 23 + mixin _$GalleryItem { 24 + String get uri => throw _privateConstructorUsedError; 25 + String get gallery => throw _privateConstructorUsedError; 26 + String get item => throw _privateConstructorUsedError; 27 + String get createdAt => throw _privateConstructorUsedError; 28 + int get position => throw _privateConstructorUsedError; 29 + 30 + /// Serializes this GalleryItem to a JSON map. 31 + Map<String, dynamic> toJson() => throw _privateConstructorUsedError; 32 + 33 + /// Create a copy of GalleryItem 34 + /// with the given fields replaced by the non-null parameter values. 35 + @JsonKey(includeFromJson: false, includeToJson: false) 36 + $GalleryItemCopyWith<GalleryItem> get copyWith => 37 + throw _privateConstructorUsedError; 38 + } 39 + 40 + /// @nodoc 41 + abstract class $GalleryItemCopyWith<$Res> { 42 + factory $GalleryItemCopyWith( 43 + GalleryItem value, 44 + $Res Function(GalleryItem) then, 45 + ) = _$GalleryItemCopyWithImpl<$Res, GalleryItem>; 46 + @useResult 47 + $Res call({ 48 + String uri, 49 + String gallery, 50 + String item, 51 + String createdAt, 52 + int position, 53 + }); 54 + } 55 + 56 + /// @nodoc 57 + class _$GalleryItemCopyWithImpl<$Res, $Val extends GalleryItem> 58 + implements $GalleryItemCopyWith<$Res> { 59 + _$GalleryItemCopyWithImpl(this._value, this._then); 60 + 61 + // ignore: unused_field 62 + final $Val _value; 63 + // ignore: unused_field 64 + final $Res Function($Val) _then; 65 + 66 + /// Create a copy of GalleryItem 67 + /// with the given fields replaced by the non-null parameter values. 68 + @pragma('vm:prefer-inline') 69 + @override 70 + $Res call({ 71 + Object? uri = null, 72 + Object? gallery = null, 73 + Object? item = null, 74 + Object? createdAt = null, 75 + Object? position = null, 76 + }) { 77 + return _then( 78 + _value.copyWith( 79 + uri: null == uri 80 + ? _value.uri 81 + : uri // ignore: cast_nullable_to_non_nullable 82 + as String, 83 + gallery: null == gallery 84 + ? _value.gallery 85 + : gallery // ignore: cast_nullable_to_non_nullable 86 + as String, 87 + item: null == item 88 + ? _value.item 89 + : item // ignore: cast_nullable_to_non_nullable 90 + as String, 91 + createdAt: null == createdAt 92 + ? _value.createdAt 93 + : createdAt // ignore: cast_nullable_to_non_nullable 94 + as String, 95 + position: null == position 96 + ? _value.position 97 + : position // ignore: cast_nullable_to_non_nullable 98 + as int, 99 + ) 100 + as $Val, 101 + ); 102 + } 103 + } 104 + 105 + /// @nodoc 106 + abstract class _$$GalleryItemImplCopyWith<$Res> 107 + implements $GalleryItemCopyWith<$Res> { 108 + factory _$$GalleryItemImplCopyWith( 109 + _$GalleryItemImpl value, 110 + $Res Function(_$GalleryItemImpl) then, 111 + ) = __$$GalleryItemImplCopyWithImpl<$Res>; 112 + @override 113 + @useResult 114 + $Res call({ 115 + String uri, 116 + String gallery, 117 + String item, 118 + String createdAt, 119 + int position, 120 + }); 121 + } 122 + 123 + /// @nodoc 124 + class __$$GalleryItemImplCopyWithImpl<$Res> 125 + extends _$GalleryItemCopyWithImpl<$Res, _$GalleryItemImpl> 126 + implements _$$GalleryItemImplCopyWith<$Res> { 127 + __$$GalleryItemImplCopyWithImpl( 128 + _$GalleryItemImpl _value, 129 + $Res Function(_$GalleryItemImpl) _then, 130 + ) : super(_value, _then); 131 + 132 + /// Create a copy of GalleryItem 133 + /// with the given fields replaced by the non-null parameter values. 134 + @pragma('vm:prefer-inline') 135 + @override 136 + $Res call({ 137 + Object? uri = null, 138 + Object? gallery = null, 139 + Object? item = null, 140 + Object? createdAt = null, 141 + Object? position = null, 142 + }) { 143 + return _then( 144 + _$GalleryItemImpl( 145 + uri: null == uri 146 + ? _value.uri 147 + : uri // ignore: cast_nullable_to_non_nullable 148 + as String, 149 + gallery: null == gallery 150 + ? _value.gallery 151 + : gallery // ignore: cast_nullable_to_non_nullable 152 + as String, 153 + item: null == item 154 + ? _value.item 155 + : item // ignore: cast_nullable_to_non_nullable 156 + as String, 157 + createdAt: null == createdAt 158 + ? _value.createdAt 159 + : createdAt // ignore: cast_nullable_to_non_nullable 160 + as String, 161 + position: null == position 162 + ? _value.position 163 + : position // ignore: cast_nullable_to_non_nullable 164 + as int, 165 + ), 166 + ); 167 + } 168 + } 169 + 170 + /// @nodoc 171 + @JsonSerializable() 172 + class _$GalleryItemImpl implements _GalleryItem { 173 + const _$GalleryItemImpl({ 174 + required this.uri, 175 + required this.gallery, 176 + required this.item, 177 + required this.createdAt, 178 + required this.position, 179 + }); 180 + 181 + factory _$GalleryItemImpl.fromJson(Map<String, dynamic> json) => 182 + _$$GalleryItemImplFromJson(json); 183 + 184 + @override 185 + final String uri; 186 + @override 187 + final String gallery; 188 + @override 189 + final String item; 190 + @override 191 + final String createdAt; 192 + @override 193 + final int position; 194 + 195 + @override 196 + String toString() { 197 + return 'GalleryItem(uri: $uri, gallery: $gallery, item: $item, createdAt: $createdAt, position: $position)'; 198 + } 199 + 200 + @override 201 + bool operator ==(Object other) { 202 + return identical(this, other) || 203 + (other.runtimeType == runtimeType && 204 + other is _$GalleryItemImpl && 205 + (identical(other.uri, uri) || other.uri == uri) && 206 + (identical(other.gallery, gallery) || other.gallery == gallery) && 207 + (identical(other.item, item) || other.item == item) && 208 + (identical(other.createdAt, createdAt) || 209 + other.createdAt == createdAt) && 210 + (identical(other.position, position) || 211 + other.position == position)); 212 + } 213 + 214 + @JsonKey(includeFromJson: false, includeToJson: false) 215 + @override 216 + int get hashCode => 217 + Object.hash(runtimeType, uri, gallery, item, createdAt, position); 218 + 219 + /// Create a copy of GalleryItem 220 + /// with the given fields replaced by the non-null parameter values. 221 + @JsonKey(includeFromJson: false, includeToJson: false) 222 + @override 223 + @pragma('vm:prefer-inline') 224 + _$$GalleryItemImplCopyWith<_$GalleryItemImpl> get copyWith => 225 + __$$GalleryItemImplCopyWithImpl<_$GalleryItemImpl>(this, _$identity); 226 + 227 + @override 228 + Map<String, dynamic> toJson() { 229 + return _$$GalleryItemImplToJson(this); 230 + } 231 + } 232 + 233 + abstract class _GalleryItem implements GalleryItem { 234 + const factory _GalleryItem({ 235 + required final String uri, 236 + required final String gallery, 237 + required final String item, 238 + required final String createdAt, 239 + required final int position, 240 + }) = _$GalleryItemImpl; 241 + 242 + factory _GalleryItem.fromJson(Map<String, dynamic> json) = 243 + _$GalleryItemImpl.fromJson; 244 + 245 + @override 246 + String get uri; 247 + @override 248 + String get gallery; 249 + @override 250 + String get item; 251 + @override 252 + String get createdAt; 253 + @override 254 + int get position; 255 + 256 + /// Create a copy of GalleryItem 257 + /// with the given fields replaced by the non-null parameter values. 258 + @override 259 + @JsonKey(includeFromJson: false, includeToJson: false) 260 + _$$GalleryItemImplCopyWith<_$GalleryItemImpl> get copyWith => 261 + throw _privateConstructorUsedError; 262 + }
+25
lib/models/gallery_item.g.dart
··· 1 + // GENERATED CODE - DO NOT MODIFY BY HAND 2 + 3 + part of 'gallery_item.dart'; 4 + 5 + // ************************************************************************** 6 + // JsonSerializableGenerator 7 + // ************************************************************************** 8 + 9 + _$GalleryItemImpl _$$GalleryItemImplFromJson(Map<String, dynamic> json) => 10 + _$GalleryItemImpl( 11 + uri: json['uri'] as String, 12 + gallery: json['gallery'] as String, 13 + item: json['item'] as String, 14 + createdAt: json['createdAt'] as String, 15 + position: (json['position'] as num).toInt(), 16 + ); 17 + 18 + Map<String, dynamic> _$$GalleryItemImplToJson(_$GalleryItemImpl instance) => 19 + <String, dynamic>{ 20 + 'uri': instance.uri, 21 + 'gallery': instance.gallery, 22 + 'item': instance.item, 23 + 'createdAt': instance.createdAt, 24 + 'position': instance.position, 25 + };
+2 -2
lib/models/gallery_photo.dart
··· 9 9 @freezed 10 10 class GalleryPhoto with _$GalleryPhoto { 11 11 const factory GalleryPhoto({ 12 - String? uri, 13 - String? cid, 12 + required String uri, 13 + required String cid, 14 14 String? thumb, 15 15 String? fullsize, 16 16 String? alt,
+26 -26
lib/models/gallery_photo.freezed.dart
··· 21 21 22 22 /// @nodoc 23 23 mixin _$GalleryPhoto { 24 - String? get uri => throw _privateConstructorUsedError; 25 - String? get cid => throw _privateConstructorUsedError; 24 + String get uri => throw _privateConstructorUsedError; 25 + String get cid => throw _privateConstructorUsedError; 26 26 String? get thumb => throw _privateConstructorUsedError; 27 27 String? get fullsize => throw _privateConstructorUsedError; 28 28 String? get alt => throw _privateConstructorUsedError; ··· 47 47 ) = _$GalleryPhotoCopyWithImpl<$Res, GalleryPhoto>; 48 48 @useResult 49 49 $Res call({ 50 - String? uri, 51 - String? cid, 50 + String uri, 51 + String cid, 52 52 String? thumb, 53 53 String? fullsize, 54 54 String? alt, ··· 75 75 @pragma('vm:prefer-inline') 76 76 @override 77 77 $Res call({ 78 - Object? uri = freezed, 79 - Object? cid = freezed, 78 + Object? uri = null, 79 + Object? cid = null, 80 80 Object? thumb = freezed, 81 81 Object? fullsize = freezed, 82 82 Object? alt = freezed, ··· 85 85 }) { 86 86 return _then( 87 87 _value.copyWith( 88 - uri: freezed == uri 88 + uri: null == uri 89 89 ? _value.uri 90 90 : uri // ignore: cast_nullable_to_non_nullable 91 - as String?, 92 - cid: freezed == cid 91 + as String, 92 + cid: null == cid 93 93 ? _value.cid 94 94 : cid // ignore: cast_nullable_to_non_nullable 95 - as String?, 95 + as String, 96 96 thumb: freezed == thumb 97 97 ? _value.thumb 98 98 : thumb // ignore: cast_nullable_to_non_nullable ··· 157 157 @override 158 158 @useResult 159 159 $Res call({ 160 - String? uri, 161 - String? cid, 160 + String uri, 161 + String cid, 162 162 String? thumb, 163 163 String? fullsize, 164 164 String? alt, ··· 186 186 @pragma('vm:prefer-inline') 187 187 @override 188 188 $Res call({ 189 - Object? uri = freezed, 190 - Object? cid = freezed, 189 + Object? uri = null, 190 + Object? cid = null, 191 191 Object? thumb = freezed, 192 192 Object? fullsize = freezed, 193 193 Object? alt = freezed, ··· 196 196 }) { 197 197 return _then( 198 198 _$GalleryPhotoImpl( 199 - uri: freezed == uri 199 + uri: null == uri 200 200 ? _value.uri 201 201 : uri // ignore: cast_nullable_to_non_nullable 202 - as String?, 203 - cid: freezed == cid 202 + as String, 203 + cid: null == cid 204 204 ? _value.cid 205 205 : cid // ignore: cast_nullable_to_non_nullable 206 - as String?, 206 + as String, 207 207 thumb: freezed == thumb 208 208 ? _value.thumb 209 209 : thumb // ignore: cast_nullable_to_non_nullable ··· 233 233 @JsonSerializable() 234 234 class _$GalleryPhotoImpl implements _GalleryPhoto { 235 235 const _$GalleryPhotoImpl({ 236 - this.uri, 237 - this.cid, 236 + required this.uri, 237 + required this.cid, 238 238 this.thumb, 239 239 this.fullsize, 240 240 this.alt, ··· 246 246 _$$GalleryPhotoImplFromJson(json); 247 247 248 248 @override 249 - final String? uri; 249 + final String uri; 250 250 @override 251 - final String? cid; 251 + final String cid; 252 252 @override 253 253 final String? thumb; 254 254 @override ··· 310 310 311 311 abstract class _GalleryPhoto implements GalleryPhoto { 312 312 const factory _GalleryPhoto({ 313 - final String? uri, 314 - final String? cid, 313 + required final String uri, 314 + required final String cid, 315 315 final String? thumb, 316 316 final String? fullsize, 317 317 final String? alt, ··· 323 323 _$GalleryPhotoImpl.fromJson; 324 324 325 325 @override 326 - String? get uri; 326 + String get uri; 327 327 @override 328 - String? get cid; 328 + String get cid; 329 329 @override 330 330 String? get thumb; 331 331 @override
+2 -2
lib/models/gallery_photo.g.dart
··· 8 8 9 9 _$GalleryPhotoImpl _$$GalleryPhotoImplFromJson(Map<String, dynamic> json) => 10 10 _$GalleryPhotoImpl( 11 - uri: json['uri'] as String?, 12 - cid: json['cid'] as String?, 11 + uri: json['uri'] as String, 12 + cid: json['cid'] as String, 13 13 thumb: json['thumb'] as String?, 14 14 fullsize: json['fullsize'] as String?, 15 15 alt: json['alt'] as String?,
+5 -1
lib/models/gallery_state.dart
··· 5 5 6 6 @freezed 7 7 class GalleryState with _$GalleryState { 8 - const factory GalleryState({required String item}) = _GalleryState; 8 + const factory GalleryState({ 9 + required String item, 10 + required String itemCreatedAt, 11 + int? itemPosition, 12 + }) = _GalleryState; 9 13 10 14 factory GalleryState.fromJson(Map<String, dynamic> json) => _$GalleryStateFromJson(json); 11 15 }
+56 -10
lib/models/gallery_state.freezed.dart
··· 22 22 /// @nodoc 23 23 mixin _$GalleryState { 24 24 String get item => throw _privateConstructorUsedError; 25 + String get itemCreatedAt => throw _privateConstructorUsedError; 26 + int? get itemPosition => throw _privateConstructorUsedError; 25 27 26 28 /// Serializes this GalleryState to a JSON map. 27 29 Map<String, dynamic> toJson() => throw _privateConstructorUsedError; ··· 40 42 $Res Function(GalleryState) then, 41 43 ) = _$GalleryStateCopyWithImpl<$Res, GalleryState>; 42 44 @useResult 43 - $Res call({String item}); 45 + $Res call({String item, String itemCreatedAt, int? itemPosition}); 44 46 } 45 47 46 48 /// @nodoc ··· 57 59 /// with the given fields replaced by the non-null parameter values. 58 60 @pragma('vm:prefer-inline') 59 61 @override 60 - $Res call({Object? item = null}) { 62 + $Res call({ 63 + Object? item = null, 64 + Object? itemCreatedAt = null, 65 + Object? itemPosition = freezed, 66 + }) { 61 67 return _then( 62 68 _value.copyWith( 63 69 item: null == item 64 70 ? _value.item 65 71 : item // ignore: cast_nullable_to_non_nullable 66 72 as String, 73 + itemCreatedAt: null == itemCreatedAt 74 + ? _value.itemCreatedAt 75 + : itemCreatedAt // ignore: cast_nullable_to_non_nullable 76 + as String, 77 + itemPosition: freezed == itemPosition 78 + ? _value.itemPosition 79 + : itemPosition // ignore: cast_nullable_to_non_nullable 80 + as int?, 67 81 ) 68 82 as $Val, 69 83 ); ··· 79 93 ) = __$$GalleryStateImplCopyWithImpl<$Res>; 80 94 @override 81 95 @useResult 82 - $Res call({String item}); 96 + $Res call({String item, String itemCreatedAt, int? itemPosition}); 83 97 } 84 98 85 99 /// @nodoc ··· 95 109 /// with the given fields replaced by the non-null parameter values. 96 110 @pragma('vm:prefer-inline') 97 111 @override 98 - $Res call({Object? item = null}) { 112 + $Res call({ 113 + Object? item = null, 114 + Object? itemCreatedAt = null, 115 + Object? itemPosition = freezed, 116 + }) { 99 117 return _then( 100 118 _$GalleryStateImpl( 101 119 item: null == item 102 120 ? _value.item 103 121 : item // ignore: cast_nullable_to_non_nullable 104 122 as String, 123 + itemCreatedAt: null == itemCreatedAt 124 + ? _value.itemCreatedAt 125 + : itemCreatedAt // ignore: cast_nullable_to_non_nullable 126 + as String, 127 + itemPosition: freezed == itemPosition 128 + ? _value.itemPosition 129 + : itemPosition // ignore: cast_nullable_to_non_nullable 130 + as int?, 105 131 ), 106 132 ); 107 133 } ··· 110 136 /// @nodoc 111 137 @JsonSerializable() 112 138 class _$GalleryStateImpl implements _GalleryState { 113 - const _$GalleryStateImpl({required this.item}); 139 + const _$GalleryStateImpl({ 140 + required this.item, 141 + required this.itemCreatedAt, 142 + this.itemPosition, 143 + }); 114 144 115 145 factory _$GalleryStateImpl.fromJson(Map<String, dynamic> json) => 116 146 _$$GalleryStateImplFromJson(json); 117 147 118 148 @override 119 149 final String item; 150 + @override 151 + final String itemCreatedAt; 152 + @override 153 + final int? itemPosition; 120 154 121 155 @override 122 156 String toString() { 123 - return 'GalleryState(item: $item)'; 157 + return 'GalleryState(item: $item, itemCreatedAt: $itemCreatedAt, itemPosition: $itemPosition)'; 124 158 } 125 159 126 160 @override ··· 128 162 return identical(this, other) || 129 163 (other.runtimeType == runtimeType && 130 164 other is _$GalleryStateImpl && 131 - (identical(other.item, item) || other.item == item)); 165 + (identical(other.item, item) || other.item == item) && 166 + (identical(other.itemCreatedAt, itemCreatedAt) || 167 + other.itemCreatedAt == itemCreatedAt) && 168 + (identical(other.itemPosition, itemPosition) || 169 + other.itemPosition == itemPosition)); 132 170 } 133 171 134 172 @JsonKey(includeFromJson: false, includeToJson: false) 135 173 @override 136 - int get hashCode => Object.hash(runtimeType, item); 174 + int get hashCode => 175 + Object.hash(runtimeType, item, itemCreatedAt, itemPosition); 137 176 138 177 /// Create a copy of GalleryState 139 178 /// with the given fields replaced by the non-null parameter values. ··· 150 189 } 151 190 152 191 abstract class _GalleryState implements GalleryState { 153 - const factory _GalleryState({required final String item}) = 154 - _$GalleryStateImpl; 192 + const factory _GalleryState({ 193 + required final String item, 194 + required final String itemCreatedAt, 195 + final int? itemPosition, 196 + }) = _$GalleryStateImpl; 155 197 156 198 factory _GalleryState.fromJson(Map<String, dynamic> json) = 157 199 _$GalleryStateImpl.fromJson; 158 200 159 201 @override 160 202 String get item; 203 + @override 204 + String get itemCreatedAt; 205 + @override 206 + int? get itemPosition; 161 207 162 208 /// Create a copy of GalleryState 163 209 /// with the given fields replaced by the non-null parameter values.
+10 -2
lib/models/gallery_state.g.dart
··· 7 7 // ************************************************************************** 8 8 9 9 _$GalleryStateImpl _$$GalleryStateImplFromJson(Map<String, dynamic> json) => 10 - _$GalleryStateImpl(item: json['item'] as String); 10 + _$GalleryStateImpl( 11 + item: json['item'] as String, 12 + itemCreatedAt: json['itemCreatedAt'] as String, 13 + itemPosition: (json['itemPosition'] as num?)?.toInt(), 14 + ); 11 15 12 16 Map<String, dynamic> _$$GalleryStateImplToJson(_$GalleryStateImpl instance) => 13 - <String, dynamic>{'item': instance.item}; 17 + <String, dynamic>{ 18 + 'item': instance.item, 19 + 'itemCreatedAt': instance.itemCreatedAt, 20 + 'itemPosition': instance.itemPosition, 21 + };
+5 -2
lib/models/profile_with_galleries.dart
··· 8 8 9 9 @freezed 10 10 class ProfileWithGalleries with _$ProfileWithGalleries { 11 - const factory ProfileWithGalleries({required Profile profile, required List<Gallery> galleries}) = 12 - _ProfileWithGalleries; 11 + const factory ProfileWithGalleries({ 12 + required Profile profile, 13 + required List<Gallery> galleries, 14 + List<Gallery>? favs, 15 + }) = _ProfileWithGalleries; 13 16 14 17 factory ProfileWithGalleries.fromJson(Map<String, dynamic> json) => 15 18 _$ProfileWithGalleriesFromJson(json);
+41 -7
lib/models/profile_with_galleries.freezed.dart
··· 23 23 mixin _$ProfileWithGalleries { 24 24 Profile get profile => throw _privateConstructorUsedError; 25 25 List<Gallery> get galleries => throw _privateConstructorUsedError; 26 + List<Gallery>? get favs => throw _privateConstructorUsedError; 26 27 27 28 /// Serializes this ProfileWithGalleries to a JSON map. 28 29 Map<String, dynamic> toJson() => throw _privateConstructorUsedError; ··· 41 42 $Res Function(ProfileWithGalleries) then, 42 43 ) = _$ProfileWithGalleriesCopyWithImpl<$Res, ProfileWithGalleries>; 43 44 @useResult 44 - $Res call({Profile profile, List<Gallery> galleries}); 45 + $Res call({Profile profile, List<Gallery> galleries, List<Gallery>? favs}); 45 46 46 47 $ProfileCopyWith<$Res> get profile; 47 48 } ··· 63 64 /// with the given fields replaced by the non-null parameter values. 64 65 @pragma('vm:prefer-inline') 65 66 @override 66 - $Res call({Object? profile = null, Object? galleries = null}) { 67 + $Res call({ 68 + Object? profile = null, 69 + Object? galleries = null, 70 + Object? favs = freezed, 71 + }) { 67 72 return _then( 68 73 _value.copyWith( 69 74 profile: null == profile ··· 74 79 ? _value.galleries 75 80 : galleries // ignore: cast_nullable_to_non_nullable 76 81 as List<Gallery>, 82 + favs: freezed == favs 83 + ? _value.favs 84 + : favs // ignore: cast_nullable_to_non_nullable 85 + as List<Gallery>?, 77 86 ) 78 87 as $Val, 79 88 ); ··· 99 108 ) = __$$ProfileWithGalleriesImplCopyWithImpl<$Res>; 100 109 @override 101 110 @useResult 102 - $Res call({Profile profile, List<Gallery> galleries}); 111 + $Res call({Profile profile, List<Gallery> galleries, List<Gallery>? favs}); 103 112 104 113 @override 105 114 $ProfileCopyWith<$Res> get profile; ··· 118 127 /// with the given fields replaced by the non-null parameter values. 119 128 @pragma('vm:prefer-inline') 120 129 @override 121 - $Res call({Object? profile = null, Object? galleries = null}) { 130 + $Res call({ 131 + Object? profile = null, 132 + Object? galleries = null, 133 + Object? favs = freezed, 134 + }) { 122 135 return _then( 123 136 _$ProfileWithGalleriesImpl( 124 137 profile: null == profile ··· 129 142 ? _value._galleries 130 143 : galleries // ignore: cast_nullable_to_non_nullable 131 144 as List<Gallery>, 145 + favs: freezed == favs 146 + ? _value._favs 147 + : favs // ignore: cast_nullable_to_non_nullable 148 + as List<Gallery>?, 132 149 ), 133 150 ); 134 151 } ··· 140 157 const _$ProfileWithGalleriesImpl({ 141 158 required this.profile, 142 159 required final List<Gallery> galleries, 143 - }) : _galleries = galleries; 160 + final List<Gallery>? favs, 161 + }) : _galleries = galleries, 162 + _favs = favs; 144 163 145 164 factory _$ProfileWithGalleriesImpl.fromJson(Map<String, dynamic> json) => 146 165 _$$ProfileWithGalleriesImplFromJson(json); ··· 155 174 return EqualUnmodifiableListView(_galleries); 156 175 } 157 176 177 + final List<Gallery>? _favs; 178 + @override 179 + List<Gallery>? get favs { 180 + final value = _favs; 181 + if (value == null) return null; 182 + if (_favs is EqualUnmodifiableListView) return _favs; 183 + // ignore: implicit_dynamic_type 184 + return EqualUnmodifiableListView(value); 185 + } 186 + 158 187 @override 159 188 String toString() { 160 - return 'ProfileWithGalleries(profile: $profile, galleries: $galleries)'; 189 + return 'ProfileWithGalleries(profile: $profile, galleries: $galleries, favs: $favs)'; 161 190 } 162 191 163 192 @override ··· 169 198 const DeepCollectionEquality().equals( 170 199 other._galleries, 171 200 _galleries, 172 - )); 201 + ) && 202 + const DeepCollectionEquality().equals(other._favs, _favs)); 173 203 } 174 204 175 205 @JsonKey(includeFromJson: false, includeToJson: false) ··· 178 208 runtimeType, 179 209 profile, 180 210 const DeepCollectionEquality().hash(_galleries), 211 + const DeepCollectionEquality().hash(_favs), 181 212 ); 182 213 183 214 /// Create a copy of ProfileWithGalleries ··· 202 233 const factory _ProfileWithGalleries({ 203 234 required final Profile profile, 204 235 required final List<Gallery> galleries, 236 + final List<Gallery>? favs, 205 237 }) = _$ProfileWithGalleriesImpl; 206 238 207 239 factory _ProfileWithGalleries.fromJson(Map<String, dynamic> json) = ··· 211 243 Profile get profile; 212 244 @override 213 245 List<Gallery> get galleries; 246 + @override 247 + List<Gallery>? get favs; 214 248 215 249 /// Create a copy of ProfileWithGalleries 216 250 /// with the given fields replaced by the non-null parameter values.
+4
lib/models/profile_with_galleries.g.dart
··· 13 13 galleries: (json['galleries'] as List<dynamic>) 14 14 .map((e) => Gallery.fromJson(e as Map<String, dynamic>)) 15 15 .toList(), 16 + favs: (json['favs'] as List<dynamic>?) 17 + ?.map((e) => Gallery.fromJson(e as Map<String, dynamic>)) 18 + .toList(), 16 19 ); 17 20 18 21 Map<String, dynamic> _$$ProfileWithGalleriesImplToJson( ··· 20 23 ) => <String, dynamic>{ 21 24 'profile': instance.profile, 22 25 'galleries': instance.galleries, 26 + 'favs': instance.favs, 23 27 };
+35
lib/providers/gallery_cache_provider.dart
··· 8 8 9 9 import '../api.dart'; 10 10 import '../models/gallery.dart'; 11 + import '../models/gallery_item.dart'; 11 12 import '../photo_manip.dart'; 12 13 13 14 part 'gallery_cache_provider.g.dart'; ··· 151 152 Future<void> deleteGallery(String uri) async { 152 153 await apiService.deleteRecord(uri); 153 154 removeGallery(uri); 155 + } 156 + 157 + /// Reorders gallery items and updates backend, then updates cache. 158 + Future<void> reorderGalleryItems({ 159 + required String galleryUri, 160 + required List<dynamic> newOrder, // List<GalleryPhoto> 161 + }) async { 162 + final gallery = state[galleryUri]; 163 + if (gallery == null) return; 164 + final orderedItems = newOrder.map((photo) { 165 + // Map GalleryPhoto to GalleryItem 166 + return GalleryItem( 167 + uri: photo.gallery?.item ?? '', 168 + gallery: galleryUri, 169 + item: photo.uri, 170 + createdAt: photo.gallery?.itemCreatedAt ?? '', 171 + position: photo.gallery?.itemPosition, 172 + ); 173 + }).toList(); 174 + // Optionally update positions if needed 175 + for (int i = 0; i < orderedItems.length; i++) { 176 + orderedItems[i] = orderedItems[i].copyWith(position: i); 177 + } 178 + await apiService.updateGallerySortOrder(galleryUri: galleryUri, orderedItems: orderedItems); 179 + // Update cache with new order 180 + final updatedPhotos = orderedItems 181 + .where((item) => gallery.items.any((p) => p.uri == item.item)) 182 + .map((item) { 183 + final photo = gallery.items.firstWhere((p) => p.uri == item.item); 184 + return photo.copyWith(gallery: photo.gallery?.copyWith(itemPosition: item.position)); 185 + }) 186 + .toList(); 187 + final updatedGallery = gallery.copyWith(items: updatedPhotos); 188 + state = {...state, galleryUri: updatedGallery}; 154 189 } 155 190 }
+1 -1
lib/providers/gallery_cache_provider.g.dart
··· 6 6 // RiverpodGenerator 7 7 // ************************************************************************** 8 8 9 - String _$galleryCacheHash() => r'e4736b7f521f5ad8eb9ce28bba012fbe993a66f6'; 9 + String _$galleryCacheHash() => r'd4561d30bcba16bf3a32f456e45c73f4c36da2e7'; 10 10 11 11 /// Holds a cache of galleries by URI. 12 12 ///
+23 -4
lib/providers/profile_provider.dart
··· 60 60 Future<ProfileWithGalleries?> _fetchProfile(String did) async { 61 61 final profile = await apiService.fetchProfile(did: did); 62 62 final galleries = await apiService.fetchActorGalleries(did: did); 63 + final favs = await apiService.getActorFavs(did: did); 63 64 if (profile != null) { 64 65 final facets = await computeAndFilterFacets(profile.description); 65 66 return ProfileWithGalleries( 66 67 profile: profile.copyWith(descriptionFacets: facets), 67 68 galleries: galleries, 69 + favs: favs, 68 70 ); 69 71 } 70 72 return null; ··· 127 129 ProfileWithGalleries( 128 130 profile: updated.copyWith(descriptionFacets: facets), 129 131 galleries: galleries, 132 + favs: state.value?.favs ?? [], 130 133 ), 131 134 ); 132 135 } else { ··· 154 157 followersCount: (profile.followersCount ?? 1) - 1, 155 158 ); 156 159 state = AsyncValue.data( 157 - ProfileWithGalleries(profile: updatedProfile, galleries: current!.galleries), 160 + ProfileWithGalleries( 161 + profile: updatedProfile, 162 + galleries: current?.galleries ?? [], 163 + favs: current?.favs, 164 + ), 158 165 ); 159 166 } 160 167 } else { ··· 166 173 followersCount: (profile.followersCount ?? 0) + 1, 167 174 ); 168 175 state = AsyncValue.data( 169 - ProfileWithGalleries(profile: updatedProfile, galleries: current!.galleries), 176 + ProfileWithGalleries( 177 + profile: updatedProfile, 178 + galleries: current?.galleries ?? [], 179 + favs: current?.favs, 180 + ), 170 181 ); 171 182 } 172 183 } ··· 180 191 galleryCount: (currentProfile.profile.galleryCount ?? 0) + 1, 181 192 ); 182 193 state = AsyncValue.data( 183 - ProfileWithGalleries(profile: updatedProfile, galleries: updatedGalleries), 194 + ProfileWithGalleries( 195 + profile: updatedProfile, 196 + galleries: updatedGalleries, 197 + favs: currentProfile.favs, 198 + ), 184 199 ); 185 200 } 186 201 } ··· 193 208 galleryCount: (currentProfile.profile.galleryCount ?? 1) - 1, 194 209 ); 195 210 state = AsyncValue.data( 196 - ProfileWithGalleries(profile: updatedProfile, galleries: updatedGalleries), 211 + ProfileWithGalleries( 212 + profile: updatedProfile, 213 + galleries: updatedGalleries, 214 + favs: currentProfile.favs, 215 + ), 197 216 ); 198 217 } 199 218 }
+1 -1
lib/providers/profile_provider.g.dart
··· 6 6 // RiverpodGenerator 7 7 // ************************************************************************** 8 8 9 - String _$profileNotifierHash() => r'6d06fd234febd0ddc0c638fb7cd7c013c8e78652'; 9 + String _$profileNotifierHash() => r'2ec9237c3a60bff58175d2d39f37b050eecdba78'; 10 10 11 11 /// Copied from Dart SDK 12 12 class _SystemHash {
+9 -2
lib/screens/gallery_page.dart
··· 144 144 showGallerySortOrderSheet( 145 145 context, 146 146 photos: galleryItems, 147 - onReorderDone: (newOrder) { 148 - // TODO: Save new order to backend and refresh gallery 147 + onReorderDone: (newOrder, sheetContext) async { 148 + await ref 149 + .read(galleryCacheProvider.notifier) 150 + .reorderGalleryItems(galleryUri: gallery.uri, newOrder: newOrder); 151 + await _maybeFetchGallery(forceRefresh: true); 152 + if (!sheetContext.mounted) return; 153 + Navigator.of(sheetContext).pop(); 154 + if (!mounted) return; 155 + Navigator.of(context).pop(); 149 156 }, 150 157 ); 151 158 },
+3 -4
lib/screens/gallery_sort_order_sheet.dart
··· 7 7 8 8 class GallerySortOrderSheet extends StatefulWidget { 9 9 final List<GalleryPhoto> photos; 10 - final void Function(List<GalleryPhoto>) onReorderDone; 10 + final void Function(List<GalleryPhoto>, BuildContext) onReorderDone; 11 11 12 12 const GallerySortOrderSheet({super.key, required this.photos, required this.onReorderDone}); 13 13 ··· 47 47 trailing: CupertinoButton( 48 48 padding: EdgeInsets.zero, 49 49 onPressed: () { 50 - widget.onReorderDone(_photos); 51 - Navigator.of(context).pop(); 50 + widget.onReorderDone(_photos, context); 52 51 }, 53 52 child: Text( 54 53 'Save', ··· 115 114 Future<void> showGallerySortOrderSheet( 116 115 BuildContext context, { 117 116 required List<GalleryPhoto> photos, 118 - required void Function(List<GalleryPhoto>) onReorderDone, 117 + required void Function(List<GalleryPhoto>, BuildContext) onReorderDone, 119 118 }) async { 120 119 final theme = Theme.of(context); 121 120 await showCupertinoSheet(
+2
lib/screens/home_page.dart
··· 54 54 _followingTimelineLoading = true; 55 55 }); 56 56 try { 57 + print("Fetching following timeline with algorithm: $algorithm"); 57 58 final galleries = await apiService.getTimeline(algorithm: algorithm); 59 + print("Fetched following timeline: ${galleries.length} items"); 58 60 container.read(galleryCacheProvider.notifier).setGalleries(galleries); 59 61 setState(() { 60 62 _followingTimelineUris = galleries.map((g) => g.uri).toList();
+10 -38
lib/screens/profile_page.dart
··· 28 28 } 29 29 30 30 class _ProfilePageState extends ConsumerState<ProfilePage> { 31 - List<Gallery> _favs = []; 32 - bool _favsLoading = false; 33 31 int _selectedSection = 0; // 0 = Galleries, 1 = Favs 34 - 35 - void _loadFavsIfNeeded(String? did) async { 36 - if (_selectedSection == 1 && _favs.isEmpty && !_favsLoading && did != null && did.isNotEmpty) { 37 - setState(() => _favsLoading = true); 38 - try { 39 - final favs = await apiService.getActorFavs(did: did); 40 - if (mounted) 41 - setState(() { 42 - _favs = favs; 43 - _favsLoading = false; 44 - }); 45 - } catch (e) { 46 - if (mounted) 47 - setState(() { 48 - _favs = []; 49 - _favsLoading = false; 50 - }); 51 - } 52 - } 53 - } 54 32 55 33 // Refactored: Just pop the sheet after save, don't return edited values 56 34 Future<void> _handleProfileSave( ··· 133 111 134 112 Future<void> refreshProfile() async { 135 113 if (did != null) { 136 - await ref.refresh(profileNotifierProvider(did).future); 114 + final _ = await ref.refresh(profileNotifierProvider(did).future); 137 115 setState(() {}); 138 116 } 139 117 } ··· 158 136 } 159 137 final profile = profileWithGalleries.profile; 160 138 final galleries = profileWithGalleries.galleries; 161 - 162 - // Load favs if needed when switching to favs 163 - _loadFavsIfNeeded(profile.did); 139 + final favs = profileWithGalleries.favs; 164 140 165 141 return Scaffold( 166 142 backgroundColor: theme.scaffoldBackgroundColor, ··· 359 335 selected: _selectedSection == 1, 360 336 onTap: () { 361 337 setState(() => _selectedSection = 1); 362 - _loadFavsIfNeeded(profile.did); 363 338 }, 364 339 ), 365 340 ), ··· 371 346 // Directly show the grid, not inside Expanded/NestedScrollView 372 347 _selectedSection == 0 373 348 ? _buildGalleryGrid(theme, galleries) 374 - : _buildFavsGrid(theme, _favs, _favsLoading), 349 + : _buildFavsGrid(theme, favs), 375 350 ], 376 351 ), 377 352 ), ··· 451 426 ); 452 427 } 453 428 454 - Widget _buildFavsGrid(ThemeData theme, List<Gallery> favs, bool favsLoading) { 455 - if (favsLoading) { 456 - return const Center( 457 - child: CircularProgressIndicator(strokeWidth: 2, color: AppTheme.primaryColor), 458 - ); 459 - } 460 - final itemCount = favs.length < 12 ? 12 : favs.length; 461 - if (favs.isEmpty) { 429 + Widget _buildFavsGrid(ThemeData theme, List<Gallery>? favs) { 430 + // Handle null favs more defensively 431 + final safeList = favs ?? []; 432 + final itemCount = safeList.length < 12 ? 12 : safeList.length; 433 + if (safeList.isEmpty) { 462 434 return GridView.builder( 463 435 shrinkWrap: true, 464 436 physics: const NeverScrollableScrollPhysics(), ··· 487 459 ), 488 460 itemCount: itemCount, 489 461 itemBuilder: (context, index) { 490 - if (favs.isNotEmpty && index < favs.length) { 491 - final gallery = favs[index]; 462 + if (safeList.isNotEmpty && index < safeList.length) { 463 + final gallery = safeList[index]; 492 464 final hasPhoto = 493 465 gallery.items.isNotEmpty && (gallery.items[0].thumb?.isNotEmpty ?? false); 494 466 return GestureDetector(