this repo has no description

feat: implement profile caching and follow/unfollow functionality

+678 -38
+35 -13
lib/api.dart
··· 11 11 import 'package:mime/mime.dart'; 12 12 13 13 import './auth.dart'; 14 - import 'models/comment.dart'; 15 14 import 'models/gallery.dart'; 15 + import 'models/gallery_thread.dart'; 16 16 import 'models/notification.dart' as grain; 17 17 import 'models/profile.dart'; 18 18 ··· 70 70 appLogger.i('Fetching profile for did: $did'); 71 71 final response = await http.get( 72 72 Uri.parse('$_apiUrl/xrpc/social.grain.actor.getProfile?actor=$did'), 73 - headers: {'Content-Type': 'application/json'}, 73 + headers: {'Content-Type': 'application/json', 'Authorization': "Bearer $_accessToken"}, 74 74 ); 75 75 if (response.statusCode != 200) { 76 76 appLogger.w('Failed to fetch profile: ${response.statusCode} ${response.body}'); ··· 153 153 return null; 154 154 } 155 155 final json = jsonDecode(response.body) as Map<String, dynamic>; 156 - final gallery = Gallery.fromJson(json['gallery']); 157 - final comments = (json['comments'] as List<dynamic>? ?? []) 158 - .map((c) => Comment.fromJson(c as Map<String, dynamic>)) 159 - .toList(); 160 - return GalleryThread(gallery: gallery, comments: comments); 156 + return GalleryThread.fromJson(json); 161 157 } 162 158 163 159 Future<List<grain.Notification>> getNotifications() async { ··· 506 502 return result['uri'] as String?; 507 503 } 508 504 505 + Future<String?> createFollow({required String followeeDid}) async { 506 + final session = await auth.getValidSession(); 507 + if (session == null) { 508 + appLogger.w('No valid session for createFollow'); 509 + return null; 510 + } 511 + final dpopClient = DpopHttpClient(dpopKey: session.dpopJwk); 512 + final issuer = session.issuer; 513 + final did = session.subject; 514 + final url = Uri.parse('$issuer/xrpc/com.atproto.repo.createRecord'); 515 + final record = { 516 + 'collection': 'social.grain.graph.follow', 517 + 'repo': did, 518 + 'record': {'subject': followeeDid, 'createdAt': DateTime.now().toUtc().toIso8601String()}, 519 + }; 520 + appLogger.i('Creating follow: $record'); 521 + final response = await dpopClient.send( 522 + method: 'POST', 523 + url: url, 524 + accessToken: session.accessToken, 525 + headers: {'Content-Type': 'application/json'}, 526 + body: jsonEncode(record), 527 + ); 528 + if (response.statusCode != 200 && response.statusCode != 201) { 529 + appLogger.w('Failed to create follow: \\${response.statusCode} \\${response.body}'); 530 + return null; 531 + } 532 + final result = jsonDecode(response.body) as Map<String, dynamic>; 533 + appLogger.i('Created follow result: $result'); 534 + return result['uri'] as String?; 535 + } 536 + 509 537 /// Deletes a record by its URI using DPoP authentication. 510 538 /// Returns true on success, false on failure. 511 539 Future<bool> deleteRecord(String uri) async { ··· 554 582 } 555 583 556 584 final apiService = ApiService(); 557 - 558 - class GalleryThread { 559 - final Gallery gallery; 560 - final List<Comment> comments; 561 - GalleryThread({required this.gallery, required this.comments}); 562 - }
+15
lib/models/gallery_thread.dart
··· 1 + import 'package:freezed_annotation/freezed_annotation.dart'; 2 + 3 + import 'comment.dart'; 4 + import 'gallery.dart'; 5 + 6 + part 'gallery_thread.freezed.dart'; 7 + part 'gallery_thread.g.dart'; 8 + 9 + @freezed 10 + class GalleryThread with _$GalleryThread { 11 + const factory GalleryThread({required Gallery gallery, required List<Comment> comments}) = 12 + _GalleryThread; 13 + 14 + factory GalleryThread.fromJson(Map<String, dynamic> json) => _$GalleryThreadFromJson(json); 15 + }
+211
lib/models/gallery_thread.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_thread.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 + GalleryThread _$GalleryThreadFromJson(Map<String, dynamic> json) { 19 + return _GalleryThread.fromJson(json); 20 + } 21 + 22 + /// @nodoc 23 + mixin _$GalleryThread { 24 + Gallery get gallery => throw _privateConstructorUsedError; 25 + List<Comment> get comments => throw _privateConstructorUsedError; 26 + 27 + /// Serializes this GalleryThread to a JSON map. 28 + Map<String, dynamic> toJson() => throw _privateConstructorUsedError; 29 + 30 + /// Create a copy of GalleryThread 31 + /// with the given fields replaced by the non-null parameter values. 32 + @JsonKey(includeFromJson: false, includeToJson: false) 33 + $GalleryThreadCopyWith<GalleryThread> get copyWith => 34 + throw _privateConstructorUsedError; 35 + } 36 + 37 + /// @nodoc 38 + abstract class $GalleryThreadCopyWith<$Res> { 39 + factory $GalleryThreadCopyWith( 40 + GalleryThread value, 41 + $Res Function(GalleryThread) then, 42 + ) = _$GalleryThreadCopyWithImpl<$Res, GalleryThread>; 43 + @useResult 44 + $Res call({Gallery gallery, List<Comment> comments}); 45 + 46 + $GalleryCopyWith<$Res> get gallery; 47 + } 48 + 49 + /// @nodoc 50 + class _$GalleryThreadCopyWithImpl<$Res, $Val extends GalleryThread> 51 + implements $GalleryThreadCopyWith<$Res> { 52 + _$GalleryThreadCopyWithImpl(this._value, this._then); 53 + 54 + // ignore: unused_field 55 + final $Val _value; 56 + // ignore: unused_field 57 + final $Res Function($Val) _then; 58 + 59 + /// Create a copy of GalleryThread 60 + /// with the given fields replaced by the non-null parameter values. 61 + @pragma('vm:prefer-inline') 62 + @override 63 + $Res call({Object? gallery = null, Object? comments = null}) { 64 + return _then( 65 + _value.copyWith( 66 + gallery: null == gallery 67 + ? _value.gallery 68 + : gallery // ignore: cast_nullable_to_non_nullable 69 + as Gallery, 70 + comments: null == comments 71 + ? _value.comments 72 + : comments // ignore: cast_nullable_to_non_nullable 73 + as List<Comment>, 74 + ) 75 + as $Val, 76 + ); 77 + } 78 + 79 + /// Create a copy of GalleryThread 80 + /// with the given fields replaced by the non-null parameter values. 81 + @override 82 + @pragma('vm:prefer-inline') 83 + $GalleryCopyWith<$Res> get gallery { 84 + return $GalleryCopyWith<$Res>(_value.gallery, (value) { 85 + return _then(_value.copyWith(gallery: value) as $Val); 86 + }); 87 + } 88 + } 89 + 90 + /// @nodoc 91 + abstract class _$$GalleryThreadImplCopyWith<$Res> 92 + implements $GalleryThreadCopyWith<$Res> { 93 + factory _$$GalleryThreadImplCopyWith( 94 + _$GalleryThreadImpl value, 95 + $Res Function(_$GalleryThreadImpl) then, 96 + ) = __$$GalleryThreadImplCopyWithImpl<$Res>; 97 + @override 98 + @useResult 99 + $Res call({Gallery gallery, List<Comment> comments}); 100 + 101 + @override 102 + $GalleryCopyWith<$Res> get gallery; 103 + } 104 + 105 + /// @nodoc 106 + class __$$GalleryThreadImplCopyWithImpl<$Res> 107 + extends _$GalleryThreadCopyWithImpl<$Res, _$GalleryThreadImpl> 108 + implements _$$GalleryThreadImplCopyWith<$Res> { 109 + __$$GalleryThreadImplCopyWithImpl( 110 + _$GalleryThreadImpl _value, 111 + $Res Function(_$GalleryThreadImpl) _then, 112 + ) : super(_value, _then); 113 + 114 + /// Create a copy of GalleryThread 115 + /// with the given fields replaced by the non-null parameter values. 116 + @pragma('vm:prefer-inline') 117 + @override 118 + $Res call({Object? gallery = null, Object? comments = null}) { 119 + return _then( 120 + _$GalleryThreadImpl( 121 + gallery: null == gallery 122 + ? _value.gallery 123 + : gallery // ignore: cast_nullable_to_non_nullable 124 + as Gallery, 125 + comments: null == comments 126 + ? _value._comments 127 + : comments // ignore: cast_nullable_to_non_nullable 128 + as List<Comment>, 129 + ), 130 + ); 131 + } 132 + } 133 + 134 + /// @nodoc 135 + @JsonSerializable() 136 + class _$GalleryThreadImpl implements _GalleryThread { 137 + const _$GalleryThreadImpl({ 138 + required this.gallery, 139 + required final List<Comment> comments, 140 + }) : _comments = comments; 141 + 142 + factory _$GalleryThreadImpl.fromJson(Map<String, dynamic> json) => 143 + _$$GalleryThreadImplFromJson(json); 144 + 145 + @override 146 + final Gallery gallery; 147 + final List<Comment> _comments; 148 + @override 149 + List<Comment> get comments { 150 + if (_comments is EqualUnmodifiableListView) return _comments; 151 + // ignore: implicit_dynamic_type 152 + return EqualUnmodifiableListView(_comments); 153 + } 154 + 155 + @override 156 + String toString() { 157 + return 'GalleryThread(gallery: $gallery, comments: $comments)'; 158 + } 159 + 160 + @override 161 + bool operator ==(Object other) { 162 + return identical(this, other) || 163 + (other.runtimeType == runtimeType && 164 + other is _$GalleryThreadImpl && 165 + (identical(other.gallery, gallery) || other.gallery == gallery) && 166 + const DeepCollectionEquality().equals(other._comments, _comments)); 167 + } 168 + 169 + @JsonKey(includeFromJson: false, includeToJson: false) 170 + @override 171 + int get hashCode => Object.hash( 172 + runtimeType, 173 + gallery, 174 + const DeepCollectionEquality().hash(_comments), 175 + ); 176 + 177 + /// Create a copy of GalleryThread 178 + /// with the given fields replaced by the non-null parameter values. 179 + @JsonKey(includeFromJson: false, includeToJson: false) 180 + @override 181 + @pragma('vm:prefer-inline') 182 + _$$GalleryThreadImplCopyWith<_$GalleryThreadImpl> get copyWith => 183 + __$$GalleryThreadImplCopyWithImpl<_$GalleryThreadImpl>(this, _$identity); 184 + 185 + @override 186 + Map<String, dynamic> toJson() { 187 + return _$$GalleryThreadImplToJson(this); 188 + } 189 + } 190 + 191 + abstract class _GalleryThread implements GalleryThread { 192 + const factory _GalleryThread({ 193 + required final Gallery gallery, 194 + required final List<Comment> comments, 195 + }) = _$GalleryThreadImpl; 196 + 197 + factory _GalleryThread.fromJson(Map<String, dynamic> json) = 198 + _$GalleryThreadImpl.fromJson; 199 + 200 + @override 201 + Gallery get gallery; 202 + @override 203 + List<Comment> get comments; 204 + 205 + /// Create a copy of GalleryThread 206 + /// with the given fields replaced by the non-null parameter values. 207 + @override 208 + @JsonKey(includeFromJson: false, includeToJson: false) 209 + _$$GalleryThreadImplCopyWith<_$GalleryThreadImpl> get copyWith => 210 + throw _privateConstructorUsedError; 211 + }
+21
lib/models/gallery_thread.g.dart
··· 1 + // GENERATED CODE - DO NOT MODIFY BY HAND 2 + 3 + part of 'gallery_thread.dart'; 4 + 5 + // ************************************************************************** 6 + // JsonSerializableGenerator 7 + // ************************************************************************** 8 + 9 + _$GalleryThreadImpl _$$GalleryThreadImplFromJson(Map<String, dynamic> json) => 10 + _$GalleryThreadImpl( 11 + gallery: Gallery.fromJson(json['gallery'] as Map<String, dynamic>), 12 + comments: (json['comments'] as List<dynamic>) 13 + .map((e) => Comment.fromJson(e as Map<String, dynamic>)) 14 + .toList(), 15 + ); 16 + 17 + Map<String, dynamic> _$$GalleryThreadImplToJson(_$GalleryThreadImpl instance) => 18 + <String, dynamic>{ 19 + 'gallery': instance.gallery, 20 + 'comments': instance.comments, 21 + };
+3
lib/models/profile.dart
··· 1 1 import 'package:freezed_annotation/freezed_annotation.dart'; 2 2 3 + import 'profile_viewer.dart'; 4 + 3 5 part 'profile.freezed.dart'; 4 6 part 'profile.g.dart'; 5 7 ··· 14 16 int? followersCount, 15 17 int? followsCount, 16 18 int? galleryCount, 19 + ProfileViewer? viewer, 17 20 }) = _Profile; 18 21 19 22 factory Profile.fromJson(Map<String, dynamic> json) => _$ProfileFromJson(json);
+42 -2
lib/models/profile.freezed.dart
··· 29 29 int? get followersCount => throw _privateConstructorUsedError; 30 30 int? get followsCount => throw _privateConstructorUsedError; 31 31 int? get galleryCount => throw _privateConstructorUsedError; 32 + ProfileViewer? get viewer => throw _privateConstructorUsedError; 32 33 33 34 /// Serializes this Profile to a JSON map. 34 35 Map<String, dynamic> toJson() => throw _privateConstructorUsedError; ··· 53 54 int? followersCount, 54 55 int? followsCount, 55 56 int? galleryCount, 57 + ProfileViewer? viewer, 56 58 }); 59 + 60 + $ProfileViewerCopyWith<$Res>? get viewer; 57 61 } 58 62 59 63 /// @nodoc ··· 79 83 Object? followersCount = freezed, 80 84 Object? followsCount = freezed, 81 85 Object? galleryCount = freezed, 86 + Object? viewer = freezed, 82 87 }) { 83 88 return _then( 84 89 _value.copyWith( ··· 114 119 ? _value.galleryCount 115 120 : galleryCount // ignore: cast_nullable_to_non_nullable 116 121 as int?, 122 + viewer: freezed == viewer 123 + ? _value.viewer 124 + : viewer // ignore: cast_nullable_to_non_nullable 125 + as ProfileViewer?, 117 126 ) 118 127 as $Val, 119 128 ); 120 129 } 130 + 131 + /// Create a copy of Profile 132 + /// with the given fields replaced by the non-null parameter values. 133 + @override 134 + @pragma('vm:prefer-inline') 135 + $ProfileViewerCopyWith<$Res>? get viewer { 136 + if (_value.viewer == null) { 137 + return null; 138 + } 139 + 140 + return $ProfileViewerCopyWith<$Res>(_value.viewer!, (value) { 141 + return _then(_value.copyWith(viewer: value) as $Val); 142 + }); 143 + } 121 144 } 122 145 123 146 /// @nodoc ··· 137 160 int? followersCount, 138 161 int? followsCount, 139 162 int? galleryCount, 163 + ProfileViewer? viewer, 140 164 }); 165 + 166 + @override 167 + $ProfileViewerCopyWith<$Res>? get viewer; 141 168 } 142 169 143 170 /// @nodoc ··· 162 189 Object? followersCount = freezed, 163 190 Object? followsCount = freezed, 164 191 Object? galleryCount = freezed, 192 + Object? viewer = freezed, 165 193 }) { 166 194 return _then( 167 195 _$ProfileImpl( ··· 197 225 ? _value.galleryCount 198 226 : galleryCount // ignore: cast_nullable_to_non_nullable 199 227 as int?, 228 + viewer: freezed == viewer 229 + ? _value.viewer 230 + : viewer // ignore: cast_nullable_to_non_nullable 231 + as ProfileViewer?, 200 232 ), 201 233 ); 202 234 } ··· 214 246 this.followersCount, 215 247 this.followsCount, 216 248 this.galleryCount, 249 + this.viewer, 217 250 }); 218 251 219 252 factory _$ProfileImpl.fromJson(Map<String, dynamic> json) => ··· 235 268 final int? followsCount; 236 269 @override 237 270 final int? galleryCount; 271 + @override 272 + final ProfileViewer? viewer; 238 273 239 274 @override 240 275 String toString() { 241 - return 'Profile(did: $did, handle: $handle, displayName: $displayName, description: $description, avatar: $avatar, followersCount: $followersCount, followsCount: $followsCount, galleryCount: $galleryCount)'; 276 + return 'Profile(did: $did, handle: $handle, displayName: $displayName, description: $description, avatar: $avatar, followersCount: $followersCount, followsCount: $followsCount, galleryCount: $galleryCount, viewer: $viewer)'; 242 277 } 243 278 244 279 @override ··· 258 293 (identical(other.followsCount, followsCount) || 259 294 other.followsCount == followsCount) && 260 295 (identical(other.galleryCount, galleryCount) || 261 - other.galleryCount == galleryCount)); 296 + other.galleryCount == galleryCount) && 297 + (identical(other.viewer, viewer) || other.viewer == viewer)); 262 298 } 263 299 264 300 @JsonKey(includeFromJson: false, includeToJson: false) ··· 273 309 followersCount, 274 310 followsCount, 275 311 galleryCount, 312 + viewer, 276 313 ); 277 314 278 315 /// Create a copy of Profile ··· 299 336 final int? followersCount, 300 337 final int? followsCount, 301 338 final int? galleryCount, 339 + final ProfileViewer? viewer, 302 340 }) = _$ProfileImpl; 303 341 304 342 factory _Profile.fromJson(Map<String, dynamic> json) = _$ProfileImpl.fromJson; ··· 319 357 int? get followsCount; 320 358 @override 321 359 int? get galleryCount; 360 + @override 361 + ProfileViewer? get viewer; 322 362 323 363 /// Create a copy of Profile 324 364 /// with the given fields replaced by the non-null parameter values.
+4
lib/models/profile.g.dart
··· 16 16 followersCount: (json['followersCount'] as num?)?.toInt(), 17 17 followsCount: (json['followsCount'] as num?)?.toInt(), 18 18 galleryCount: (json['galleryCount'] as num?)?.toInt(), 19 + viewer: json['viewer'] == null 20 + ? null 21 + : ProfileViewer.fromJson(json['viewer'] as Map<String, dynamic>), 19 22 ); 20 23 21 24 Map<String, dynamic> _$$ProfileImplToJson(_$ProfileImpl instance) => ··· 28 31 'followersCount': instance.followersCount, 29 32 'followsCount': instance.followsCount, 30 33 'galleryCount': instance.galleryCount, 34 + 'viewer': instance.viewer, 31 35 };
+11
lib/models/profile_viewer.dart
··· 1 + import 'package:freezed_annotation/freezed_annotation.dart'; 2 + 3 + part 'profile_viewer.freezed.dart'; 4 + part 'profile_viewer.g.dart'; 5 + 6 + @freezed 7 + class ProfileViewer with _$ProfileViewer { 8 + const factory ProfileViewer({String? following, String? followedBy}) = _ProfileViewer; 9 + 10 + factory ProfileViewer.fromJson(Map<String, dynamic> json) => _$ProfileViewerFromJson(json); 11 + }
+186
lib/models/profile_viewer.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 'profile_viewer.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 + ProfileViewer _$ProfileViewerFromJson(Map<String, dynamic> json) { 19 + return _ProfileViewer.fromJson(json); 20 + } 21 + 22 + /// @nodoc 23 + mixin _$ProfileViewer { 24 + String? get following => throw _privateConstructorUsedError; 25 + String? get followedBy => throw _privateConstructorUsedError; 26 + 27 + /// Serializes this ProfileViewer to a JSON map. 28 + Map<String, dynamic> toJson() => throw _privateConstructorUsedError; 29 + 30 + /// Create a copy of ProfileViewer 31 + /// with the given fields replaced by the non-null parameter values. 32 + @JsonKey(includeFromJson: false, includeToJson: false) 33 + $ProfileViewerCopyWith<ProfileViewer> get copyWith => 34 + throw _privateConstructorUsedError; 35 + } 36 + 37 + /// @nodoc 38 + abstract class $ProfileViewerCopyWith<$Res> { 39 + factory $ProfileViewerCopyWith( 40 + ProfileViewer value, 41 + $Res Function(ProfileViewer) then, 42 + ) = _$ProfileViewerCopyWithImpl<$Res, ProfileViewer>; 43 + @useResult 44 + $Res call({String? following, String? followedBy}); 45 + } 46 + 47 + /// @nodoc 48 + class _$ProfileViewerCopyWithImpl<$Res, $Val extends ProfileViewer> 49 + implements $ProfileViewerCopyWith<$Res> { 50 + _$ProfileViewerCopyWithImpl(this._value, this._then); 51 + 52 + // ignore: unused_field 53 + final $Val _value; 54 + // ignore: unused_field 55 + final $Res Function($Val) _then; 56 + 57 + /// Create a copy of ProfileViewer 58 + /// with the given fields replaced by the non-null parameter values. 59 + @pragma('vm:prefer-inline') 60 + @override 61 + $Res call({Object? following = freezed, Object? followedBy = freezed}) { 62 + return _then( 63 + _value.copyWith( 64 + following: freezed == following 65 + ? _value.following 66 + : following // ignore: cast_nullable_to_non_nullable 67 + as String?, 68 + followedBy: freezed == followedBy 69 + ? _value.followedBy 70 + : followedBy // ignore: cast_nullable_to_non_nullable 71 + as String?, 72 + ) 73 + as $Val, 74 + ); 75 + } 76 + } 77 + 78 + /// @nodoc 79 + abstract class _$$ProfileViewerImplCopyWith<$Res> 80 + implements $ProfileViewerCopyWith<$Res> { 81 + factory _$$ProfileViewerImplCopyWith( 82 + _$ProfileViewerImpl value, 83 + $Res Function(_$ProfileViewerImpl) then, 84 + ) = __$$ProfileViewerImplCopyWithImpl<$Res>; 85 + @override 86 + @useResult 87 + $Res call({String? following, String? followedBy}); 88 + } 89 + 90 + /// @nodoc 91 + class __$$ProfileViewerImplCopyWithImpl<$Res> 92 + extends _$ProfileViewerCopyWithImpl<$Res, _$ProfileViewerImpl> 93 + implements _$$ProfileViewerImplCopyWith<$Res> { 94 + __$$ProfileViewerImplCopyWithImpl( 95 + _$ProfileViewerImpl _value, 96 + $Res Function(_$ProfileViewerImpl) _then, 97 + ) : super(_value, _then); 98 + 99 + /// Create a copy of ProfileViewer 100 + /// with the given fields replaced by the non-null parameter values. 101 + @pragma('vm:prefer-inline') 102 + @override 103 + $Res call({Object? following = freezed, Object? followedBy = freezed}) { 104 + return _then( 105 + _$ProfileViewerImpl( 106 + following: freezed == following 107 + ? _value.following 108 + : following // ignore: cast_nullable_to_non_nullable 109 + as String?, 110 + followedBy: freezed == followedBy 111 + ? _value.followedBy 112 + : followedBy // ignore: cast_nullable_to_non_nullable 113 + as String?, 114 + ), 115 + ); 116 + } 117 + } 118 + 119 + /// @nodoc 120 + @JsonSerializable() 121 + class _$ProfileViewerImpl implements _ProfileViewer { 122 + const _$ProfileViewerImpl({this.following, this.followedBy}); 123 + 124 + factory _$ProfileViewerImpl.fromJson(Map<String, dynamic> json) => 125 + _$$ProfileViewerImplFromJson(json); 126 + 127 + @override 128 + final String? following; 129 + @override 130 + final String? followedBy; 131 + 132 + @override 133 + String toString() { 134 + return 'ProfileViewer(following: $following, followedBy: $followedBy)'; 135 + } 136 + 137 + @override 138 + bool operator ==(Object other) { 139 + return identical(this, other) || 140 + (other.runtimeType == runtimeType && 141 + other is _$ProfileViewerImpl && 142 + (identical(other.following, following) || 143 + other.following == following) && 144 + (identical(other.followedBy, followedBy) || 145 + other.followedBy == followedBy)); 146 + } 147 + 148 + @JsonKey(includeFromJson: false, includeToJson: false) 149 + @override 150 + int get hashCode => Object.hash(runtimeType, following, followedBy); 151 + 152 + /// Create a copy of ProfileViewer 153 + /// with the given fields replaced by the non-null parameter values. 154 + @JsonKey(includeFromJson: false, includeToJson: false) 155 + @override 156 + @pragma('vm:prefer-inline') 157 + _$$ProfileViewerImplCopyWith<_$ProfileViewerImpl> get copyWith => 158 + __$$ProfileViewerImplCopyWithImpl<_$ProfileViewerImpl>(this, _$identity); 159 + 160 + @override 161 + Map<String, dynamic> toJson() { 162 + return _$$ProfileViewerImplToJson(this); 163 + } 164 + } 165 + 166 + abstract class _ProfileViewer implements ProfileViewer { 167 + const factory _ProfileViewer({ 168 + final String? following, 169 + final String? followedBy, 170 + }) = _$ProfileViewerImpl; 171 + 172 + factory _ProfileViewer.fromJson(Map<String, dynamic> json) = 173 + _$ProfileViewerImpl.fromJson; 174 + 175 + @override 176 + String? get following; 177 + @override 178 + String? get followedBy; 179 + 180 + /// Create a copy of ProfileViewer 181 + /// with the given fields replaced by the non-null parameter values. 182 + @override 183 + @JsonKey(includeFromJson: false, includeToJson: false) 184 + _$$ProfileViewerImplCopyWith<_$ProfileViewerImpl> get copyWith => 185 + throw _privateConstructorUsedError; 186 + }
+19
lib/models/profile_viewer.g.dart
··· 1 + // GENERATED CODE - DO NOT MODIFY BY HAND 2 + 3 + part of 'profile_viewer.dart'; 4 + 5 + // ************************************************************************** 6 + // JsonSerializableGenerator 7 + // ************************************************************************** 8 + 9 + _$ProfileViewerImpl _$$ProfileViewerImplFromJson(Map<String, dynamic> json) => 10 + _$ProfileViewerImpl( 11 + following: json['following'] as String?, 12 + followedBy: json['followedBy'] as String?, 13 + ); 14 + 15 + Map<String, dynamic> _$$ProfileViewerImplToJson(_$ProfileViewerImpl instance) => 16 + <String, dynamic>{ 17 + 'following': instance.following, 18 + 'followedBy': instance.followedBy, 19 + };
+48
lib/providers/profile_cache_provider.dart
··· 1 + import 'package:grain/api.dart'; 2 + import 'package:grain/models/profile.dart'; 3 + import 'package:riverpod_annotation/riverpod_annotation.dart'; 4 + 5 + part 'profile_cache_provider.g.dart'; 6 + 7 + @Riverpod(keepAlive: true) 8 + class ProfileCache extends _$ProfileCache { 9 + @override 10 + Map<String, Profile> build() => {}; 11 + 12 + Future<Profile?> fetch(String did) async { 13 + if (state.containsKey(did)) { 14 + return state[did]; 15 + } 16 + final profile = await apiService.fetchProfile(did: did); 17 + if (profile != null) { 18 + state = {...state, did: profile}; 19 + } 20 + return profile; 21 + } 22 + 23 + void setProfile(Profile profile) { 24 + state = {...state, profile.did: profile}; 25 + } 26 + 27 + Future<void> toggleFollow(String followeeDid, String? followerDid) async { 28 + final profile = state[followeeDid]; 29 + if (profile == null || followerDid == null) return; 30 + final viewer = profile.viewer; 31 + final followUri = viewer?.following; 32 + if (followUri != null && followUri.isNotEmpty) { 33 + // Unfollow 34 + final success = await apiService.deleteRecord(followUri); 35 + if (success) { 36 + final updatedProfile = profile.copyWith(viewer: viewer?.copyWith(following: null)); 37 + state = {...state, followeeDid: updatedProfile}; 38 + } 39 + } else { 40 + // Follow 41 + final newFollowUri = await apiService.createFollow(followeeDid: followeeDid); 42 + if (newFollowUri != null) { 43 + final updatedProfile = profile.copyWith(viewer: viewer?.copyWith(following: newFollowUri)); 44 + state = {...state, followeeDid: updatedProfile}; 45 + } 46 + } 47 + } 48 + }
+26
lib/providers/profile_cache_provider.g.dart
··· 1 + // GENERATED CODE - DO NOT MODIFY BY HAND 2 + 3 + part of 'profile_cache_provider.dart'; 4 + 5 + // ************************************************************************** 6 + // RiverpodGenerator 7 + // ************************************************************************** 8 + 9 + String _$profileCacheHash() => r'd3110401e19ec458d25f10f955ba245d9d775436'; 10 + 11 + /// See also [ProfileCache]. 12 + @ProviderFor(ProfileCache) 13 + final profileCacheProvider = 14 + NotifierProvider<ProfileCache, Map<String, Profile>>.internal( 15 + ProfileCache.new, 16 + name: r'profileCacheProvider', 17 + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') 18 + ? null 19 + : _$profileCacheHash, 20 + dependencies: null, 21 + allTransitiveDependencies: null, 22 + ); 23 + 24 + typedef _$ProfileCache = Notifier<Map<String, Profile>>; 25 + // ignore_for_file: type=lint 26 + // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
+57 -23
lib/screens/profile_page.dart
··· 1 1 import 'package:bluesky_text/bluesky_text.dart'; 2 2 import 'package:flutter/material.dart'; 3 + import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 4 import 'package:grain/api.dart'; 4 5 import 'package:grain/app_theme.dart'; 5 6 import 'package:grain/models/gallery.dart'; 7 + import 'package:grain/providers/profile_cache_provider.dart'; 6 8 import 'package:grain/screens/hashtag_page.dart'; 9 + import 'package:grain/widgets/app_button.dart'; 7 10 import 'package:grain/widgets/app_image.dart'; 8 11 import 'package:grain/widgets/faceted_text.dart'; 9 12 10 13 import 'gallery_page.dart'; 11 14 12 - class ProfilePage extends StatefulWidget { 15 + class ProfilePage extends ConsumerStatefulWidget { 13 16 final dynamic profile; 14 17 final String? did; 15 18 final bool showAppBar; 16 19 const ProfilePage({super.key, this.profile, this.did, this.showAppBar = false}); 17 20 18 21 @override 19 - State<ProfilePage> createState() => _ProfilePageState(); 22 + ConsumerState<ProfilePage> createState() => _ProfilePageState(); 20 23 } 21 24 22 - class _ProfilePageState extends State<ProfilePage> with SingleTickerProviderStateMixin { 25 + class _ProfilePageState extends ConsumerState<ProfilePage> with SingleTickerProviderStateMixin { 23 26 dynamic _profile; 24 27 bool _loading = true; 25 28 List<Gallery> _galleries = []; ··· 103 106 } 104 107 return; 105 108 } 106 - final profile = await apiService.fetchProfile(did: did); 109 + // Use the profileCacheProvider to fetch and cache the profile 110 + final profile = await ref.read(profileCacheProvider.notifier).fetch(did); 107 111 final galleries = await apiService.fetchActorGalleries(did: did); 108 112 List<Map<String, dynamic>>? descriptionFacets; 109 113 if ((profile?.description ?? '').isNotEmpty) { ··· 127 131 @override 128 132 Widget build(BuildContext context) { 129 133 final theme = Theme.of(context); 130 - final profile = _profile ?? widget.profile; 134 + final did = widget.did ?? widget.profile?.did; 135 + final profile = did != null 136 + ? ref.watch(profileCacheProvider)[did] ?? _profile ?? widget.profile 137 + : _profile ?? widget.profile; 131 138 if (_loading) { 132 139 return Scaffold( 133 140 backgroundColor: Theme.of(context).scaffoldBackgroundColor, ··· 169 176 crossAxisAlignment: CrossAxisAlignment.start, 170 177 children: [ 171 178 const SizedBox(height: 16), 172 - if (profile.avatar != null) 173 - Align( 174 - alignment: Alignment.centerLeft, 175 - child: ClipOval( 176 - child: AppImage( 177 - url: profile.avatar, 178 - width: 64, 179 - height: 64, 180 - fit: BoxFit.cover, 179 + Row( 180 + crossAxisAlignment: CrossAxisAlignment.start, 181 + children: [ 182 + // Avatar 183 + if (profile.avatar != null) 184 + ClipOval( 185 + child: AppImage( 186 + url: profile.avatar, 187 + width: 64, 188 + height: 64, 189 + fit: BoxFit.cover, 190 + ), 191 + ) 192 + else 193 + const Icon(Icons.account_circle, size: 64, color: Colors.grey), 194 + const Spacer(), 195 + // Follow/Unfollow button 196 + if (profile.did != apiService.currentUser?.did) 197 + SizedBox( 198 + height: 28, 199 + child: AppButton( 200 + variant: profile.viewer?.following?.isNotEmpty == true 201 + ? AppButtonVariant.secondary 202 + : AppButtonVariant.primary, 203 + onPressed: () async { 204 + await ref 205 + .read(profileCacheProvider.notifier) 206 + .toggleFollow( 207 + profile.did, 208 + apiService.currentUser?.did, 209 + ); 210 + }, 211 + borderRadius: 8, 212 + padding: const EdgeInsets.symmetric( 213 + horizontal: 14, 214 + vertical: 0, 215 + ), 216 + label: (profile.viewer?.following?.isNotEmpty == true) 217 + ? 'Following' 218 + : 'Follow', 219 + ), 181 220 ), 182 - ), 183 - ) 184 - else 185 - const Align( 186 - alignment: Alignment.centerLeft, 187 - child: Icon(Icons.account_circle, size: 64, color: Colors.grey), 188 - ), 221 + ], 222 + ), 189 223 const SizedBox(height: 8), 190 224 Text( 191 225 profile.displayName ?? '', ··· 336 370 MaterialPageRoute( 337 371 builder: (context) => GalleryPage( 338 372 uri: gallery.uri, 339 - currentUserDid: profile.did, 373 + currentUserDid: apiService.currentUser?.did ?? '', 340 374 ), 341 375 ), 342 376 ); ··· 414 448 MaterialPageRoute( 415 449 builder: (context) => GalleryPage( 416 450 uri: gallery.uri, 417 - currentUserDid: profile.did, 451 + currentUserDid: apiService.currentUser?.did ?? '', 418 452 ), 419 453 ), 420 454 );