this repo has no description

refactor: Refactor profile management. Remove profile cache provider, implement profile provider with async fetching and updating, and integrate profile editing functionality in the UI.

+1285 -464
+72
lib/api.dart
··· 7 import 'package:grain/dpop_client.dart'; 8 import 'package:grain/main.dart'; 9 import 'package:grain/models/atproto_session.dart'; 10 import 'package:http/http.dart' as http; 11 import 'package:jose/jose.dart'; 12 import 'package:mime/mime.dart'; ··· 590 return false; 591 } 592 appLogger.i('Deleted record $uri'); 593 return true; 594 } 595 }
··· 7 import 'package:grain/dpop_client.dart'; 8 import 'package:grain/main.dart'; 9 import 'package:grain/models/atproto_session.dart'; 10 + import 'package:grain/photo_manip.dart'; 11 import 'package:http/http.dart' as http; 12 import 'package:jose/jose.dart'; 13 import 'package:mime/mime.dart'; ··· 591 return false; 592 } 593 appLogger.i('Deleted record $uri'); 594 + return true; 595 + } 596 + 597 + /// Updates the current user's profile (displayName, description, avatar). 598 + /// If avatarFile is provided, uploads it as a blob and sets avatar. 599 + /// Returns true on success, false on failure. 600 + Future<bool> updateProfile({ 601 + required String displayName, 602 + required String description, 603 + File? avatarFile, 604 + }) async { 605 + final session = await auth.getValidSession(); 606 + if (session == null) { 607 + appLogger.w('No valid session for updateProfile'); 608 + return false; 609 + } 610 + final dpopClient = DpopHttpClient(dpopKey: session.dpopJwk); 611 + final issuer = session.issuer; 612 + final did = session.subject; 613 + // Fetch the raw profile record from atproto getRecord endpoint 614 + final getUrl = Uri.parse( 615 + '$issuer/xrpc/com.atproto.repo.getRecord?repo=$did&collection=social.grain.actor.profile&rkey=self', 616 + ); 617 + final getResp = await dpopClient.send( 618 + method: 'GET', 619 + url: getUrl, 620 + accessToken: session.accessToken, 621 + headers: {'Content-Type': 'application/json'}, 622 + ); 623 + if (getResp.statusCode != 200) { 624 + appLogger.w( 625 + 'Failed to fetch raw profile record for update: \\${getResp.statusCode} \\${getResp.body}', 626 + ); 627 + return false; 628 + } 629 + final recordJson = jsonDecode(getResp.body) as Map<String, dynamic>; 630 + var avatar = recordJson['value']?['avatar']; 631 + // If avatarFile is provided, upload it and set avatar 632 + if (avatarFile != null) { 633 + try { 634 + // Resize avatar before upload using photo_manip 635 + final resizeResult = await resizeImage(file: avatarFile); 636 + final blobResult = await uploadBlob(resizeResult.file); 637 + if (blobResult != null && blobResult['blob'] != null) { 638 + avatar = blobResult['blob']; 639 + } 640 + } catch (e) { 641 + appLogger.w('Failed to upload avatar: $e'); 642 + } 643 + } 644 + // Update the profile record 645 + final url = Uri.parse('$issuer/xrpc/com.atproto.repo.putRecord'); 646 + final record = { 647 + 'collection': 'social.grain.actor.profile', 648 + 'repo': did, 649 + 'rkey': 'self', 650 + 'record': {'displayName': displayName, 'description': description, 'avatar': avatar}, 651 + }; 652 + appLogger.i('Updating profile: $record'); 653 + final response = await dpopClient.send( 654 + method: 'POST', 655 + url: url, 656 + accessToken: session.accessToken, 657 + headers: {'Content-Type': 'application/json'}, 658 + body: jsonEncode(record), 659 + ); 660 + if (response.statusCode != 200 && response.statusCode != 201) { 661 + appLogger.w('Failed to update profile: \\${response.statusCode} \\${response.body}'); 662 + return false; 663 + } 664 + appLogger.i('Profile updated successfully'); 665 return true; 666 } 667 }
+10 -2
lib/main.dart
··· 9 import 'package:grain/screens/home_page.dart'; 10 import 'package:grain/screens/splash_page.dart'; 11 12 class AppConfig { 13 static late final String apiUrl; 14 ··· 72 } 73 74 void handleSignIn() async { 75 setState(() { 76 isSignedIn = true; 77 }); ··· 80 await apiService.fetchCurrentUser(); 81 } 82 83 - void handleSignOut() async { 84 await auth.clearSession(); // Clear session data 85 setState(() { 86 isSignedIn = false; 87 }); ··· 98 ); 99 } else { 100 home = isSignedIn 101 - ? MyHomePage(title: 'Grain', onSignOut: handleSignOut) 102 : SplashPage(onSignIn: handleSignIn); 103 } 104 return MaterialApp(
··· 9 import 'package:grain/screens/home_page.dart'; 10 import 'package:grain/screens/splash_page.dart'; 11 12 + import 'providers/profile_provider.dart'; 13 + 14 class AppConfig { 15 static late final String apiUrl; 16 ··· 74 } 75 76 void handleSignIn() async { 77 + final container = ProviderScope.containerOf(context, listen: false); 78 + 79 setState(() { 80 isSignedIn = true; 81 }); ··· 84 await apiService.fetchCurrentUser(); 85 } 86 87 + void handleSignOut(BuildContext context) async { 88 + final container = ProviderScope.containerOf(context, listen: false); 89 await auth.clearSession(); // Clear session data 90 + // Invalidate Riverpod providers for profile and gallery state 91 + container.invalidate(profileNotifierProvider); 92 + // Add any other providers you want to invalidate here 93 setState(() { 94 isSignedIn = false; 95 }); ··· 106 ); 107 } else { 108 home = isSignedIn 109 + ? MyHomePage(title: 'Grain', onSignOut: () => handleSignOut(context)) 110 : SplashPage(onSignIn: handleSignIn); 111 } 112 return MaterialApp(
+1
lib/models/profile.dart
··· 8 @freezed 9 class Profile with _$Profile { 10 const factory Profile({ 11 required String did, 12 required String handle, 13 String? displayName,
··· 8 @freezed 9 class Profile with _$Profile { 10 const factory Profile({ 11 + required String cid, 12 required String did, 13 required String handle, 14 String? displayName,
+22 -1
lib/models/profile.freezed.dart
··· 21 22 /// @nodoc 23 mixin _$Profile { 24 String get did => throw _privateConstructorUsedError; 25 String get handle => throw _privateConstructorUsedError; 26 String? get displayName => throw _privateConstructorUsedError; ··· 46 _$ProfileCopyWithImpl<$Res, Profile>; 47 @useResult 48 $Res call({ 49 String did, 50 String handle, 51 String? displayName, ··· 75 @pragma('vm:prefer-inline') 76 @override 77 $Res call({ 78 Object? did = null, 79 Object? handle = null, 80 Object? displayName = freezed, ··· 87 }) { 88 return _then( 89 _value.copyWith( 90 did: null == did 91 ? _value.did 92 : did // ignore: cast_nullable_to_non_nullable ··· 152 @override 153 @useResult 154 $Res call({ 155 String did, 156 String handle, 157 String? displayName, ··· 181 @pragma('vm:prefer-inline') 182 @override 183 $Res call({ 184 Object? did = null, 185 Object? handle = null, 186 Object? displayName = freezed, ··· 193 }) { 194 return _then( 195 _$ProfileImpl( 196 did: null == did 197 ? _value.did 198 : did // ignore: cast_nullable_to_non_nullable ··· 238 @JsonSerializable() 239 class _$ProfileImpl implements _Profile { 240 const _$ProfileImpl({ 241 required this.did, 242 required this.handle, 243 this.displayName, ··· 252 factory _$ProfileImpl.fromJson(Map<String, dynamic> json) => 253 _$$ProfileImplFromJson(json); 254 255 @override 256 final String did; 257 @override ··· 273 274 @override 275 String toString() { 276 - return 'Profile(did: $did, handle: $handle, displayName: $displayName, description: $description, avatar: $avatar, followersCount: $followersCount, followsCount: $followsCount, galleryCount: $galleryCount, viewer: $viewer)'; 277 } 278 279 @override ··· 281 return identical(this, other) || 282 (other.runtimeType == runtimeType && 283 other is _$ProfileImpl && 284 (identical(other.did, did) || other.did == did) && 285 (identical(other.handle, handle) || other.handle == handle) && 286 (identical(other.displayName, displayName) || ··· 301 @override 302 int get hashCode => Object.hash( 303 runtimeType, 304 did, 305 handle, 306 displayName, ··· 328 329 abstract class _Profile implements Profile { 330 const factory _Profile({ 331 required final String did, 332 required final String handle, 333 final String? displayName, ··· 341 342 factory _Profile.fromJson(Map<String, dynamic> json) = _$ProfileImpl.fromJson; 343 344 @override 345 String get did; 346 @override
··· 21 22 /// @nodoc 23 mixin _$Profile { 24 + String get cid => throw _privateConstructorUsedError; 25 String get did => throw _privateConstructorUsedError; 26 String get handle => throw _privateConstructorUsedError; 27 String? get displayName => throw _privateConstructorUsedError; ··· 47 _$ProfileCopyWithImpl<$Res, Profile>; 48 @useResult 49 $Res call({ 50 + String cid, 51 String did, 52 String handle, 53 String? displayName, ··· 77 @pragma('vm:prefer-inline') 78 @override 79 $Res call({ 80 + Object? cid = null, 81 Object? did = null, 82 Object? handle = null, 83 Object? displayName = freezed, ··· 90 }) { 91 return _then( 92 _value.copyWith( 93 + cid: null == cid 94 + ? _value.cid 95 + : cid // ignore: cast_nullable_to_non_nullable 96 + as String, 97 did: null == did 98 ? _value.did 99 : did // ignore: cast_nullable_to_non_nullable ··· 159 @override 160 @useResult 161 $Res call({ 162 + String cid, 163 String did, 164 String handle, 165 String? displayName, ··· 189 @pragma('vm:prefer-inline') 190 @override 191 $Res call({ 192 + Object? cid = null, 193 Object? did = null, 194 Object? handle = null, 195 Object? displayName = freezed, ··· 202 }) { 203 return _then( 204 _$ProfileImpl( 205 + cid: null == cid 206 + ? _value.cid 207 + : cid // ignore: cast_nullable_to_non_nullable 208 + as String, 209 did: null == did 210 ? _value.did 211 : did // ignore: cast_nullable_to_non_nullable ··· 251 @JsonSerializable() 252 class _$ProfileImpl implements _Profile { 253 const _$ProfileImpl({ 254 + required this.cid, 255 required this.did, 256 required this.handle, 257 this.displayName, ··· 266 factory _$ProfileImpl.fromJson(Map<String, dynamic> json) => 267 _$$ProfileImplFromJson(json); 268 269 + @override 270 + final String cid; 271 @override 272 final String did; 273 @override ··· 289 290 @override 291 String toString() { 292 + return 'Profile(cid: $cid, did: $did, handle: $handle, displayName: $displayName, description: $description, avatar: $avatar, followersCount: $followersCount, followsCount: $followsCount, galleryCount: $galleryCount, viewer: $viewer)'; 293 } 294 295 @override ··· 297 return identical(this, other) || 298 (other.runtimeType == runtimeType && 299 other is _$ProfileImpl && 300 + (identical(other.cid, cid) || other.cid == cid) && 301 (identical(other.did, did) || other.did == did) && 302 (identical(other.handle, handle) || other.handle == handle) && 303 (identical(other.displayName, displayName) || ··· 318 @override 319 int get hashCode => Object.hash( 320 runtimeType, 321 + cid, 322 did, 323 handle, 324 displayName, ··· 346 347 abstract class _Profile implements Profile { 348 const factory _Profile({ 349 + required final String cid, 350 required final String did, 351 required final String handle, 352 final String? displayName, ··· 360 361 factory _Profile.fromJson(Map<String, dynamic> json) = _$ProfileImpl.fromJson; 362 363 + @override 364 + String get cid; 365 @override 366 String get did; 367 @override
+2
lib/models/profile.g.dart
··· 8 9 _$ProfileImpl _$$ProfileImplFromJson(Map<String, dynamic> json) => 10 _$ProfileImpl( 11 did: json['did'] as String, 12 handle: json['handle'] as String, 13 displayName: json['displayName'] as String?, ··· 23 24 Map<String, dynamic> _$$ProfileImplToJson(_$ProfileImpl instance) => 25 <String, dynamic>{ 26 'did': instance.did, 27 'handle': instance.handle, 28 'displayName': instance.displayName,
··· 8 9 _$ProfileImpl _$$ProfileImplFromJson(Map<String, dynamic> json) => 10 _$ProfileImpl( 11 + cid: json['cid'] as String, 12 did: json['did'] as String, 13 handle: json['handle'] as String, 14 displayName: json['displayName'] as String?, ··· 24 25 Map<String, dynamic> _$$ProfileImplToJson(_$ProfileImpl instance) => 26 <String, dynamic>{ 27 + 'cid': instance.cid, 28 'did': instance.did, 29 'handle': instance.handle, 30 'displayName': instance.displayName,
+16
lib/models/profile_with_galleries.dart
···
··· 1 + import 'package:freezed_annotation/freezed_annotation.dart'; 2 + 3 + import 'gallery.dart'; 4 + import 'profile.dart'; 5 + 6 + part 'profile_with_galleries.freezed.dart'; 7 + part 'profile_with_galleries.g.dart'; 8 + 9 + @freezed 10 + class ProfileWithGalleries with _$ProfileWithGalleries { 11 + const factory ProfileWithGalleries({required Profile profile, required List<Gallery> galleries}) = 12 + _ProfileWithGalleries; 13 + 14 + factory ProfileWithGalleries.fromJson(Map<String, dynamic> json) => 15 + _$ProfileWithGalleriesFromJson(json); 16 + }
+221
lib/models/profile_with_galleries.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_with_galleries.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 + ProfileWithGalleries _$ProfileWithGalleriesFromJson(Map<String, dynamic> json) { 19 + return _ProfileWithGalleries.fromJson(json); 20 + } 21 + 22 + /// @nodoc 23 + mixin _$ProfileWithGalleries { 24 + Profile get profile => throw _privateConstructorUsedError; 25 + List<Gallery> get galleries => throw _privateConstructorUsedError; 26 + 27 + /// Serializes this ProfileWithGalleries to a JSON map. 28 + Map<String, dynamic> toJson() => throw _privateConstructorUsedError; 29 + 30 + /// Create a copy of ProfileWithGalleries 31 + /// with the given fields replaced by the non-null parameter values. 32 + @JsonKey(includeFromJson: false, includeToJson: false) 33 + $ProfileWithGalleriesCopyWith<ProfileWithGalleries> get copyWith => 34 + throw _privateConstructorUsedError; 35 + } 36 + 37 + /// @nodoc 38 + abstract class $ProfileWithGalleriesCopyWith<$Res> { 39 + factory $ProfileWithGalleriesCopyWith( 40 + ProfileWithGalleries value, 41 + $Res Function(ProfileWithGalleries) then, 42 + ) = _$ProfileWithGalleriesCopyWithImpl<$Res, ProfileWithGalleries>; 43 + @useResult 44 + $Res call({Profile profile, List<Gallery> galleries}); 45 + 46 + $ProfileCopyWith<$Res> get profile; 47 + } 48 + 49 + /// @nodoc 50 + class _$ProfileWithGalleriesCopyWithImpl< 51 + $Res, 52 + $Val extends ProfileWithGalleries 53 + > 54 + implements $ProfileWithGalleriesCopyWith<$Res> { 55 + _$ProfileWithGalleriesCopyWithImpl(this._value, this._then); 56 + 57 + // ignore: unused_field 58 + final $Val _value; 59 + // ignore: unused_field 60 + final $Res Function($Val) _then; 61 + 62 + /// Create a copy of ProfileWithGalleries 63 + /// with the given fields replaced by the non-null parameter values. 64 + @pragma('vm:prefer-inline') 65 + @override 66 + $Res call({Object? profile = null, Object? galleries = null}) { 67 + return _then( 68 + _value.copyWith( 69 + profile: null == profile 70 + ? _value.profile 71 + : profile // ignore: cast_nullable_to_non_nullable 72 + as Profile, 73 + galleries: null == galleries 74 + ? _value.galleries 75 + : galleries // ignore: cast_nullable_to_non_nullable 76 + as List<Gallery>, 77 + ) 78 + as $Val, 79 + ); 80 + } 81 + 82 + /// Create a copy of ProfileWithGalleries 83 + /// with the given fields replaced by the non-null parameter values. 84 + @override 85 + @pragma('vm:prefer-inline') 86 + $ProfileCopyWith<$Res> get profile { 87 + return $ProfileCopyWith<$Res>(_value.profile, (value) { 88 + return _then(_value.copyWith(profile: value) as $Val); 89 + }); 90 + } 91 + } 92 + 93 + /// @nodoc 94 + abstract class _$$ProfileWithGalleriesImplCopyWith<$Res> 95 + implements $ProfileWithGalleriesCopyWith<$Res> { 96 + factory _$$ProfileWithGalleriesImplCopyWith( 97 + _$ProfileWithGalleriesImpl value, 98 + $Res Function(_$ProfileWithGalleriesImpl) then, 99 + ) = __$$ProfileWithGalleriesImplCopyWithImpl<$Res>; 100 + @override 101 + @useResult 102 + $Res call({Profile profile, List<Gallery> galleries}); 103 + 104 + @override 105 + $ProfileCopyWith<$Res> get profile; 106 + } 107 + 108 + /// @nodoc 109 + class __$$ProfileWithGalleriesImplCopyWithImpl<$Res> 110 + extends _$ProfileWithGalleriesCopyWithImpl<$Res, _$ProfileWithGalleriesImpl> 111 + implements _$$ProfileWithGalleriesImplCopyWith<$Res> { 112 + __$$ProfileWithGalleriesImplCopyWithImpl( 113 + _$ProfileWithGalleriesImpl _value, 114 + $Res Function(_$ProfileWithGalleriesImpl) _then, 115 + ) : super(_value, _then); 116 + 117 + /// Create a copy of ProfileWithGalleries 118 + /// with the given fields replaced by the non-null parameter values. 119 + @pragma('vm:prefer-inline') 120 + @override 121 + $Res call({Object? profile = null, Object? galleries = null}) { 122 + return _then( 123 + _$ProfileWithGalleriesImpl( 124 + profile: null == profile 125 + ? _value.profile 126 + : profile // ignore: cast_nullable_to_non_nullable 127 + as Profile, 128 + galleries: null == galleries 129 + ? _value._galleries 130 + : galleries // ignore: cast_nullable_to_non_nullable 131 + as List<Gallery>, 132 + ), 133 + ); 134 + } 135 + } 136 + 137 + /// @nodoc 138 + @JsonSerializable() 139 + class _$ProfileWithGalleriesImpl implements _ProfileWithGalleries { 140 + const _$ProfileWithGalleriesImpl({ 141 + required this.profile, 142 + required final List<Gallery> galleries, 143 + }) : _galleries = galleries; 144 + 145 + factory _$ProfileWithGalleriesImpl.fromJson(Map<String, dynamic> json) => 146 + _$$ProfileWithGalleriesImplFromJson(json); 147 + 148 + @override 149 + final Profile profile; 150 + final List<Gallery> _galleries; 151 + @override 152 + List<Gallery> get galleries { 153 + if (_galleries is EqualUnmodifiableListView) return _galleries; 154 + // ignore: implicit_dynamic_type 155 + return EqualUnmodifiableListView(_galleries); 156 + } 157 + 158 + @override 159 + String toString() { 160 + return 'ProfileWithGalleries(profile: $profile, galleries: $galleries)'; 161 + } 162 + 163 + @override 164 + bool operator ==(Object other) { 165 + return identical(this, other) || 166 + (other.runtimeType == runtimeType && 167 + other is _$ProfileWithGalleriesImpl && 168 + (identical(other.profile, profile) || other.profile == profile) && 169 + const DeepCollectionEquality().equals( 170 + other._galleries, 171 + _galleries, 172 + )); 173 + } 174 + 175 + @JsonKey(includeFromJson: false, includeToJson: false) 176 + @override 177 + int get hashCode => Object.hash( 178 + runtimeType, 179 + profile, 180 + const DeepCollectionEquality().hash(_galleries), 181 + ); 182 + 183 + /// Create a copy of ProfileWithGalleries 184 + /// with the given fields replaced by the non-null parameter values. 185 + @JsonKey(includeFromJson: false, includeToJson: false) 186 + @override 187 + @pragma('vm:prefer-inline') 188 + _$$ProfileWithGalleriesImplCopyWith<_$ProfileWithGalleriesImpl> 189 + get copyWith => 190 + __$$ProfileWithGalleriesImplCopyWithImpl<_$ProfileWithGalleriesImpl>( 191 + this, 192 + _$identity, 193 + ); 194 + 195 + @override 196 + Map<String, dynamic> toJson() { 197 + return _$$ProfileWithGalleriesImplToJson(this); 198 + } 199 + } 200 + 201 + abstract class _ProfileWithGalleries implements ProfileWithGalleries { 202 + const factory _ProfileWithGalleries({ 203 + required final Profile profile, 204 + required final List<Gallery> galleries, 205 + }) = _$ProfileWithGalleriesImpl; 206 + 207 + factory _ProfileWithGalleries.fromJson(Map<String, dynamic> json) = 208 + _$ProfileWithGalleriesImpl.fromJson; 209 + 210 + @override 211 + Profile get profile; 212 + @override 213 + List<Gallery> get galleries; 214 + 215 + /// Create a copy of ProfileWithGalleries 216 + /// with the given fields replaced by the non-null parameter values. 217 + @override 218 + @JsonKey(includeFromJson: false, includeToJson: false) 219 + _$$ProfileWithGalleriesImplCopyWith<_$ProfileWithGalleriesImpl> 220 + get copyWith => throw _privateConstructorUsedError; 221 + }
+23
lib/models/profile_with_galleries.g.dart
···
··· 1 + // GENERATED CODE - DO NOT MODIFY BY HAND 2 + 3 + part of 'profile_with_galleries.dart'; 4 + 5 + // ************************************************************************** 6 + // JsonSerializableGenerator 7 + // ************************************************************************** 8 + 9 + _$ProfileWithGalleriesImpl _$$ProfileWithGalleriesImplFromJson( 10 + Map<String, dynamic> json, 11 + ) => _$ProfileWithGalleriesImpl( 12 + profile: Profile.fromJson(json['profile'] as Map<String, dynamic>), 13 + galleries: (json['galleries'] as List<dynamic>) 14 + .map((e) => Gallery.fromJson(e as Map<String, dynamic>)) 15 + .toList(), 16 + ); 17 + 18 + Map<String, dynamic> _$$ProfileWithGalleriesImplToJson( 19 + _$ProfileWithGalleriesImpl instance, 20 + ) => <String, dynamic>{ 21 + 'profile': instance.profile, 22 + 'galleries': instance.galleries, 23 + };
+5
lib/providers/gallery_cache_provider.dart
··· 27 28 Gallery? getGallery(String uri) => state[uri]; 29 30 Future<void> toggleFavorite(String uri) async { 31 // Fetch the latest gallery from the API to ensure up-to-date favorite state 32 final latestGallery = await apiService.getGallery(uri: uri);
··· 27 28 Gallery? getGallery(String uri) => state[uri]; 29 30 + void setGalleriesForActor(String did, List<Gallery> galleries) { 31 + setGalleries(galleries); 32 + // Optionally, you could keep a mapping of actor DID to gallery URIs if needed 33 + } 34 + 35 Future<void> toggleFavorite(String uri) async { 36 // Fetch the latest gallery from the API to ensure up-to-date favorite state 37 final latestGallery = await apiService.getGallery(uri: uri);
+1 -1
lib/providers/gallery_cache_provider.g.dart
··· 6 // RiverpodGenerator 7 // ************************************************************************** 8 9 - String _$galleryCacheHash() => r'dc64ef86246ea4ac742837cfcc8a9353375f2144'; 10 11 /// Holds a cache of galleries by URI. 12 ///
··· 6 // RiverpodGenerator 7 // ************************************************************************** 8 9 + String _$galleryCacheHash() => r'24f705b282f56332ab62705a6b29eb3f3c80142b'; 10 11 /// Holds a cache of galleries by URI. 12 ///
-54
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( 37 - viewer: viewer?.copyWith(following: null), 38 - followersCount: (profile.followersCount ?? 1) - 1, 39 - ); 40 - state = {...state, followeeDid: updatedProfile}; 41 - } 42 - } else { 43 - // Follow 44 - final newFollowUri = await apiService.createFollow(followeeDid: followeeDid); 45 - if (newFollowUri != null) { 46 - final updatedProfile = profile.copyWith( 47 - viewer: viewer?.copyWith(following: newFollowUri), 48 - followersCount: (profile.followersCount ?? 0) + 1, 49 - ); 50 - state = {...state, followeeDid: updatedProfile}; 51 - } 52 - } 53 - } 54 - }
···
-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'e2b88310e5f54587442c2a9a0307b01030993fcc'; 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
···
+113
lib/providers/profile_provider.dart
···
··· 1 + import 'dart:io'; 2 + 3 + import 'package:grain/api.dart'; 4 + import 'package:grain/models/profile.dart'; 5 + import 'package:grain/models/profile_with_galleries.dart'; 6 + import 'package:grain/providers/gallery_cache_provider.dart'; 7 + import 'package:image_picker/image_picker.dart'; 8 + import 'package:riverpod_annotation/riverpod_annotation.dart'; 9 + 10 + part 'profile_provider.g.dart'; 11 + 12 + @Riverpod(keepAlive: true) 13 + class ProfileNotifier extends _$ProfileNotifier { 14 + @override 15 + Future<ProfileWithGalleries?> build(String did) async { 16 + return _fetchProfile(did); 17 + } 18 + 19 + Future<ProfileWithGalleries?> _fetchProfile(String did) async { 20 + final profile = await apiService.fetchProfile(did: did); 21 + final galleries = await apiService.fetchActorGalleries(did: did); 22 + if (profile != null) { 23 + return ProfileWithGalleries(profile: profile, galleries: galleries); 24 + } 25 + return null; 26 + } 27 + 28 + Future<void> refresh() async { 29 + state = const AsyncValue.loading(); 30 + state = await AsyncValue.guard(() => _fetchProfile(did)); 31 + } 32 + 33 + Future<bool> updateProfile({ 34 + required String displayName, 35 + required String description, 36 + dynamic avatarFile, 37 + }) async { 38 + File? file; 39 + if (avatarFile is XFile) { 40 + file = File(avatarFile.path); 41 + } else if (avatarFile is File) { 42 + file = avatarFile; 43 + } else { 44 + file = null; 45 + } 46 + final success = await apiService.updateProfile( 47 + displayName: displayName, 48 + description: description, 49 + avatarFile: file, 50 + ); 51 + if (success) { 52 + final start = DateTime.now(); 53 + const timeout = Duration(seconds: 20); 54 + const pollInterval = Duration(milliseconds: 1000); 55 + Profile? updated; 56 + final prevCid = state.value?.profile.cid; 57 + state = const AsyncValue.loading(); // Force UI to show loading and rebuild 58 + while (DateTime.now().difference(start) < timeout) { 59 + updated = await apiService.fetchProfile(did: did); 60 + if (updated != null && updated.cid != prevCid) { 61 + break; 62 + } 63 + await Future.delayed(pollInterval); 64 + } 65 + // Always assign a new instance to state 66 + if (updated != null) { 67 + final galleries = await apiService.fetchActorGalleries(did: did); 68 + // Update the gallery cache provider 69 + ref.read(galleryCacheProvider.notifier).setGalleriesForActor(did, galleries); 70 + state = AsyncValue.data(ProfileWithGalleries(profile: updated, galleries: galleries)); 71 + } else { 72 + state = const AsyncValue.data(null); 73 + } 74 + if (updated == null) { 75 + await refresh(); 76 + } 77 + } 78 + return success; 79 + } 80 + 81 + Future<void> toggleFollow(String? followerDid) async { 82 + final current = state.value; 83 + final profile = current?.profile; 84 + if (profile == null || followerDid == null) return; 85 + final viewer = profile.viewer; 86 + final followUri = viewer?.following; 87 + if (followUri != null && followUri.isNotEmpty) { 88 + // Unfollow 89 + final success = await apiService.deleteRecord(followUri); 90 + if (success) { 91 + final updatedProfile = profile.copyWith( 92 + viewer: viewer?.copyWith(following: null), 93 + followersCount: (profile.followersCount ?? 1) - 1, 94 + ); 95 + state = AsyncValue.data( 96 + ProfileWithGalleries(profile: updatedProfile, galleries: current!.galleries), 97 + ); 98 + } 99 + } else { 100 + // Follow 101 + final newFollowUri = await apiService.createFollow(followeeDid: did); 102 + if (newFollowUri != null) { 103 + final updatedProfile = profile.copyWith( 104 + viewer: viewer?.copyWith(following: newFollowUri), 105 + followersCount: (profile.followersCount ?? 0) + 1, 106 + ); 107 + state = AsyncValue.data( 108 + ProfileWithGalleries(profile: updatedProfile, galleries: current!.galleries), 109 + ); 110 + } 111 + } 112 + } 113 + }
+165
lib/providers/profile_provider.g.dart
···
··· 1 + // GENERATED CODE - DO NOT MODIFY BY HAND 2 + 3 + part of 'profile_provider.dart'; 4 + 5 + // ************************************************************************** 6 + // RiverpodGenerator 7 + // ************************************************************************** 8 + 9 + String _$profileNotifierHash() => r'0a4d40043196309ae74bdb3b88d7d63d25b8b4f6'; 10 + 11 + /// Copied from Dart SDK 12 + class _SystemHash { 13 + _SystemHash._(); 14 + 15 + static int combine(int hash, int value) { 16 + // ignore: parameter_assignments 17 + hash = 0x1fffffff & (hash + value); 18 + // ignore: parameter_assignments 19 + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); 20 + return hash ^ (hash >> 6); 21 + } 22 + 23 + static int finish(int hash) { 24 + // ignore: parameter_assignments 25 + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); 26 + // ignore: parameter_assignments 27 + hash = hash ^ (hash >> 11); 28 + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); 29 + } 30 + } 31 + 32 + abstract class _$ProfileNotifier 33 + extends BuildlessAsyncNotifier<ProfileWithGalleries?> { 34 + late final String did; 35 + 36 + FutureOr<ProfileWithGalleries?> build(String did); 37 + } 38 + 39 + /// See also [ProfileNotifier]. 40 + @ProviderFor(ProfileNotifier) 41 + const profileNotifierProvider = ProfileNotifierFamily(); 42 + 43 + /// See also [ProfileNotifier]. 44 + class ProfileNotifierFamily extends Family<AsyncValue<ProfileWithGalleries?>> { 45 + /// See also [ProfileNotifier]. 46 + const ProfileNotifierFamily(); 47 + 48 + /// See also [ProfileNotifier]. 49 + ProfileNotifierProvider call(String did) { 50 + return ProfileNotifierProvider(did); 51 + } 52 + 53 + @override 54 + ProfileNotifierProvider getProviderOverride( 55 + covariant ProfileNotifierProvider provider, 56 + ) { 57 + return call(provider.did); 58 + } 59 + 60 + static const Iterable<ProviderOrFamily>? _dependencies = null; 61 + 62 + @override 63 + Iterable<ProviderOrFamily>? get dependencies => _dependencies; 64 + 65 + static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null; 66 + 67 + @override 68 + Iterable<ProviderOrFamily>? get allTransitiveDependencies => 69 + _allTransitiveDependencies; 70 + 71 + @override 72 + String? get name => r'profileNotifierProvider'; 73 + } 74 + 75 + /// See also [ProfileNotifier]. 76 + class ProfileNotifierProvider 77 + extends AsyncNotifierProviderImpl<ProfileNotifier, ProfileWithGalleries?> { 78 + /// See also [ProfileNotifier]. 79 + ProfileNotifierProvider(String did) 80 + : this._internal( 81 + () => ProfileNotifier()..did = did, 82 + from: profileNotifierProvider, 83 + name: r'profileNotifierProvider', 84 + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') 85 + ? null 86 + : _$profileNotifierHash, 87 + dependencies: ProfileNotifierFamily._dependencies, 88 + allTransitiveDependencies: 89 + ProfileNotifierFamily._allTransitiveDependencies, 90 + did: did, 91 + ); 92 + 93 + ProfileNotifierProvider._internal( 94 + super._createNotifier, { 95 + required super.name, 96 + required super.dependencies, 97 + required super.allTransitiveDependencies, 98 + required super.debugGetCreateSourceHash, 99 + required super.from, 100 + required this.did, 101 + }) : super.internal(); 102 + 103 + final String did; 104 + 105 + @override 106 + FutureOr<ProfileWithGalleries?> runNotifierBuild( 107 + covariant ProfileNotifier notifier, 108 + ) { 109 + return notifier.build(did); 110 + } 111 + 112 + @override 113 + Override overrideWith(ProfileNotifier Function() create) { 114 + return ProviderOverride( 115 + origin: this, 116 + override: ProfileNotifierProvider._internal( 117 + () => create()..did = did, 118 + from: from, 119 + name: null, 120 + dependencies: null, 121 + allTransitiveDependencies: null, 122 + debugGetCreateSourceHash: null, 123 + did: did, 124 + ), 125 + ); 126 + } 127 + 128 + @override 129 + AsyncNotifierProviderElement<ProfileNotifier, ProfileWithGalleries?> 130 + createElement() { 131 + return _ProfileNotifierProviderElement(this); 132 + } 133 + 134 + @override 135 + bool operator ==(Object other) { 136 + return other is ProfileNotifierProvider && other.did == did; 137 + } 138 + 139 + @override 140 + int get hashCode { 141 + var hash = _SystemHash.combine(0, runtimeType.hashCode); 142 + hash = _SystemHash.combine(hash, did.hashCode); 143 + 144 + return _SystemHash.finish(hash); 145 + } 146 + } 147 + 148 + @Deprecated('Will be removed in 3.0. Use Ref instead') 149 + // ignore: unused_element 150 + mixin ProfileNotifierRef on AsyncNotifierProviderRef<ProfileWithGalleries?> { 151 + /// The parameter `did` of this provider. 152 + String get did; 153 + } 154 + 155 + class _ProfileNotifierProviderElement 156 + extends AsyncNotifierProviderElement<ProfileNotifier, ProfileWithGalleries?> 157 + with ProfileNotifierRef { 158 + _ProfileNotifierProviderElement(super.provider); 159 + 160 + @override 161 + String get did => (origin as ProfileNotifierProvider).did; 162 + } 163 + 164 + // ignore_for_file: type=lint 165 + // 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
-2
lib/screens/home_page.dart
··· 392 showExplore = false; 393 }); 394 }, 395 - avatarUrl: apiService.currentUser?.avatar, 396 ), 397 floatingActionButton: (!showProfile && !showNotifications && !showExplore) 398 ? FloatingActionButton( ··· 498 showExplore = false; 499 }); 500 }, 501 - avatarUrl: apiService.currentUser?.avatar, 502 ), 503 ); 504 }
··· 392 showExplore = false; 393 }); 394 }, 395 ), 396 floatingActionButton: (!showProfile && !showNotifications && !showExplore) 397 ? FloatingActionButton( ··· 497 showExplore = false; 498 }); 499 }, 500 ), 501 ); 502 }
+431 -372
lib/screens/profile_page.dart
··· 4 import 'package:grain/api.dart'; 5 import 'package:grain/app_theme.dart'; 6 import 'package:grain/models/gallery.dart'; 7 - import 'package:grain/providers/profile_cache_provider.dart'; 8 import 'package:grain/screens/hashtag_page.dart'; 9 import 'package:grain/widgets/app_button.dart'; 10 import 'package:grain/widgets/app_image.dart'; 11 import 'package:grain/widgets/faceted_text.dart'; 12 13 import 'gallery_page.dart'; ··· 23 } 24 25 class _ProfilePageState extends ConsumerState<ProfilePage> with SingleTickerProviderStateMixin { 26 - dynamic _profile; 27 - bool _loading = true; 28 - List<Gallery> _galleries = []; 29 List<Gallery> _favs = []; 30 TabController? _tabController; 31 bool _favsLoading = false; 32 - bool _galleriesLoading = false; 33 - List<Map<String, dynamic>>? _descriptionFacets; 34 - 35 - Future<List<Map<String, dynamic>>> _extractFacets(String text) async { 36 - final blueskyText = BlueskyText(text); 37 - final entities = blueskyText.entities; 38 - final facets = await entities.toFacets(); 39 - return List<Map<String, dynamic>>.from(facets); 40 - } 41 42 @override 43 void initState() { ··· 46 final isOwnProfile = (apiService.currentUser?.did == did); 47 _tabController = TabController(length: isOwnProfile ? 2 : 1, vsync: this); 48 _tabController!.addListener(_onTabChanged); 49 - _fetchProfileAndGalleries(); 50 } 51 52 @override ··· 63 _favsLoading = true; 64 }); 65 } 66 - String? did = (_profile ?? widget.profile)?.did; 67 if (did != null && did.isNotEmpty) { 68 try { 69 final favs = await apiService.getActorFavs(did: did); ··· 91 } 92 } 93 94 - Future<void> _fetchProfileAndGalleries() async { 95 - if (mounted) { 96 - setState(() { 97 - _loading = true; 98 - }); 99 - } 100 - String? did = widget.did ?? widget.profile?.did; 101 - if (did == null || did.isEmpty) { 102 - if (mounted) { 103 - setState(() { 104 - _loading = false; 105 - }); 106 - } 107 - return; 108 - } 109 - // Use the profileCacheProvider to fetch and cache the profile 110 - final profile = await ref.read(profileCacheProvider.notifier).fetch(did); 111 - final galleries = await apiService.fetchActorGalleries(did: did); 112 - List<Map<String, dynamic>>? descriptionFacets; 113 - if ((profile?.description ?? '').isNotEmpty) { 114 - try { 115 - final desc = profile != null ? profile.description : ''; 116 - descriptionFacets = await _extractFacets(desc ?? ''); 117 - } catch (_) { 118 - descriptionFacets = null; 119 - } 120 - } 121 - if (mounted) { 122 - setState(() { 123 - _profile = profile; 124 - _galleries = galleries; 125 - _descriptionFacets = descriptionFacets; 126 - _loading = false; 127 - }); 128 } 129 } 130 ··· 132 Widget build(BuildContext context) { 133 final theme = Theme.of(context); 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; 138 - if (_loading) { 139 - return Scaffold( 140 - backgroundColor: Theme.of(context).scaffoldBackgroundColor, 141 body: Center( 142 child: CircularProgressIndicator(strokeWidth: 2, color: theme.colorScheme.primary), 143 ), 144 - ); 145 - } 146 - if (profile == null) { 147 - return const Center(child: Text('No profile data')); 148 - } 149 - return Scaffold( 150 - backgroundColor: theme.scaffoldBackgroundColor, 151 - appBar: widget.showAppBar 152 - ? AppBar( 153 - backgroundColor: theme.appBarTheme.backgroundColor, 154 - surfaceTintColor: theme.appBarTheme.backgroundColor, 155 - bottom: PreferredSize( 156 - preferredSize: const Size.fromHeight(1), 157 - child: Container(color: theme.dividerColor, height: 1), 158 - ), 159 - leading: const BackButton(), 160 - ) 161 - : null, 162 - body: SafeArea( 163 - bottom: false, 164 - child: Column( 165 - children: [ 166 - Expanded( 167 - child: NestedScrollView( 168 - headerSliverBuilder: (context, innerBoxIsScrolled) => [ 169 - SliverToBoxAdapter( 170 - child: Column( 171 - crossAxisAlignment: CrossAxisAlignment.start, 172 - children: [ 173 - Padding( 174 - padding: const EdgeInsets.symmetric(horizontal: 8), 175 - child: Column( 176 - crossAxisAlignment: CrossAxisAlignment.start, 177 - children: [ 178 - const SizedBox(height: 16), 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 - child: AppButton( 199 - size: AppButtonSize.small, 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 - label: (profile.viewer?.following?.isNotEmpty == true) 212 - ? 'Following' 213 - : 'Follow', 214 - ), 215 ), 216 ], 217 ), 218 - const SizedBox(height: 8), 219 - Text( 220 - profile.displayName ?? '', 221 - style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w800), 222 - textAlign: TextAlign.left, 223 ), 224 - const SizedBox(height: 2), 225 - Text( 226 - '@${profile.handle ?? ''}', 227 - style: TextStyle( 228 - fontSize: 14, 229 - color: Theme.of(context).brightness == Brightness.dark 230 - ? Colors.grey[400] 231 - : Colors.grey[700], 232 ), 233 - textAlign: TextAlign.left, 234 - ), 235 - const SizedBox(height: 12), 236 - _ProfileStatsRow( 237 - followers: 238 - (profile.followersCount is int 239 - ? profile.followersCount 240 - : int.tryParse( 241 - profile.followersCount?.toString() ?? '0', 242 - ) ?? 243 - 0) 244 - .toString(), 245 - following: 246 - (profile.followsCount is int 247 - ? profile.followsCount 248 - : int.tryParse( 249 - profile.followsCount?.toString() ?? '0', 250 - ) ?? 251 - 0) 252 - .toString(), 253 - galleries: 254 - (profile.galleryCount is int 255 - ? profile.galleryCount 256 - : int.tryParse( 257 - profile.galleryCount?.toString() ?? '0', 258 - ) ?? 259 - 0) 260 - .toString(), 261 - ), 262 - if ((profile.description ?? '').isNotEmpty) ...[ 263 - const SizedBox(height: 16), 264 - FacetedText( 265 - text: profile.description, 266 - facets: _descriptionFacets, 267 - onMentionTap: (didOrHandle) { 268 - Navigator.of(context).push( 269 - MaterialPageRoute( 270 - builder: (context) => 271 - ProfilePage(did: didOrHandle, showAppBar: true), 272 ), 273 ); 274 - }, 275 - onLinkTap: (url) { 276 - // TODO: Implement WebViewPage navigation 277 - }, 278 - onTagTap: (tag) => Navigator.push( 279 - context, 280 - MaterialPageRoute(builder: (_) => HashtagPage(hashtag: tag)), 281 ), 282 - linkStyle: TextStyle( 283 - color: Theme.of(context).colorScheme.primary, 284 - fontWeight: FontWeight.w600, 285 ), 286 - ), 287 - ], 288 - const SizedBox(height: 24), 289 - ], 290 - ), 291 - ), 292 - // TabBar with no horizontal padding 293 - Container( 294 - color: theme.scaffoldBackgroundColor, 295 - child: TabBar( 296 - dividerColor: theme.disabledColor, 297 - controller: _tabController, 298 - indicator: UnderlineTabIndicator( 299 - borderSide: const BorderSide(color: AppTheme.primaryColor, width: 3), 300 - insets: EdgeInsets.zero, 301 - ), 302 - indicatorSize: TabBarIndicatorSize.tab, 303 - labelColor: theme.colorScheme.onSurface, 304 - unselectedLabelColor: theme.colorScheme.onSurfaceVariant, 305 - labelStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16), 306 - tabs: [ 307 - const Tab(text: 'Galleries'), 308 - if (apiService.currentUser?.did == profile.did) 309 - const Tab(text: 'Favs'), 310 - ], 311 - ), 312 - ), 313 - ], 314 - ), 315 - ), 316 - ], 317 - body: TabBarView( 318 - controller: _tabController, 319 - children: [ 320 - // Galleries tab, edge-to-edge grid 321 - _galleriesLoading 322 - ? Center( 323 - child: CircularProgressIndicator( 324 - strokeWidth: 2, 325 - color: theme.colorScheme.primary, 326 - ), 327 - ) 328 - : _galleries.isEmpty 329 - ? GridView.builder( 330 - shrinkWrap: true, 331 - physics: const NeverScrollableScrollPhysics(), 332 - padding: EdgeInsets.zero, 333 - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( 334 - crossAxisCount: 3, 335 - childAspectRatio: 3 / 4, 336 - crossAxisSpacing: 2, 337 - mainAxisSpacing: 2, 338 - ), 339 - itemCount: 12, // Enough to fill the screen 340 - itemBuilder: (context, index) { 341 - return Container(color: theme.colorScheme.surfaceContainerHighest); 342 - }, 343 - ) 344 - : GridView.builder( 345 - shrinkWrap: true, 346 - physics: const NeverScrollableScrollPhysics(), 347 - padding: EdgeInsets.zero, 348 - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( 349 - crossAxisCount: 3, 350 - childAspectRatio: 3 / 4, 351 - crossAxisSpacing: 2, 352 - mainAxisSpacing: 2, 353 - ), 354 - itemCount: (_galleries.length < 12 ? 12 : _galleries.length), 355 - itemBuilder: (context, index) { 356 - if (_galleries.isNotEmpty && index < _galleries.length) { 357 - final gallery = _galleries[index]; 358 - final hasPhoto = 359 - gallery.items.isNotEmpty && 360 - (gallery.items[0].thumb?.isNotEmpty ?? false); 361 - return GestureDetector( 362 - onTap: () { 363 - if (gallery.uri.isNotEmpty) { 364 - Navigator.of(context).push( 365 - MaterialPageRoute( 366 - builder: (context) => GalleryPage( 367 - uri: gallery.uri, 368 - currentUserDid: apiService.currentUser?.did ?? '', 369 - ), 370 - ), 371 - ); 372 - } 373 }, 374 - child: Container( 375 - decoration: BoxDecoration( 376 - color: Theme.of(context).colorScheme.surfaceContainerHighest, 377 - ), 378 - clipBehavior: Clip.antiAlias, 379 - child: hasPhoto 380 - ? AppImage(url: gallery.items[0].thumb, fit: BoxFit.cover) 381 - : Center( 382 - child: Text( 383 - gallery.title ?? '', 384 - style: TextStyle( 385 - fontSize: 12, 386 - color: theme.colorScheme.onSurfaceVariant, 387 ), 388 - textAlign: TextAlign.center, 389 ), 390 - ), 391 - ), 392 - ); 393 - } 394 - // Placeholder for empty slots 395 - return Container(color: theme.colorScheme.surfaceContainerHighest); 396 - }, 397 - ), 398 - // Favs tab 399 - if (apiService.currentUser?.did == profile.did) 400 - (_favsLoading 401 - ? const Center( 402 - child: CircularProgressIndicator( 403 - strokeWidth: 2, 404 - color: AppTheme.primaryColor, 405 - ), 406 - ) 407 - : _favs.isEmpty 408 - ? GridView.builder( 409 - shrinkWrap: true, 410 - physics: const NeverScrollableScrollPhysics(), 411 - padding: EdgeInsets.zero, 412 - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( 413 - crossAxisCount: 3, 414 - childAspectRatio: 3 / 4, 415 - crossAxisSpacing: 2, 416 - mainAxisSpacing: 2, 417 - ), 418 - itemCount: 12, // Enough to fill the screen 419 - itemBuilder: (context, index) { 420 - return Container(color: theme.colorScheme.surfaceContainerHighest); 421 - }, 422 - ) 423 - : GridView.builder( 424 - shrinkWrap: true, 425 - physics: const NeverScrollableScrollPhysics(), 426 - padding: EdgeInsets.zero, 427 - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( 428 - crossAxisCount: 3, 429 - childAspectRatio: 3 / 4, 430 - crossAxisSpacing: 2, 431 - mainAxisSpacing: 2, 432 - ), 433 - itemCount: _favs.length, 434 - itemBuilder: (context, index) { 435 - final gallery = _favs[index]; 436 - final hasPhoto = 437 - gallery.items.isNotEmpty && 438 - (gallery.items[0].thumb?.isNotEmpty ?? false); 439 - return GestureDetector( 440 - onTap: () { 441 - if (gallery.uri.isNotEmpty) { 442 - Navigator.of(context).push( 443 - MaterialPageRoute( 444 - builder: (context) => GalleryPage( 445 - uri: gallery.uri, 446 - currentUserDid: apiService.currentUser?.did ?? '', 447 - ), 448 ), 449 - ); 450 - } 451 - }, 452 - child: Container( 453 - decoration: BoxDecoration( 454 - color: theme.colorScheme.surfaceContainerHighest, 455 - ), 456 - clipBehavior: Clip.antiAlias, 457 - child: hasPhoto 458 - ? AppImage(url: gallery.items[0].thumb, fit: BoxFit.cover) 459 - : Center( 460 - child: Text( 461 - gallery.title ?? '', 462 - style: TextStyle( 463 - fontSize: 12, 464 - color: theme.colorScheme.onSurfaceVariant, 465 ), 466 - textAlign: TextAlign.center, 467 - ), 468 - ), 469 - ), 470 - ); 471 - }, 472 - )), 473 - ], 474 ), 475 - ), 476 ), 477 - ], 478 - ), 479 - ), 480 ); 481 } 482 }
··· 4 import 'package:grain/api.dart'; 5 import 'package:grain/app_theme.dart'; 6 import 'package:grain/models/gallery.dart'; 7 + import 'package:grain/models/profile_with_galleries.dart'; 8 + import 'package:grain/providers/profile_provider.dart'; 9 import 'package:grain/screens/hashtag_page.dart'; 10 import 'package:grain/widgets/app_button.dart'; 11 import 'package:grain/widgets/app_image.dart'; 12 + import 'package:grain/widgets/edit_profile_sheet.dart'; 13 import 'package:grain/widgets/faceted_text.dart'; 14 15 import 'gallery_page.dart'; ··· 25 } 26 27 class _ProfilePageState extends ConsumerState<ProfilePage> with SingleTickerProviderStateMixin { 28 List<Gallery> _favs = []; 29 TabController? _tabController; 30 bool _favsLoading = false; 31 32 @override 33 void initState() { ··· 36 final isOwnProfile = (apiService.currentUser?.did == did); 37 _tabController = TabController(length: isOwnProfile ? 2 : 1, vsync: this); 38 _tabController!.addListener(_onTabChanged); 39 } 40 41 @override ··· 52 _favsLoading = true; 53 }); 54 } 55 + String? did = widget.did ?? widget.profile?.did; 56 if (did != null && did.isNotEmpty) { 57 try { 58 final favs = await apiService.getActorFavs(did: did); ··· 80 } 81 } 82 83 + // Refactored: Just pop the sheet after save, don't return edited values 84 + Future<void> _handleProfileSave( 85 + String did, 86 + String displayName, 87 + String description, 88 + dynamic avatarFile, 89 + ) async { 90 + final notifier = ref.read(profileNotifierProvider(did).notifier); 91 + final success = await notifier.updateProfile( 92 + displayName: displayName, 93 + description: description, 94 + avatarFile: avatarFile, 95 + ); 96 + if (!mounted) return; 97 + if (success) { 98 + Navigator.of(context).pop(); 99 + if (mounted) setState(() {}); // Force widget rebuild after modal closes 100 + } else { 101 + if (!mounted) return; 102 + ScaffoldMessenger.of( 103 + context, 104 + ).showSnackBar(const SnackBar(content: Text('Failed to update profile'))); 105 } 106 } 107 ··· 109 Widget build(BuildContext context) { 110 final theme = Theme.of(context); 111 final did = widget.did ?? widget.profile?.did; 112 + final asyncProfile = did != null 113 + ? ref.watch(profileNotifierProvider(did)) 114 + : const AsyncValue<ProfileWithGalleries?>.loading(); 115 + 116 + return asyncProfile.when( 117 + loading: () => Scaffold( 118 + backgroundColor: theme.scaffoldBackgroundColor, 119 body: Center( 120 child: CircularProgressIndicator(strokeWidth: 2, color: theme.colorScheme.primary), 121 ), 122 + ), 123 + error: (err, stack) => Scaffold( 124 + backgroundColor: theme.scaffoldBackgroundColor, 125 + body: Center(child: Text('Failed to load profile')), 126 + ), 127 + data: (profileWithGalleries) { 128 + if (profileWithGalleries == null) { 129 + return Scaffold( 130 + backgroundColor: theme.scaffoldBackgroundColor, 131 + body: Center(child: Text('Profile not found')), 132 + ); 133 + } 134 + final profile = profileWithGalleries.profile; 135 + final galleries = profileWithGalleries.galleries; 136 + Future<List<Map<String, dynamic>>?> getDescriptionFacets() async { 137 + final desc = profile.description ?? ''; 138 + if (desc.isEmpty) return null; 139 + try { 140 + final blueskyText = BlueskyText(desc); 141 + final entities = blueskyText.entities; 142 + return await entities.toFacets(); 143 + } catch (_) { 144 + return null; 145 + } 146 + } 147 + 148 + return Scaffold( 149 + backgroundColor: theme.scaffoldBackgroundColor, 150 + appBar: widget.showAppBar 151 + ? AppBar( 152 + backgroundColor: theme.appBarTheme.backgroundColor, 153 + surfaceTintColor: theme.appBarTheme.backgroundColor, 154 + bottom: PreferredSize( 155 + preferredSize: const Size.fromHeight(1), 156 + child: Container(color: theme.dividerColor, height: 1), 157 + ), 158 + leading: const BackButton(), 159 + ) 160 + : null, 161 + body: SafeArea( 162 + bottom: false, 163 + child: Column( 164 + children: [ 165 + Expanded( 166 + child: NestedScrollView( 167 + headerSliverBuilder: (context, innerBoxIsScrolled) => [ 168 + SliverToBoxAdapter( 169 + child: Column( 170 + crossAxisAlignment: CrossAxisAlignment.start, 171 + children: [ 172 + Padding( 173 + padding: const EdgeInsets.symmetric(horizontal: 8), 174 + child: Column( 175 crossAxisAlignment: CrossAxisAlignment.start, 176 children: [ 177 + const SizedBox(height: 16), 178 + Row( 179 + crossAxisAlignment: CrossAxisAlignment.start, 180 + children: [ 181 + // Avatar 182 + if (profile.avatar != null) 183 + ClipOval( 184 + child: AppImage( 185 + url: profile.avatar, 186 + width: 64, 187 + height: 64, 188 + fit: BoxFit.cover, 189 + ), 190 + ) 191 + else 192 + const Icon( 193 + Icons.account_circle, 194 + size: 64, 195 + color: Colors.grey, 196 + ), 197 + const Spacer(), 198 + // Follow/Unfollow button 199 + if (profile.did != apiService.currentUser?.did) 200 + SizedBox( 201 + child: AppButton( 202 + size: AppButtonSize.small, 203 + variant: profile.viewer?.following?.isNotEmpty == true 204 + ? AppButtonVariant.secondary 205 + : AppButtonVariant.primary, 206 + onPressed: () async { 207 + await ref 208 + .read( 209 + profileNotifierProvider(profile.did).notifier, 210 + ) 211 + .toggleFollow(apiService.currentUser?.did); 212 + }, 213 + label: (profile.viewer?.following?.isNotEmpty == true) 214 + ? 'Following' 215 + : 'Follow', 216 + ), 217 + ) 218 + // Edit Profile button for current user 219 + else 220 + SizedBox( 221 + child: AppButton( 222 + size: AppButtonSize.small, 223 + variant: AppButtonVariant.secondary, 224 + onPressed: () async { 225 + final bottomSheetContext = context; 226 + await showModalBottomSheet<Map<String, dynamic>>( 227 + context: bottomSheetContext, 228 + isScrollControlled: true, 229 + backgroundColor: Colors.transparent, 230 + builder: (sheetContext) => EditProfileSheet( 231 + initialDisplayName: profile.displayName, 232 + initialDescription: profile.description, 233 + initialAvatarUrl: profile.avatar, 234 + onSave: 235 + (displayName, description, avatarFile) async { 236 + await _handleProfileSave( 237 + profile.did, 238 + displayName, 239 + description, 240 + avatarFile, 241 + ); 242 + }, 243 + onCancel: () => Navigator.of(sheetContext).pop(), 244 + ), 245 ); 246 + }, 247 + label: 'Edit profile', 248 + ), 249 + ), 250 + ], 251 + ), 252 + const SizedBox(height: 8), 253 + Text( 254 + profile.displayName ?? '', 255 + style: const TextStyle( 256 + fontSize: 28, 257 + fontWeight: FontWeight.w800, 258 + ), 259 + textAlign: TextAlign.left, 260 + ), 261 + const SizedBox(height: 2), 262 + Text( 263 + '@${profile.handle}', 264 + style: TextStyle( 265 + fontSize: 14, 266 + color: Theme.of(context).brightness == Brightness.dark 267 + ? Colors.grey[400] 268 + : Colors.grey[700], 269 + ), 270 + textAlign: TextAlign.left, 271 + ), 272 + const SizedBox(height: 12), 273 + _ProfileStatsRow( 274 + followers: 275 + (profile.followersCount is int 276 + ? profile.followersCount 277 + : int.tryParse( 278 + profile.followersCount?.toString() ?? '0', 279 + ) ?? 280 + 0) 281 + .toString(), 282 + following: 283 + (profile.followsCount is int 284 + ? profile.followsCount 285 + : int.tryParse( 286 + profile.followsCount?.toString() ?? '0', 287 + ) ?? 288 + 0) 289 + .toString(), 290 + galleries: 291 + (profile.galleryCount is int 292 + ? profile.galleryCount 293 + : int.tryParse( 294 + profile.galleryCount?.toString() ?? '0', 295 + ) ?? 296 + 0) 297 + .toString(), 298 + ), 299 + if ((profile.description ?? '').isNotEmpty) ...[ 300 + const SizedBox(height: 16), 301 + FutureBuilder<List<Map<String, dynamic>>?>( 302 + future: getDescriptionFacets(), 303 + builder: (context, snapshot) { 304 + return FacetedText( 305 + text: profile.description ?? '', 306 + facets: snapshot.data, 307 + onMentionTap: (didOrHandle) { 308 + Navigator.of(context).push( 309 + MaterialPageRoute( 310 + builder: (context) => 311 + ProfilePage(did: didOrHandle, showAppBar: true), 312 + ), 313 + ); 314 + }, 315 + onLinkTap: (url) { 316 + // TODO: Implement WebViewPage navigation 317 + }, 318 + onTagTap: (tag) => Navigator.push( 319 + context, 320 + MaterialPageRoute( 321 + builder: (_) => HashtagPage(hashtag: tag), 322 + ), 323 + ), 324 + linkStyle: TextStyle( 325 + color: Theme.of(context).colorScheme.primary, 326 + fontWeight: FontWeight.w600, 327 + ), 328 + ); 329 + }, 330 ), 331 + ], 332 + const SizedBox(height: 24), 333 ], 334 ), 335 + ), 336 + // TabBar with no horizontal padding 337 + Container( 338 + color: theme.scaffoldBackgroundColor, 339 + child: TabBar( 340 + dividerColor: theme.disabledColor, 341 + controller: _tabController, 342 + indicator: UnderlineTabIndicator( 343 + borderSide: const BorderSide( 344 + color: AppTheme.primaryColor, 345 + width: 3, 346 + ), 347 + insets: EdgeInsets.zero, 348 + ), 349 + indicatorSize: TabBarIndicatorSize.tab, 350 + labelColor: theme.colorScheme.onSurface, 351 + unselectedLabelColor: theme.colorScheme.onSurfaceVariant, 352 + labelStyle: const TextStyle( 353 + fontWeight: FontWeight.w600, 354 + fontSize: 16, 355 + ), 356 + tabs: [ 357 + const Tab(text: 'Galleries'), 358 + if (apiService.currentUser?.did == profile.did) 359 + const Tab(text: 'Favs'), 360 + ], 361 ), 362 + ), 363 + ], 364 + ), 365 + ), 366 + ], 367 + body: TabBarView( 368 + controller: _tabController, 369 + children: [ 370 + // Galleries tab, edge-to-edge grid 371 + galleries.isEmpty 372 + ? GridView.builder( 373 + shrinkWrap: true, 374 + physics: const NeverScrollableScrollPhysics(), 375 + padding: EdgeInsets.zero, 376 + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( 377 + crossAxisCount: 3, 378 + childAspectRatio: 3 / 4, 379 + crossAxisSpacing: 2, 380 + mainAxisSpacing: 2, 381 + ), 382 + itemCount: 12, // Enough to fill the screen 383 + itemBuilder: (context, index) { 384 + return Container( 385 + color: theme.colorScheme.surfaceContainerHighest, 386 + ); 387 + }, 388 + ) 389 + : GridView.builder( 390 + shrinkWrap: true, 391 + physics: const NeverScrollableScrollPhysics(), 392 + padding: EdgeInsets.zero, 393 + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( 394 + crossAxisCount: 3, 395 + childAspectRatio: 3 / 4, 396 + crossAxisSpacing: 2, 397 + mainAxisSpacing: 2, 398 ), 399 + itemCount: (galleries.length < 12 ? 12 : galleries.length), 400 + itemBuilder: (context, index) { 401 + if (galleries.isNotEmpty && index < galleries.length) { 402 + final gallery = galleries[index]; 403 + final hasPhoto = 404 + gallery.items.isNotEmpty && 405 + (gallery.items[0].thumb?.isNotEmpty ?? false); 406 + return GestureDetector( 407 + onTap: () { 408 + if (gallery.uri.isNotEmpty) { 409 + Navigator.of(context).push( 410 + MaterialPageRoute( 411 + builder: (context) => GalleryPage( 412 + uri: gallery.uri, 413 + currentUserDid: apiService.currentUser?.did ?? '', 414 + ), 415 + ), 416 + ); 417 + } 418 + }, 419 + child: Container( 420 + decoration: BoxDecoration( 421 + color: Theme.of( 422 + context, 423 + ).colorScheme.surfaceContainerHighest, 424 + ), 425 + clipBehavior: Clip.antiAlias, 426 + child: hasPhoto 427 + ? AppImage( 428 + url: gallery.items[0].thumb, 429 + fit: BoxFit.cover, 430 + ) 431 + : Center( 432 + child: Text( 433 + gallery.title ?? '', 434 + style: TextStyle( 435 + fontSize: 12, 436 + color: theme.colorScheme.onSurfaceVariant, 437 + ), 438 + textAlign: TextAlign.center, 439 + ), 440 + ), 441 ), 442 ); 443 + } 444 + // Placeholder for empty slots 445 + return Container( 446 + color: theme.colorScheme.surfaceContainerHighest, 447 + ); 448 + }, 449 + ), 450 + // Favs tab 451 + if (apiService.currentUser?.did == profile.did) 452 + (_favsLoading 453 + ? const Center( 454 + child: CircularProgressIndicator( 455 + strokeWidth: 2, 456 + color: AppTheme.primaryColor, 457 ), 458 + ) 459 + : _favs.isEmpty 460 + ? GridView.builder( 461 + shrinkWrap: true, 462 + physics: const NeverScrollableScrollPhysics(), 463 + padding: EdgeInsets.zero, 464 + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( 465 + crossAxisCount: 3, 466 + childAspectRatio: 3 / 4, 467 + crossAxisSpacing: 2, 468 + mainAxisSpacing: 2, 469 ), 470 + itemCount: 12, // Enough to fill the screen 471 + itemBuilder: (context, index) { 472 + return Container( 473 + color: theme.colorScheme.surfaceContainerHighest, 474 + ); 475 }, 476 + ) 477 + : GridView.builder( 478 + shrinkWrap: true, 479 + physics: const NeverScrollableScrollPhysics(), 480 + padding: EdgeInsets.zero, 481 + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( 482 + crossAxisCount: 3, 483 + childAspectRatio: 3 / 4, 484 + crossAxisSpacing: 2, 485 + mainAxisSpacing: 2, 486 + ), 487 + itemCount: _favs.length, 488 + itemBuilder: (context, index) { 489 + final gallery = _favs[index]; 490 + final hasPhoto = 491 + gallery.items.isNotEmpty && 492 + (gallery.items[0].thumb?.isNotEmpty ?? false); 493 + return GestureDetector( 494 + onTap: () { 495 + if (gallery.uri.isNotEmpty) { 496 + Navigator.of(context).push( 497 + MaterialPageRoute( 498 + builder: (context) => GalleryPage( 499 + uri: gallery.uri, 500 + currentUserDid: apiService.currentUser?.did ?? '', 501 ), 502 ), 503 + ); 504 + } 505 + }, 506 + child: Container( 507 + decoration: BoxDecoration( 508 + color: theme.colorScheme.surfaceContainerHighest, 509 ), 510 + clipBehavior: Clip.antiAlias, 511 + child: hasPhoto 512 + ? AppImage( 513 + url: gallery.items[0].thumb, 514 + fit: BoxFit.cover, 515 + ) 516 + : Center( 517 + child: Text( 518 + gallery.title ?? '', 519 + style: TextStyle( 520 + fontSize: 12, 521 + color: theme.colorScheme.onSurfaceVariant, 522 + ), 523 + textAlign: TextAlign.center, 524 + ), 525 ), 526 + ), 527 + ); 528 + }, 529 + )), 530 + ], 531 + ), 532 + ), 533 ), 534 + ], 535 ), 536 + ), 537 + ); 538 + }, 539 ); 540 } 541 }
+18 -6
lib/widgets/bottom_nav_bar.dart
··· 1 import 'package:flutter/material.dart'; 2 import 'package:font_awesome_flutter/font_awesome_flutter.dart'; 3 import 'package:grain/app_theme.dart'; 4 import 'package:grain/widgets/app_image.dart'; 5 6 - class BottomNavBar extends StatelessWidget { 7 final int navIndex; 8 final VoidCallback onHome; 9 final VoidCallback onExplore; 10 final VoidCallback onNotifications; 11 final VoidCallback onProfile; 12 - final String? avatarUrl; 13 14 const BottomNavBar({ 15 super.key, ··· 18 required this.onExplore, 19 required this.onNotifications, 20 required this.onProfile, 21 - this.avatarUrl, 22 }); 23 24 @override 25 - Widget build(BuildContext context) { 26 return Container( 27 decoration: BoxDecoration( 28 color: Theme.of(context).scaffoldBackgroundColor, ··· 104 child: Transform.translate( 105 offset: const Offset(0, -10), 106 child: Center( 107 - child: avatarUrl != null && avatarUrl!.isNotEmpty 108 ? Container( 109 width: 28, 110 height: 28, ··· 117 : null, 118 child: ClipOval( 119 child: AppImage( 120 - url: avatarUrl!, 121 width: 24, 122 height: 24, 123 fit: BoxFit.cover,
··· 1 import 'package:flutter/material.dart'; 2 + import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 import 'package:font_awesome_flutter/font_awesome_flutter.dart'; 4 + import 'package:grain/api.dart'; 5 import 'package:grain/app_theme.dart'; 6 + import 'package:grain/models/profile_with_galleries.dart'; 7 + import 'package:grain/providers/profile_provider.dart'; 8 import 'package:grain/widgets/app_image.dart'; 9 10 + class BottomNavBar extends ConsumerWidget { 11 final int navIndex; 12 final VoidCallback onHome; 13 final VoidCallback onExplore; 14 final VoidCallback onNotifications; 15 final VoidCallback onProfile; 16 17 const BottomNavBar({ 18 super.key, ··· 21 required this.onExplore, 22 required this.onNotifications, 23 required this.onProfile, 24 }); 25 26 @override 27 + Widget build(BuildContext context, WidgetRef ref) { 28 + final did = apiService.currentUser?.did; 29 + final asyncProfile = did != null 30 + ? ref.watch(profileNotifierProvider(did)) 31 + : const AsyncValue<ProfileWithGalleries?>.loading(); 32 + 33 + final avatarUrl = asyncProfile.maybeWhen( 34 + data: (profileWithGalleries) => profileWithGalleries?.profile.avatar, 35 + orElse: () => null, 36 + ); 37 + 38 return Container( 39 decoration: BoxDecoration( 40 color: Theme.of(context).scaffoldBackgroundColor, ··· 116 child: Transform.translate( 117 offset: const Offset(0, -10), 118 child: Center( 119 + child: avatarUrl != null && avatarUrl.isNotEmpty 120 ? Container( 121 width: 28, 122 height: 28, ··· 129 : null, 130 child: ClipOval( 131 child: AppImage( 132 + url: avatarUrl, 133 width: 24, 134 height: 24, 135 fit: BoxFit.cover,
+185
lib/widgets/edit_profile_sheet.dart
···
··· 1 + import 'dart:io'; 2 + 3 + import 'package:flutter/material.dart'; 4 + import 'package:grain/widgets/app_button.dart'; 5 + import 'package:grain/widgets/plain_text_field.dart'; 6 + import 'package:image_picker/image_picker.dart'; 7 + 8 + class EditProfileSheet extends StatefulWidget { 9 + final String? initialDisplayName; 10 + final String? initialDescription; 11 + final String? initialAvatarUrl; 12 + final Future<void> Function(String displayName, String description, XFile? avatar)? onSave; 13 + final VoidCallback? onCancel; 14 + 15 + const EditProfileSheet({ 16 + super.key, 17 + this.initialDisplayName, 18 + this.initialDescription, 19 + this.initialAvatarUrl, 20 + this.onSave, 21 + this.onCancel, 22 + }); 23 + 24 + @override 25 + State<EditProfileSheet> createState() => _EditProfileSheetState(); 26 + } 27 + 28 + class _EditProfileSheetState extends State<EditProfileSheet> { 29 + late TextEditingController _displayNameController; 30 + late TextEditingController _descriptionController; 31 + XFile? _selectedAvatar; 32 + bool _saving = false; 33 + 34 + @override 35 + void initState() { 36 + super.initState(); 37 + _displayNameController = TextEditingController(text: widget.initialDisplayName ?? ''); 38 + _descriptionController = TextEditingController(text: widget.initialDescription ?? ''); 39 + } 40 + 41 + @override 42 + void dispose() { 43 + _displayNameController.dispose(); 44 + _descriptionController.dispose(); 45 + super.dispose(); 46 + } 47 + 48 + Future<void> _pickAvatar() async { 49 + final picker = ImagePicker(); 50 + final picked = await picker.pickImage(source: ImageSource.gallery, imageQuality: 85); 51 + if (picked != null) { 52 + setState(() { 53 + _selectedAvatar = picked; 54 + }); 55 + } 56 + } 57 + 58 + void _onSave() async { 59 + if (_saving) return; 60 + setState(() => _saving = true); 61 + if (widget.onSave != null) { 62 + await widget.onSave!( 63 + _displayNameController.text.trim(), 64 + _descriptionController.text.trim(), 65 + _selectedAvatar, 66 + ); 67 + } 68 + if (mounted) setState(() => _saving = false); 69 + } 70 + 71 + @override 72 + Widget build(BuildContext context) { 73 + final theme = Theme.of(context); 74 + final avatarRadius = 44.0; 75 + final double maxHeight = 76 + MediaQuery.of(context).size.height - kToolbarHeight - MediaQuery.of(context).padding.top; 77 + 78 + return ConstrainedBox( 79 + constraints: BoxConstraints(maxHeight: maxHeight), 80 + child: ClipRRect( 81 + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), 82 + child: Container( 83 + color: theme.colorScheme.surface, 84 + child: LayoutBuilder( 85 + builder: (context, constraints) { 86 + return Padding( 87 + padding: EdgeInsets.fromLTRB(16, 16, 16, MediaQuery.of(context).viewInsets.bottom), 88 + child: Column( 89 + mainAxisSize: MainAxisSize.max, 90 + children: [ 91 + Row( 92 + mainAxisAlignment: MainAxisAlignment.spaceBetween, 93 + children: [ 94 + AppButton( 95 + label: 'Cancel', 96 + size: AppButtonSize.small, 97 + variant: AppButtonVariant.secondary, 98 + onPressed: widget.onCancel ?? () => Navigator.of(context).maybePop(), 99 + ), 100 + Text( 101 + 'Edit profile', 102 + style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), 103 + ), 104 + AppButton( 105 + label: 'Save', 106 + size: AppButtonSize.small, 107 + variant: AppButtonVariant.primary, 108 + loading: _saving, 109 + onPressed: _saving ? null : _onSave, 110 + ), 111 + ], 112 + ), 113 + const SizedBox(height: 8), 114 + GestureDetector( 115 + onTap: _pickAvatar, 116 + child: Stack( 117 + alignment: Alignment.center, 118 + children: [ 119 + CircleAvatar( 120 + radius: avatarRadius, 121 + backgroundColor: theme.colorScheme.surfaceVariant, 122 + backgroundImage: _selectedAvatar != null 123 + ? FileImage(File(_selectedAvatar!.path)) 124 + : (widget.initialAvatarUrl != null && 125 + widget.initialAvatarUrl!.isNotEmpty) 126 + ? NetworkImage(widget.initialAvatarUrl!) 127 + : null as ImageProvider<Object>?, 128 + child: 129 + (_selectedAvatar == null && 130 + (widget.initialAvatarUrl == null || 131 + widget.initialAvatarUrl!.isEmpty)) 132 + ? Icon( 133 + Icons.account_circle, 134 + size: avatarRadius * 2, 135 + color: theme.colorScheme.onSurfaceVariant, 136 + ) 137 + : null, 138 + ), 139 + Positioned( 140 + bottom: 0, 141 + right: 0, 142 + child: Container( 143 + decoration: BoxDecoration( 144 + color: theme.colorScheme.primary, 145 + shape: BoxShape.circle, 146 + ), 147 + padding: const EdgeInsets.all(6), 148 + child: Icon(Icons.edit, color: Colors.white, size: 18), 149 + ), 150 + ), 151 + ], 152 + ), 153 + ), 154 + const SizedBox(height: 16), 155 + Expanded( 156 + child: SingleChildScrollView( 157 + padding: EdgeInsets.zero, 158 + child: Column( 159 + children: [ 160 + PlainTextField( 161 + label: 'Display Name', 162 + controller: _displayNameController, 163 + maxLines: 1, 164 + ), 165 + const SizedBox(height: 12), 166 + PlainTextField( 167 + label: 'Description', 168 + controller: _descriptionController, 169 + maxLines: 3, 170 + ), 171 + ], 172 + ), 173 + ), 174 + ), 175 + const SizedBox(height: 24), 176 + ], 177 + ), 178 + ); 179 + }, 180 + ), 181 + ), 182 + ), 183 + ); 184 + } 185 + }