feat(profile): enhance profile page with visual improvements

- Add banner image support with gradient fallback
- Add avatar drop shadow for depth
- Display handle and DID (with QR icon) instead of display name
- Add "Joined" date with calendar icon
- Add tabbed content bar with icons (Posts, Comments, Likes)
- Implement frosted glass effect on collapsed header
- Show Memberships stat instead of Communities/Reputation
- Add UserProfile model and UserProfileProvider
- Add TappableAuthor widget for navigating to profiles

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+18
lib/main.dart
··· 9 9 import 'models/post.dart'; 10 10 import 'providers/auth_provider.dart'; 11 11 import 'providers/multi_feed_provider.dart'; 12 + import 'providers/user_profile_provider.dart'; 12 13 import 'providers/vote_provider.dart'; 13 14 import 'screens/auth/login_screen.dart'; 14 15 import 'screens/home/main_shell_screen.dart'; 15 16 import 'screens/home/post_detail_screen.dart'; 17 + import 'screens/home/profile_screen.dart'; 16 18 import 'screens/landing_screen.dart'; 17 19 import 'services/comment_service.dart'; 18 20 import 'services/comments_provider_cache.dart'; ··· 101 103 ), 102 104 // StreamableService for video embeds 103 105 Provider<StreamableService>(create: (_) => StreamableService()), 106 + // UserProfileProvider for profile pages 107 + ChangeNotifierProxyProvider<AuthProvider, UserProfileProvider>( 108 + create: (context) => UserProfileProvider(authProvider), 109 + update: (context, auth, previous) { 110 + // Propagate auth changes to existing provider 111 + previous?.updateAuthProvider(auth); 112 + return previous ?? UserProfileProvider(auth); 113 + }, 114 + ), 104 115 ], 105 116 child: const CovesApp(), 106 117 ), ··· 139 150 GoRoute( 140 151 path: '/feed', 141 152 builder: (context, state) => const MainShellScreen(), 153 + ), 154 + GoRoute( 155 + path: '/profile/:actor', 156 + builder: (context, state) { 157 + final actor = state.pathParameters['actor']!; 158 + return ProfileScreen(actor: actor); 159 + }, 142 160 ), 143 161 GoRoute( 144 162 path: '/post/:postUri',
+322
lib/models/user_profile.dart
··· 1 + // User profile data models for Coves 2 + // 3 + // These models match the backend response structure from: 4 + // /xrpc/social.coves.actor.getprofile 5 + 6 + /// User profile with display information and stats 7 + class UserProfile { 8 + /// Creates a UserProfile with validation. 9 + /// 10 + /// Throws [ArgumentError] if [did] doesn't start with 'did:'. 11 + factory UserProfile({ 12 + required String did, 13 + String? handle, 14 + String? displayName, 15 + String? bio, 16 + String? avatar, 17 + String? banner, 18 + DateTime? createdAt, 19 + ProfileStats? stats, 20 + ProfileViewerState? viewer, 21 + }) { 22 + if (!did.startsWith('did:')) { 23 + throw ArgumentError.value(did, 'did', 'Must start with "did:" prefix'); 24 + } 25 + return UserProfile._( 26 + did: did, 27 + handle: handle, 28 + displayName: displayName, 29 + bio: bio, 30 + avatar: avatar, 31 + banner: banner, 32 + createdAt: createdAt, 33 + stats: stats, 34 + viewer: viewer, 35 + ); 36 + } 37 + 38 + /// Private constructor - validation happens in factory 39 + const UserProfile._({ 40 + required this.did, 41 + this.handle, 42 + this.displayName, 43 + this.bio, 44 + this.avatar, 45 + this.banner, 46 + this.createdAt, 47 + this.stats, 48 + this.viewer, 49 + }); 50 + 51 + factory UserProfile.fromJson(Map<String, dynamic> json) { 52 + final did = json['did'] as String?; 53 + if (did == null || !did.startsWith('did:')) { 54 + throw FormatException('Invalid or missing DID in profile: $did'); 55 + } 56 + 57 + // Handle can be at top level or nested inside 'profile' object 58 + // (backend returns nested structure) 59 + final profileData = json['profile'] as Map<String, dynamic>?; 60 + final handle = 61 + json['handle'] as String? ?? profileData?['handle'] as String?; 62 + final createdAtStr = 63 + json['createdAt'] as String? ?? profileData?['createdAt'] as String?; 64 + 65 + return UserProfile._( 66 + did: did, 67 + handle: handle, 68 + displayName: json['displayName'] as String?, 69 + bio: json['bio'] as String?, 70 + avatar: json['avatar'] as String?, 71 + banner: json['banner'] as String?, 72 + createdAt: createdAtStr != null ? DateTime.tryParse(createdAtStr) : null, 73 + stats: 74 + json['stats'] != null 75 + ? ProfileStats.fromJson(json['stats'] as Map<String, dynamic>) 76 + : null, 77 + viewer: 78 + json['viewer'] != null 79 + ? ProfileViewerState.fromJson( 80 + json['viewer'] as Map<String, dynamic>, 81 + ) 82 + : null, 83 + ); 84 + } 85 + 86 + final String did; 87 + final String? handle; 88 + final String? displayName; 89 + final String? bio; 90 + final String? avatar; 91 + final String? banner; 92 + final DateTime? createdAt; 93 + final ProfileStats? stats; 94 + final ProfileViewerState? viewer; 95 + 96 + /// Returns display name if available, otherwise handle, otherwise DID 97 + String get displayNameOrHandle => displayName ?? handle ?? did; 98 + 99 + /// Returns handle with @ prefix if available 100 + String? get formattedHandle => handle != null ? '@$handle' : null; 101 + 102 + /// Creates a copy with the given fields replaced. 103 + /// 104 + /// Note: [did] cannot be changed to an invalid value - validation still 105 + /// applies via the factory constructor. 106 + UserProfile copyWith({ 107 + String? did, 108 + String? handle, 109 + String? displayName, 110 + String? bio, 111 + String? avatar, 112 + String? banner, 113 + DateTime? createdAt, 114 + ProfileStats? stats, 115 + ProfileViewerState? viewer, 116 + }) { 117 + return UserProfile( 118 + did: did ?? this.did, 119 + handle: handle ?? this.handle, 120 + displayName: displayName ?? this.displayName, 121 + bio: bio ?? this.bio, 122 + avatar: avatar ?? this.avatar, 123 + banner: banner ?? this.banner, 124 + createdAt: createdAt ?? this.createdAt, 125 + stats: stats ?? this.stats, 126 + viewer: viewer ?? this.viewer, 127 + ); 128 + } 129 + 130 + Map<String, dynamic> toJson() => { 131 + 'did': did, 132 + if (handle != null) 'handle': handle, 133 + if (displayName != null) 'displayName': displayName, 134 + if (bio != null) 'bio': bio, 135 + if (avatar != null) 'avatar': avatar, 136 + if (banner != null) 'banner': banner, 137 + if (createdAt != null) 'createdAt': createdAt!.toIso8601String(), 138 + if (stats != null) 'stats': stats!.toJson(), 139 + if (viewer != null) 'viewer': viewer!.toJson(), 140 + }; 141 + 142 + @override 143 + bool operator ==(Object other) => 144 + identical(this, other) || 145 + other is UserProfile && 146 + runtimeType == other.runtimeType && 147 + did == other.did && 148 + handle == other.handle && 149 + displayName == other.displayName && 150 + bio == other.bio && 151 + avatar == other.avatar && 152 + banner == other.banner && 153 + createdAt == other.createdAt && 154 + stats == other.stats && 155 + viewer == other.viewer; 156 + 157 + @override 158 + int get hashCode => Object.hash( 159 + did, 160 + handle, 161 + displayName, 162 + bio, 163 + avatar, 164 + banner, 165 + createdAt, 166 + stats, 167 + viewer, 168 + ); 169 + } 170 + 171 + /// User profile statistics 172 + /// 173 + /// Contains counts for posts, comments, communities, and reputation. 174 + /// All count fields are guaranteed to be non-negative. 175 + class ProfileStats { 176 + /// Creates ProfileStats with non-negative count validation. 177 + const ProfileStats({ 178 + this.postCount = 0, 179 + this.commentCount = 0, 180 + this.communityCount = 0, 181 + this.reputation, 182 + this.membershipCount = 0, 183 + }); 184 + 185 + factory ProfileStats.fromJson(Map<String, dynamic> json) { 186 + // Clamp values to ensure non-negative (defensive parsing) 187 + const maxInt = 0x7FFFFFFF; // Max 32-bit signed int 188 + return ProfileStats( 189 + postCount: (json['postCount'] as int? ?? 0).clamp(0, maxInt), 190 + commentCount: (json['commentCount'] as int? ?? 0).clamp(0, maxInt), 191 + communityCount: (json['communityCount'] as int? ?? 0).clamp(0, maxInt), 192 + reputation: json['reputation'] as int?, 193 + membershipCount: (json['membershipCount'] as int? ?? 0).clamp(0, maxInt), 194 + ); 195 + } 196 + 197 + final int postCount; 198 + final int commentCount; 199 + final int communityCount; 200 + final int? reputation; 201 + final int membershipCount; 202 + 203 + ProfileStats copyWith({ 204 + int? postCount, 205 + int? commentCount, 206 + int? communityCount, 207 + int? reputation, 208 + int? membershipCount, 209 + }) { 210 + return ProfileStats( 211 + postCount: postCount ?? this.postCount, 212 + commentCount: commentCount ?? this.commentCount, 213 + communityCount: communityCount ?? this.communityCount, 214 + reputation: reputation ?? this.reputation, 215 + membershipCount: membershipCount ?? this.membershipCount, 216 + ); 217 + } 218 + 219 + Map<String, dynamic> toJson() => { 220 + 'postCount': postCount, 221 + 'commentCount': commentCount, 222 + 'communityCount': communityCount, 223 + if (reputation != null) 'reputation': reputation, 224 + 'membershipCount': membershipCount, 225 + }; 226 + 227 + @override 228 + bool operator ==(Object other) => 229 + identical(this, other) || 230 + other is ProfileStats && 231 + runtimeType == other.runtimeType && 232 + postCount == other.postCount && 233 + commentCount == other.commentCount && 234 + communityCount == other.communityCount && 235 + reputation == other.reputation && 236 + membershipCount == other.membershipCount; 237 + 238 + @override 239 + int get hashCode => Object.hash( 240 + postCount, 241 + commentCount, 242 + communityCount, 243 + reputation, 244 + membershipCount, 245 + ); 246 + } 247 + 248 + /// Viewer-specific state for a profile (block status) 249 + /// 250 + /// Represents the relationship between the viewer and the profile owner. 251 + /// Invariant: if [blocked] is true, [blockUri] must be non-null. 252 + class ProfileViewerState { 253 + /// Creates ProfileViewerState. 254 + /// 255 + /// Note: The factory enforces that blocked requires blockUri. 256 + factory ProfileViewerState({ 257 + bool blocked = false, 258 + bool blockedBy = false, 259 + String? blockUri, 260 + }) { 261 + // Enforce invariant: if blocked, must have blockUri 262 + // Defensive: treat as not blocked if no URI 263 + final effectiveBlocked = blocked && blockUri != null; 264 + return ProfileViewerState._( 265 + blocked: effectiveBlocked, 266 + blockedBy: blockedBy, 267 + blockUri: blockUri, 268 + ); 269 + } 270 + 271 + const ProfileViewerState._({ 272 + required this.blocked, 273 + required this.blockedBy, 274 + this.blockUri, 275 + }); 276 + 277 + factory ProfileViewerState.fromJson(Map<String, dynamic> json) { 278 + final blocked = json['blocked'] as bool? ?? false; 279 + final blockUri = json['blockUri'] as String?; 280 + 281 + return ProfileViewerState._( 282 + // If blocked but no blockUri, treat as not blocked (defensive) 283 + blocked: blocked && blockUri != null, 284 + blockedBy: json['blockedBy'] as bool? ?? false, 285 + blockUri: blockUri, 286 + ); 287 + } 288 + 289 + final bool blocked; 290 + final bool blockedBy; 291 + final String? blockUri; 292 + 293 + ProfileViewerState copyWith({ 294 + bool? blocked, 295 + bool? blockedBy, 296 + String? blockUri, 297 + }) { 298 + return ProfileViewerState( 299 + blocked: blocked ?? this.blocked, 300 + blockedBy: blockedBy ?? this.blockedBy, 301 + blockUri: blockUri ?? this.blockUri, 302 + ); 303 + } 304 + 305 + Map<String, dynamic> toJson() => { 306 + 'blocked': blocked, 307 + 'blockedBy': blockedBy, 308 + if (blockUri != null) 'blockUri': blockUri, 309 + }; 310 + 311 + @override 312 + bool operator ==(Object other) => 313 + identical(this, other) || 314 + other is ProfileViewerState && 315 + runtimeType == other.runtimeType && 316 + blocked == other.blocked && 317 + blockedBy == other.blockedBy && 318 + blockUri == other.blockUri; 319 + 320 + @override 321 + int get hashCode => Object.hash(blocked, blockedBy, blockUri); 322 + }
+392
lib/providers/user_profile_provider.dart
··· 1 + import 'package:flutter/foundation.dart'; 2 + 3 + import '../models/feed_state.dart'; 4 + import '../models/post.dart'; 5 + import '../models/user_profile.dart'; 6 + import '../services/api_exceptions.dart'; 7 + import '../services/coves_api_service.dart'; 8 + import 'auth_provider.dart'; 9 + 10 + /// User Profile Provider 11 + /// 12 + /// Manages state for user profile pages including profile data and 13 + /// author posts feed. Supports viewing both own profile and other users. 14 + /// 15 + /// IMPORTANT: Accepts AuthProvider reference to fetch fresh access 16 + /// tokens before each authenticated request (critical for atProto OAuth 17 + /// token rotation). 18 + class UserProfileProvider with ChangeNotifier { 19 + UserProfileProvider(AuthProvider authProvider, {CovesApiService? apiService}) 20 + : _authProvider = authProvider { 21 + _apiService = 22 + apiService ?? 23 + CovesApiService( 24 + tokenGetter: _authProvider.getAccessToken, 25 + tokenRefresher: _authProvider.refreshToken, 26 + signOutHandler: _authProvider.signOut, 27 + ); 28 + 29 + // Listen to auth state changes 30 + _authProvider.addListener(_onAuthChanged); 31 + } 32 + 33 + AuthProvider _authProvider; 34 + 35 + /// Update auth provider reference (called by ChangeNotifierProxyProvider) 36 + /// 37 + /// This ensures token refresh and sign-out handlers stay in sync when 38 + /// auth state changes propagate through the provider tree. 39 + void updateAuthProvider(AuthProvider newAuth) { 40 + if (_authProvider != newAuth) { 41 + _authProvider.removeListener(_onAuthChanged); 42 + _authProvider = newAuth; 43 + _authProvider.addListener(_onAuthChanged); 44 + // Recreate API service with new auth callbacks 45 + _apiService.dispose(); 46 + _apiService = CovesApiService( 47 + tokenGetter: _authProvider.getAccessToken, 48 + tokenRefresher: _authProvider.refreshToken, 49 + signOutHandler: _authProvider.signOut, 50 + ); 51 + } 52 + } 53 + 54 + late CovesApiService _apiService; 55 + 56 + // Profile state 57 + UserProfile? _profile; 58 + bool _isLoadingProfile = false; 59 + String? _profileError; 60 + String? _currentProfileDid; 61 + 62 + // Posts feed state (reusing FeedState pattern) 63 + FeedState _postsState = FeedState.initial(); 64 + 65 + // LRU profile cache keyed by DID (max 50 entries) 66 + static const int _maxCacheSize = 50; 67 + final Map<String, UserProfile> _profileCache = {}; 68 + final List<String> _cacheAccessOrder = []; 69 + 70 + /// Add profile to cache with LRU eviction 71 + void _cacheProfile(UserProfile profile) { 72 + final did = profile.did; 73 + 74 + // Remove from current position in access order 75 + _cacheAccessOrder.remove(did); 76 + 77 + // Add to end (most recently used) 78 + _cacheAccessOrder.add(did); 79 + _profileCache[did] = profile; 80 + 81 + // Evict oldest entries if over capacity 82 + while (_cacheAccessOrder.length > _maxCacheSize) { 83 + final oldestDid = _cacheAccessOrder.removeAt(0); 84 + _profileCache.remove(oldestDid); 85 + } 86 + } 87 + 88 + /// Get profile from cache (updates access order) 89 + UserProfile? _getCachedProfile(String did) { 90 + final profile = _profileCache[did]; 91 + if (profile != null) { 92 + // Update access order (move to end) 93 + _cacheAccessOrder.remove(did); 94 + _cacheAccessOrder.add(did); 95 + } 96 + return profile; 97 + } 98 + 99 + // Getters 100 + UserProfile? get profile => _profile; 101 + bool get isLoadingProfile => _isLoadingProfile; 102 + String? get profileError => _profileError; 103 + String? get currentProfileDid => _currentProfileDid; 104 + FeedState get postsState => _postsState; 105 + 106 + /// Check if currently viewing own profile 107 + bool get isOwnProfile { 108 + if (_currentProfileDid == null) return false; 109 + return _currentProfileDid == _authProvider.did; 110 + } 111 + 112 + /// Handle auth state changes 113 + void _onAuthChanged() { 114 + // Clear profile cache on sign-out to prevent stale data 115 + if (!_authProvider.isAuthenticated) { 116 + if (kDebugMode) { 117 + debugPrint('🔒 User signed out - clearing profile cache'); 118 + } 119 + _profileCache.clear(); 120 + _cacheAccessOrder.clear(); 121 + _profile = null; 122 + _postsState = FeedState.initial(); 123 + _currentProfileDid = null; 124 + notifyListeners(); 125 + } 126 + } 127 + 128 + /// Load profile for a user 129 + /// 130 + /// Parameters: 131 + /// - [actor]: User's DID or handle (required) 132 + /// - [forceRefresh]: Bypass cache and fetch fresh data 133 + Future<void> loadProfile(String actor, {bool forceRefresh = false}) async { 134 + // Check cache first (updates LRU access order) 135 + final cachedProfile = _getCachedProfile(actor); 136 + if (cachedProfile != null && !forceRefresh) { 137 + _profile = cachedProfile; 138 + _currentProfileDid = cachedProfile.did; 139 + _profileError = null; 140 + notifyListeners(); 141 + return; 142 + } 143 + 144 + if (_isLoadingProfile) return; 145 + 146 + _isLoadingProfile = true; 147 + _profileError = null; 148 + _currentProfileDid = actor.startsWith('did:') ? actor : null; 149 + notifyListeners(); 150 + 151 + try { 152 + final profile = await _apiService.getProfile(actor: actor); 153 + 154 + // Cache by DID with LRU eviction 155 + _cacheProfile(profile); 156 + 157 + _profile = profile; 158 + _currentProfileDid = profile.did; 159 + _isLoadingProfile = false; 160 + _profileError = null; 161 + 162 + if (kDebugMode) { 163 + debugPrint('✅ Profile loaded: ${profile.displayNameOrHandle}'); 164 + } 165 + } on NotFoundException { 166 + _isLoadingProfile = false; 167 + _profileError = 'User not found'; 168 + _profile = null; 169 + 170 + if (kDebugMode) { 171 + debugPrint('❌ Profile not found: $actor'); 172 + } 173 + } on AuthenticationException { 174 + _isLoadingProfile = false; 175 + _profileError = 'Please sign in to view this profile'; 176 + 177 + if (kDebugMode) { 178 + debugPrint('❌ Auth required to load profile: $actor'); 179 + } 180 + } on NetworkException catch (e) { 181 + _isLoadingProfile = false; 182 + _profileError = 'Network error. Check your connection.'; 183 + 184 + if (kDebugMode) { 185 + debugPrint('❌ Network error loading profile: ${e.message}'); 186 + } 187 + } on ApiException catch (e) { 188 + _isLoadingProfile = false; 189 + _profileError = e.message; 190 + 191 + if (kDebugMode) { 192 + debugPrint('❌ Failed to load profile: ${e.message}'); 193 + } 194 + } on FormatException catch (e) { 195 + _isLoadingProfile = false; 196 + _profileError = 'Invalid data received from server'; 197 + 198 + if (kDebugMode) { 199 + debugPrint('❌ Format error loading profile: $e'); 200 + } 201 + } on Exception catch (e) { 202 + // Catch-all for other exceptions 203 + _isLoadingProfile = false; 204 + _profileError = 'Failed to load profile. Please try again.'; 205 + 206 + if (kDebugMode) { 207 + debugPrint('❌ Unexpected error loading profile: $e'); 208 + } 209 + } 210 + 211 + notifyListeners(); 212 + } 213 + 214 + /// Load posts by the current profile's author 215 + /// 216 + /// Parameters: 217 + /// - [refresh]: Reload from beginning instead of paginating 218 + Future<void> loadPosts({bool refresh = false}) async { 219 + if (_currentProfileDid == null) { 220 + // Set error state instead of silently returning 221 + _postsState = _postsState.copyWith( 222 + error: 'No profile loaded', 223 + isLoading: false, 224 + isLoadingMore: false, 225 + ); 226 + notifyListeners(); 227 + return; 228 + } 229 + if (_postsState.isLoading || _postsState.isLoadingMore) return; 230 + 231 + final currentState = _postsState; 232 + 233 + try { 234 + if (refresh) { 235 + _postsState = currentState.copyWith(isLoading: true, error: null); 236 + } else { 237 + if (!currentState.hasMore) return; 238 + _postsState = currentState.copyWith(isLoadingMore: true); 239 + } 240 + notifyListeners(); 241 + 242 + final response = await _apiService.getAuthorPosts( 243 + actor: _currentProfileDid!, 244 + cursor: refresh ? null : currentState.cursor, 245 + ); 246 + 247 + final List<FeedViewPost> newPosts; 248 + if (refresh) { 249 + newPosts = response.feed; 250 + } else { 251 + newPosts = [...currentState.posts, ...response.feed]; 252 + } 253 + 254 + _postsState = currentState.copyWith( 255 + posts: newPosts, 256 + cursor: response.cursor, 257 + hasMore: response.cursor != null, 258 + error: null, 259 + isLoading: false, 260 + isLoadingMore: false, 261 + lastRefreshTime: 262 + refresh ? DateTime.now() : currentState.lastRefreshTime, 263 + ); 264 + 265 + if (kDebugMode) { 266 + debugPrint('✅ Author posts loaded: ${newPosts.length} posts total'); 267 + } 268 + } on AuthenticationException { 269 + _postsState = currentState.copyWith( 270 + error: 'Please sign in to view posts', 271 + isLoading: false, 272 + isLoadingMore: false, 273 + ); 274 + 275 + if (kDebugMode) { 276 + debugPrint('❌ Auth required to load posts'); 277 + } 278 + } on NotFoundException { 279 + // Author posts endpoint not implemented yet - show empty state 280 + _postsState = currentState.copyWith( 281 + posts: [], 282 + hasMore: false, 283 + error: null, 284 + isLoading: false, 285 + isLoadingMore: false, 286 + ); 287 + 288 + if (kDebugMode) { 289 + debugPrint('⚠️ Author posts endpoint not available'); 290 + } 291 + } on NetworkException catch (e) { 292 + _postsState = currentState.copyWith( 293 + error: 'Network error. Check your connection.', 294 + isLoading: false, 295 + isLoadingMore: false, 296 + ); 297 + 298 + if (kDebugMode) { 299 + debugPrint('❌ Network error loading posts: ${e.message}'); 300 + } 301 + } on ApiException catch (e) { 302 + _postsState = currentState.copyWith( 303 + error: e.message, 304 + isLoading: false, 305 + isLoadingMore: false, 306 + ); 307 + 308 + if (kDebugMode) { 309 + debugPrint('❌ Failed to load author posts: ${e.message}'); 310 + } 311 + } on FormatException catch (e) { 312 + _postsState = currentState.copyWith( 313 + error: 'Invalid data received from server', 314 + isLoading: false, 315 + isLoadingMore: false, 316 + ); 317 + 318 + if (kDebugMode) { 319 + debugPrint('❌ Format error loading posts: $e'); 320 + } 321 + } on Exception catch (e) { 322 + // Catch-all for other exceptions 323 + _postsState = currentState.copyWith( 324 + error: 'Failed to load posts. Please try again.', 325 + isLoading: false, 326 + isLoadingMore: false, 327 + ); 328 + 329 + if (kDebugMode) { 330 + debugPrint('❌ Unexpected error loading posts: $e'); 331 + } 332 + } 333 + 334 + notifyListeners(); 335 + } 336 + 337 + /// Load more posts (pagination) 338 + Future<void> loadMorePosts() async { 339 + await loadPosts(refresh: false); 340 + } 341 + 342 + /// Clear current profile and reset state 343 + void clearProfile() { 344 + _profile = null; 345 + _currentProfileDid = null; 346 + _postsState = FeedState.initial(); 347 + _profileError = null; 348 + _isLoadingProfile = false; 349 + notifyListeners(); 350 + } 351 + 352 + /// Set an error message directly (for cases like missing actor) 353 + void setError(String message) { 354 + _profileError = message; 355 + _isLoadingProfile = false; 356 + notifyListeners(); 357 + } 358 + 359 + /// Retry loading profile after error 360 + /// 361 + /// Returns: 362 + /// - `true` if retry was initiated (profile DID was available) 363 + /// - `false` if no profile DID is available to retry 364 + /// 365 + /// Note: A return of `true` does not mean the profile loaded successfully, 366 + /// only that the retry attempt was started. Check [profileError] after 367 + /// the operation completes to determine if it succeeded. 368 + Future<bool> retryProfile() async { 369 + if (_currentProfileDid == null) { 370 + if (kDebugMode) { 371 + debugPrint('⚠️ retryProfile called but no profile DID available'); 372 + } 373 + return false; 374 + } 375 + await loadProfile(_currentProfileDid!, forceRefresh: true); 376 + return true; 377 + } 378 + 379 + /// Retry loading posts after error 380 + Future<void> retryPosts() async { 381 + _postsState = _postsState.copyWith(error: null); 382 + notifyListeners(); 383 + await loadPosts(refresh: true); 384 + } 385 + 386 + @override 387 + void dispose() { 388 + _authProvider.removeListener(_onAuthChanged); 389 + _apiService.dispose(); 390 + super.dispose(); 391 + } 392 + }
+553 -49
lib/screens/home/profile_screen.dart
··· 1 + import 'dart:ui'; 2 + 1 3 import 'package:flutter/material.dart'; 2 4 import 'package:go_router/go_router.dart'; 3 5 import 'package:provider/provider.dart'; 6 + import 'package:share_plus/share_plus.dart'; 4 7 5 8 import '../../constants/app_colors.dart'; 6 9 import '../../providers/auth_provider.dart'; 10 + import '../../providers/user_profile_provider.dart'; 11 + import '../../widgets/loading_error_states.dart'; 12 + import '../../widgets/post_card.dart'; 7 13 import '../../widgets/primary_button.dart'; 14 + import '../../widgets/profile_header.dart'; 8 15 9 - class ProfileScreen extends StatelessWidget { 10 - const ProfileScreen({super.key}); 16 + /// Profile screen displaying user profile with header and posts 17 + /// 18 + /// Supports viewing both own profile (via bottom nav) and other users 19 + /// (via /profile/:actor route with DID or handle parameter). 20 + class ProfileScreen extends StatefulWidget { 21 + const ProfileScreen({this.actor, super.key}); 22 + 23 + /// User DID or handle to display. If null, shows current user's profile. 24 + final String? actor; 25 + 26 + @override 27 + State<ProfileScreen> createState() => _ProfileScreenState(); 28 + } 29 + 30 + class _ProfileScreenState extends State<ProfileScreen> { 31 + int _selectedTabIndex = 0; 32 + 33 + @override 34 + void initState() { 35 + super.initState(); 36 + WidgetsBinding.instance.addPostFrameCallback((_) { 37 + _loadProfile(); 38 + }); 39 + } 40 + 41 + @override 42 + void didUpdateWidget(ProfileScreen oldWidget) { 43 + super.didUpdateWidget(oldWidget); 44 + if (oldWidget.actor != widget.actor) { 45 + _loadProfile(); 46 + } 47 + } 48 + 49 + Future<void> _loadProfile() async { 50 + final authProvider = context.read<AuthProvider>(); 51 + final profileProvider = context.read<UserProfileProvider>(); 52 + 53 + // Determine which profile to load 54 + final actor = widget.actor ?? authProvider.did; 55 + 56 + if (actor == null) { 57 + // No actor available - set error state instead of silently failing 58 + profileProvider.setError('Unable to determine profile to load'); 59 + return; 60 + } 61 + 62 + await profileProvider.loadProfile(actor); 63 + 64 + // Check mounted after async gap (CLAUDE.md requirement) 65 + if (!mounted) return; 66 + 67 + // Only load posts if profile loaded successfully (no error) 68 + if (profileProvider.profileError == null) { 69 + await profileProvider.loadPosts(refresh: true); 70 + } 71 + } 72 + 73 + void _showMenuSheet(BuildContext context) { 74 + showModalBottomSheet<void>( 75 + context: context, 76 + backgroundColor: AppColors.backgroundSecondary, 77 + shape: const RoundedRectangleBorder( 78 + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), 79 + ), 80 + builder: (sheetContext) { 81 + return SafeArea( 82 + child: Column( 83 + mainAxisSize: MainAxisSize.min, 84 + children: [ 85 + // Handle bar 86 + Container( 87 + margin: const EdgeInsets.only(top: 12), 88 + width: 40, 89 + height: 4, 90 + decoration: BoxDecoration( 91 + color: AppColors.textSecondary.withValues(alpha: 0.3), 92 + borderRadius: BorderRadius.circular(2), 93 + ), 94 + ), 95 + const SizedBox(height: 16), 96 + // Sign out option 97 + ListTile( 98 + leading: Icon( 99 + Icons.logout, 100 + color: Colors.red.shade400, 101 + ), 102 + title: Text( 103 + 'Sign Out', 104 + style: TextStyle( 105 + color: Colors.red.shade400, 106 + fontSize: 16, 107 + ), 108 + ), 109 + onTap: () async { 110 + Navigator.pop(sheetContext); 111 + await _handleSignOut(); 112 + }, 113 + ), 114 + const SizedBox(height: 8), 115 + ], 116 + ), 117 + ); 118 + }, 119 + ); 120 + } 121 + 122 + void _handleShare() { 123 + final profile = context.read<UserProfileProvider>().profile; 124 + if (profile == null) return; 125 + 126 + final handle = profile.handle; 127 + final profileUrl = 'https://coves.social/profile/$handle'; 128 + final subject = 'Check out ${profile.displayNameOrHandle} on Coves'; 129 + Share.share(profileUrl, subject: subject); 130 + } 131 + 132 + Future<void> _handleSignOut() async { 133 + final authProvider = context.read<AuthProvider>(); 134 + await authProvider.signOut(); 135 + 136 + // Check mounted after async gap 137 + if (!mounted) return; 138 + 139 + // Navigate to login screen 140 + context.go('/login'); 141 + } 11 142 12 143 @override 13 144 Widget build(BuildContext context) { 14 - final authProvider = Provider.of<AuthProvider>(context); 15 - final isAuthenticated = authProvider.isAuthenticated; 145 + final authProvider = context.watch<AuthProvider>(); 146 + final profileProvider = context.watch<UserProfileProvider>(); 147 + 148 + // If no actor specified and not authenticated, show sign-in prompt 149 + if (widget.actor == null && !authProvider.isAuthenticated) { 150 + return _buildSignInPrompt(context); 151 + } 152 + 153 + // Show loading state 154 + if (profileProvider.isLoadingProfile && profileProvider.profile == null) { 155 + return Scaffold( 156 + backgroundColor: AppColors.background, 157 + appBar: _buildAppBar(context, null), 158 + body: const FullScreenLoading(), 159 + ); 160 + } 161 + 162 + // Show error state 163 + if (profileProvider.profileError != null && 164 + profileProvider.profile == null) { 165 + return Scaffold( 166 + backgroundColor: AppColors.background, 167 + appBar: _buildAppBar(context, null), 168 + body: FullScreenError( 169 + title: 'Failed to load profile', 170 + message: profileProvider.profileError!, 171 + onRetry: () => profileProvider.retryProfile(), 172 + ), 173 + ); 174 + } 16 175 17 176 return Scaffold( 18 - backgroundColor: const Color(0xFF0B0F14), 177 + backgroundColor: AppColors.background, 178 + body: RefreshIndicator( 179 + color: AppColors.primary, 180 + backgroundColor: AppColors.backgroundSecondary, 181 + onRefresh: () async { 182 + final actor = widget.actor ?? authProvider.did; 183 + if (actor != null) { 184 + await profileProvider.loadProfile(actor, forceRefresh: true); 185 + await profileProvider.loadPosts(refresh: true); 186 + } 187 + }, 188 + child: CustomScrollView( 189 + slivers: [ 190 + // Collapsing app bar with profile header and frosted glass effect 191 + SliverAppBar( 192 + backgroundColor: Colors.transparent, 193 + foregroundColor: AppColors.textPrimary, 194 + expandedHeight: 220, 195 + pinned: true, 196 + stretch: true, 197 + leading: 198 + widget.actor != null 199 + ? IconButton( 200 + icon: const Icon(Icons.arrow_back), 201 + onPressed: () => context.pop(), 202 + ) 203 + : null, 204 + automaticallyImplyLeading: widget.actor != null, 205 + actions: profileProvider.isOwnProfile 206 + ? [ 207 + IconButton( 208 + icon: const Icon(Icons.share_outlined), 209 + onPressed: _handleShare, 210 + tooltip: 'Share Profile', 211 + ), 212 + IconButton( 213 + icon: const Icon(Icons.menu), 214 + onPressed: () => _showMenuSheet(context), 215 + tooltip: 'Menu', 216 + ), 217 + ] 218 + : null, 219 + flexibleSpace: LayoutBuilder( 220 + builder: (context, constraints) { 221 + // Calculate collapse progress (0 = expanded, 1 = collapsed) 222 + const expandedHeight = 220.0; 223 + final collapsedHeight = kToolbarHeight + 224 + MediaQuery.of(context).padding.top; 225 + final currentHeight = constraints.maxHeight; 226 + final collapseProgress = 1 - 227 + ((currentHeight - collapsedHeight) / 228 + (expandedHeight - collapsedHeight)) 229 + .clamp(0.0, 1.0); 230 + 231 + return Stack( 232 + fit: StackFit.expand, 233 + children: [ 234 + // Profile header background (parallax effect) 235 + Positioned( 236 + top: 0, 237 + left: 0, 238 + right: 0, 239 + bottom: 0, 240 + child: ProfileHeader( 241 + profile: profileProvider.profile, 242 + isOwnProfile: profileProvider.isOwnProfile, 243 + ), 244 + ), 245 + // Frosted glass overlay when collapsed 246 + if (collapseProgress > 0) 247 + Positioned( 248 + top: 0, 249 + left: 0, 250 + right: 0, 251 + height: collapsedHeight, 252 + child: ClipRect( 253 + child: BackdropFilter( 254 + filter: ImageFilter.blur( 255 + sigmaX: 10 * collapseProgress, 256 + sigmaY: 10 * collapseProgress, 257 + ), 258 + child: Container( 259 + color: AppColors.background 260 + .withValues(alpha: 0.7 * collapseProgress), 261 + ), 262 + ), 263 + ), 264 + ), 265 + ], 266 + ); 267 + }, 268 + ), 269 + ), 270 + // Tab bar header 271 + SliverPersistentHeader( 272 + pinned: true, 273 + delegate: _ProfileTabBarDelegate( 274 + child: Container( 275 + color: AppColors.background, 276 + child: _ProfileTabBar( 277 + selectedIndex: _selectedTabIndex, 278 + onTabChanged: (index) { 279 + setState(() { 280 + _selectedTabIndex = index; 281 + }); 282 + }, 283 + ), 284 + ), 285 + ), 286 + ), 287 + // Content based on selected tab 288 + if (_selectedTabIndex == 0) 289 + _buildPostsList(profileProvider) 290 + else 291 + _buildComingSoonPlaceholder( 292 + _selectedTabIndex == 1 ? 'Comments' : 'Likes', 293 + ), 294 + ], 295 + ), 296 + ), 297 + ); 298 + } 299 + 300 + AppBar _buildAppBar(BuildContext context, String? title) { 301 + return AppBar( 302 + backgroundColor: AppColors.background, 303 + foregroundColor: AppColors.textPrimary, 304 + title: Text(title ?? 'Profile'), 305 + leading: 306 + widget.actor != null 307 + ? IconButton( 308 + icon: const Icon(Icons.arrow_back), 309 + onPressed: () => context.pop(), 310 + ) 311 + : null, 312 + automaticallyImplyLeading: widget.actor != null, 313 + ); 314 + } 315 + 316 + Widget _buildSignInPrompt(BuildContext context) { 317 + return Scaffold( 318 + backgroundColor: AppColors.background, 19 319 appBar: AppBar( 20 - backgroundColor: const Color(0xFF0B0F14), 21 - foregroundColor: Colors.white, 320 + backgroundColor: AppColors.background, 321 + foregroundColor: AppColors.textPrimary, 22 322 title: const Text('Profile'), 23 323 automaticallyImplyLeading: false, 24 324 ), ··· 30 330 children: [ 31 331 const Icon(Icons.person, size: 64, color: AppColors.primary), 32 332 const SizedBox(height: 24), 33 - Text( 34 - isAuthenticated ? 'Your Profile' : 'Profile', 35 - style: const TextStyle( 333 + const Text( 334 + 'Profile', 335 + style: TextStyle( 36 336 fontSize: 28, 37 - color: Colors.white, 337 + color: AppColors.textPrimary, 38 338 fontWeight: FontWeight.bold, 39 339 ), 40 340 ), 41 341 const SizedBox(height: 16), 42 - if (isAuthenticated && authProvider.did != null) ...[ 43 - Text( 44 - 'Signed in as:', 45 - style: TextStyle( 46 - fontSize: 14, 47 - color: Colors.white.withValues(alpha: 0.6), 48 - ), 342 + const Text( 343 + 'Sign in to view your profile', 344 + style: TextStyle(fontSize: 16, color: AppColors.textSecondary), 345 + textAlign: TextAlign.center, 346 + ), 347 + const SizedBox(height: 48), 348 + PrimaryButton( 349 + title: 'Sign in', 350 + onPressed: () => context.go('/login'), 351 + ), 352 + ], 353 + ), 354 + ), 355 + ), 356 + ); 357 + } 358 + 359 + Widget _buildPostsList(UserProfileProvider profileProvider) { 360 + final postsState = profileProvider.postsState; 361 + 362 + // Loading state for posts 363 + if (postsState.isLoading && postsState.posts.isEmpty) { 364 + return const SliverFillRemaining( 365 + child: Center( 366 + child: CircularProgressIndicator(color: AppColors.primary), 367 + ), 368 + ); 369 + } 370 + 371 + // Error state for posts 372 + if (postsState.error != null && postsState.posts.isEmpty) { 373 + return SliverFillRemaining( 374 + child: Center( 375 + child: InlineError( 376 + message: postsState.error!, 377 + onRetry: () => profileProvider.retryPosts(), 378 + ), 379 + ), 380 + ); 381 + } 382 + 383 + // Empty state 384 + if (postsState.posts.isEmpty && !postsState.isLoading) { 385 + return const SliverFillRemaining( 386 + child: Center( 387 + child: Text( 388 + 'No posts yet', 389 + style: TextStyle(fontSize: 16, color: AppColors.textSecondary), 390 + ), 391 + ), 392 + ); 393 + } 394 + 395 + // Posts list 396 + // Only add extra slot for loading/error indicators, not just hasMore 397 + final showLoadingSlot = 398 + postsState.isLoadingMore || postsState.error != null; 399 + 400 + return SliverList( 401 + delegate: SliverChildBuilderDelegate((context, index) { 402 + // Load more when reaching end 403 + if (index == postsState.posts.length - 3 && postsState.hasMore) { 404 + profileProvider.loadMorePosts(); 405 + } 406 + 407 + // Show loading indicator or error at the end 408 + if (index == postsState.posts.length) { 409 + if (postsState.isLoadingMore) { 410 + return const InlineLoading(); 411 + } 412 + if (postsState.error != null) { 413 + return InlineError( 414 + message: postsState.error!, 415 + onRetry: () => profileProvider.loadMorePosts(), 416 + ); 417 + } 418 + // Shouldn't reach here due to showLoadingSlot check 419 + return const SizedBox.shrink(); 420 + } 421 + 422 + final feedViewPost = postsState.posts[index]; 423 + return PostCard(post: feedViewPost); 424 + }, childCount: postsState.posts.length + (showLoadingSlot ? 1 : 0)), 425 + ); 426 + } 427 + 428 + Widget _buildComingSoonPlaceholder(String feature) { 429 + return SliverFillRemaining( 430 + child: Center( 431 + child: Column( 432 + mainAxisAlignment: MainAxisAlignment.center, 433 + children: [ 434 + Icon( 435 + feature == 'Comments' 436 + ? Icons.chat_bubble_outline 437 + : Icons.favorite_outline, 438 + size: 48, 439 + color: AppColors.textSecondary, 440 + ), 441 + const SizedBox(height: 16), 442 + Text( 443 + '$feature coming soon', 444 + style: const TextStyle( 445 + fontSize: 16, 446 + color: AppColors.textSecondary, 447 + ), 448 + ), 449 + ], 450 + ), 451 + ), 452 + ); 453 + } 454 + } 455 + 456 + /// Tab bar for profile content with icons 457 + class _ProfileTabBar extends StatelessWidget { 458 + const _ProfileTabBar({ 459 + required this.selectedIndex, 460 + required this.onTabChanged, 461 + }); 462 + 463 + final int selectedIndex; 464 + final ValueChanged<int> onTabChanged; 465 + 466 + @override 467 + Widget build(BuildContext context) { 468 + return Container( 469 + height: 48, 470 + decoration: const BoxDecoration( 471 + border: Border(bottom: BorderSide(color: AppColors.border)), 472 + ), 473 + child: Row( 474 + children: [ 475 + Expanded( 476 + child: _TabItem( 477 + label: 'Posts', 478 + icon: Icons.grid_view, 479 + isSelected: selectedIndex == 0, 480 + onTap: () => onTabChanged(0), 481 + ), 482 + ), 483 + Expanded( 484 + child: _TabItem( 485 + label: 'Comments', 486 + icon: Icons.chat_bubble_outline, 487 + isSelected: selectedIndex == 1, 488 + onTap: () => onTabChanged(1), 489 + ), 490 + ), 491 + Expanded( 492 + child: _TabItem( 493 + label: 'Likes', 494 + icon: Icons.favorite_outline, 495 + isSelected: selectedIndex == 2, 496 + onTap: () => onTabChanged(2), 497 + ), 498 + ), 499 + ], 500 + ), 501 + ); 502 + } 503 + } 504 + 505 + class _TabItem extends StatelessWidget { 506 + const _TabItem({ 507 + required this.label, 508 + required this.icon, 509 + required this.isSelected, 510 + required this.onTap, 511 + }); 512 + 513 + final String label; 514 + final IconData icon; 515 + final bool isSelected; 516 + final VoidCallback onTap; 517 + 518 + @override 519 + Widget build(BuildContext context) { 520 + return GestureDetector( 521 + onTap: onTap, 522 + behavior: HitTestBehavior.opaque, 523 + child: Container( 524 + alignment: Alignment.center, 525 + child: Column( 526 + mainAxisAlignment: MainAxisAlignment.center, 527 + children: [ 528 + Row( 529 + mainAxisSize: MainAxisSize.min, 530 + children: [ 531 + Icon( 532 + icon, 533 + size: 16, 534 + color: isSelected 535 + ? AppColors.textPrimary 536 + : AppColors.textSecondary, 49 537 ), 50 - const SizedBox(height: 4), 538 + const SizedBox(width: 6), 51 539 Text( 52 - authProvider.did!, 53 - style: const TextStyle( 54 - fontSize: 16, 55 - color: Color(0xFFB6C2D2), 56 - fontFamily: 'monospace', 540 + label, 541 + style: TextStyle( 542 + fontSize: 13, 543 + fontWeight: 544 + isSelected ? FontWeight.bold : FontWeight.normal, 545 + color: isSelected 546 + ? AppColors.textPrimary 547 + : AppColors.textSecondary, 57 548 ), 58 - textAlign: TextAlign.center, 59 - ), 60 - const SizedBox(height: 48), 61 - PrimaryButton( 62 - title: 'Sign Out', 63 - onPressed: () async { 64 - await authProvider.signOut(); 65 - if (context.mounted) { 66 - context.go('/'); 67 - } 68 - }, 69 - variant: ButtonVariant.outline, 70 - ), 71 - ] else ...[ 72 - const Text( 73 - 'Sign in to view your profile', 74 - style: TextStyle(fontSize: 16, color: Color(0xFFB6C2D2)), 75 - textAlign: TextAlign.center, 76 - ), 77 - const SizedBox(height: 48), 78 - PrimaryButton( 79 - title: 'Sign in', 80 - onPressed: () => context.go('/login'), 81 549 ), 82 550 ], 83 - ], 84 - ), 551 + ), 552 + const SizedBox(height: 8), 553 + Container( 554 + height: 3, 555 + width: 50, 556 + decoration: BoxDecoration( 557 + color: isSelected ? AppColors.primary : Colors.transparent, 558 + borderRadius: BorderRadius.circular(2), 559 + ), 560 + ), 561 + ], 85 562 ), 86 563 ), 87 564 ); 88 565 } 89 566 } 567 + 568 + /// Delegate for pinned tab bar header 569 + class _ProfileTabBarDelegate extends SliverPersistentHeaderDelegate { 570 + _ProfileTabBarDelegate({required this.child}); 571 + 572 + final Widget child; 573 + 574 + @override 575 + Widget build( 576 + BuildContext context, 577 + double shrinkOffset, 578 + bool overlapsContent, 579 + ) { 580 + return child; 581 + } 582 + 583 + @override 584 + double get maxExtent => 48; 585 + 586 + @override 587 + double get minExtent => 48; 588 + 589 + @override 590 + bool shouldRebuild(covariant _ProfileTabBarDelegate oldDelegate) { 591 + return child != oldDelegate.child; 592 + } 593 + }
+134 -12
lib/services/coves_api_service.dart
··· 5 5 import '../models/comment.dart'; 6 6 import '../models/community.dart'; 7 7 import '../models/post.dart'; 8 + import '../models/user_profile.dart'; 8 9 import 'api_exceptions.dart'; 9 10 10 11 /// Coves API Service ··· 388 389 debugPrint('📡 Fetching communities: sort=$sort, limit=$limit'); 389 390 } 390 391 391 - final queryParams = <String, dynamic>{ 392 - 'limit': limit, 393 - 'sort': sort, 394 - }; 392 + final queryParams = <String, dynamic>{'limit': limit, 'sort': sort}; 395 393 396 394 if (cursor != null) { 397 395 queryParams['cursor'] = cursor; ··· 448 446 } 449 447 450 448 // Build request body with only non-null fields 451 - final requestBody = <String, dynamic>{ 452 - 'community': community, 453 - }; 449 + final requestBody = <String, dynamic>{'community': community}; 454 450 455 451 if (title != null) { 456 452 requestBody['title'] = title; ··· 481 477 debugPrint('✅ Post created successfully'); 482 478 } 483 479 484 - return CreatePostResponse.fromJson( 485 - response.data as Map<String, dynamic>, 486 - ); 480 + return CreatePostResponse.fromJson(response.data as Map<String, dynamic>); 487 481 } on DioException catch (e) { 488 482 _handleDioException(e, 'create post'); 489 483 } catch (e) { ··· 545 539 } 546 540 } 547 541 542 + /// Get user profile by DID or handle 543 + /// 544 + /// Fetches detailed profile information for a user. 545 + /// Works with both DID (did:plc:...) or handle (user.bsky.social). 546 + /// 547 + /// Parameters: 548 + /// - [actor]: User's DID or handle (required) 549 + /// 550 + /// Throws: 551 + /// - `NotFoundException` if the user does not exist 552 + /// - `UnauthorizedException` if authentication is required/expired 553 + /// - `ApiException` for other API errors 554 + Future<UserProfile> getProfile({required String actor}) async { 555 + try { 556 + if (kDebugMode) { 557 + debugPrint('📡 Fetching profile for: $actor'); 558 + } 559 + 560 + final response = await _dio.get( 561 + '/xrpc/social.coves.actor.getprofile', 562 + queryParameters: {'actor': actor}, 563 + ); 564 + 565 + if (kDebugMode) { 566 + debugPrint('✅ Profile fetched for: $actor'); 567 + } 568 + 569 + final data = response.data; 570 + if (data is! Map<String, dynamic>) { 571 + throw FormatException('Expected Map but got ${data.runtimeType}'); 572 + } 573 + return UserProfile.fromJson(data); 574 + } on DioException catch (e) { 575 + _handleDioException(e, 'profile'); // Never returns - always throws 576 + } on FormatException { 577 + rethrow; 578 + } on Exception catch (e) { 579 + if (kDebugMode) { 580 + debugPrint('❌ Error parsing profile response: $e'); 581 + } 582 + throw ApiException('Failed to parse server response', originalError: e); 583 + } 584 + } 585 + 586 + /// Get posts by a specific actor 587 + /// 588 + /// Fetches posts created by a specific user using the dedicated 589 + /// actor posts endpoint. 590 + /// 591 + /// Parameters: 592 + /// - [actor]: User's DID or handle (required) 593 + /// - [filter]: Post filter type (optional): 594 + /// - 'posts_with_replies': Include replies 595 + /// - 'posts_no_replies': Exclude replies (default behavior) 596 + /// - 'posts_with_media': Only posts with media attachments 597 + /// - [community]: Filter to posts in a specific community (optional) 598 + /// - [limit]: Number of posts per page (default: 15, max: 50) 599 + /// - [cursor]: Pagination cursor from previous response 600 + /// 601 + /// Throws: 602 + /// - `NotFoundException` if the actor does not exist 603 + /// - `UnauthorizedException` if authentication is required/expired 604 + /// - `ApiException` for other API errors 605 + Future<TimelineResponse> getAuthorPosts({ 606 + required String actor, 607 + String? filter, 608 + String? community, 609 + int limit = 15, 610 + String? cursor, 611 + }) async { 612 + try { 613 + if (kDebugMode) { 614 + debugPrint('📡 Fetching posts for actor: $actor'); 615 + } 616 + 617 + final queryParams = <String, dynamic>{ 618 + 'actor': actor, 619 + 'limit': limit, 620 + }; 621 + 622 + if (filter != null) { 623 + queryParams['filter'] = filter; 624 + } 625 + 626 + if (community != null) { 627 + queryParams['community'] = community; 628 + } 629 + 630 + if (cursor != null) { 631 + queryParams['cursor'] = cursor; 632 + } 633 + 634 + final response = await _dio.get( 635 + '/xrpc/social.coves.actor.getPosts', 636 + queryParameters: queryParams, 637 + ); 638 + 639 + final data = response.data; 640 + if (data is! Map<String, dynamic>) { 641 + throw FormatException('Expected Map but got ${data.runtimeType}'); 642 + } 643 + 644 + if (kDebugMode) { 645 + debugPrint( 646 + '✅ Actor posts fetched: ' 647 + '${data['feed']?.length ?? 0} posts', 648 + ); 649 + } 650 + 651 + return TimelineResponse.fromJson(data); 652 + } on DioException catch (e) { 653 + _handleDioException(e, 'actor posts'); // Never returns - always throws 654 + } on FormatException { 655 + rethrow; 656 + } on Exception catch (e) { 657 + if (kDebugMode) { 658 + debugPrint('❌ Error parsing actor posts response: $e'); 659 + } 660 + throw ApiException('Failed to parse server response', originalError: e); 661 + } 662 + } 663 + 548 664 /// Handle Dio exceptions with specific error types 549 665 /// 550 666 /// Converts generic DioException into specific typed exceptions ··· 561 677 // Handle specific HTTP status codes 562 678 if (e.response != null) { 563 679 final statusCode = e.response!.statusCode; 564 - final message = 565 - e.response!.data?['error'] ?? e.response!.data?['message']; 680 + // Handle both JSON error responses and plain text responses 681 + String? message; 682 + final data = e.response!.data; 683 + if (data is Map<String, dynamic>) { 684 + message = data['error'] as String? ?? data['message'] as String?; 685 + } else if (data is String && data.isNotEmpty) { 686 + message = data; 687 + } 566 688 567 689 if (statusCode != null) { 568 690 if (statusCode == 401) {
+25
lib/utils/date_time_utils.dart
··· 81 81 82 82 return '$hour12:$minute$period · $month $day, $year'; 83 83 } 84 + 85 + /// Format datetime as "Joined Month Year" string 86 + /// 87 + /// Example: "Joined January 2025" 88 + /// 89 + /// [dateTime] is the account creation date 90 + static String formatJoinedDate(DateTime dateTime) { 91 + const months = [ 92 + 'January', 93 + 'February', 94 + 'March', 95 + 'April', 96 + 'May', 97 + 'June', 98 + 'July', 99 + 'August', 100 + 'September', 101 + 'October', 102 + 'November', 103 + 'December', 104 + ]; 105 + assert(dateTime.month >= 1 && dateTime.month <= 12, 'Invalid month'); 106 + final month = months[dateTime.month - 1]; 107 + return 'Joined $month ${dateTime.year}'; 108 + } 84 109 }
+21 -12
lib/widgets/comment_card.dart
··· 13 13 import '../utils/date_time_utils.dart'; 14 14 import 'icons/animated_heart_icon.dart'; 15 15 import 'sign_in_dialog.dart'; 16 + import 'tappable_author.dart'; 16 17 17 18 /// Comment card widget for displaying individual comments 18 19 /// ··· 123 124 // Author info row 124 125 Row( 125 126 children: [ 126 - // Author avatar 127 - _buildAuthorAvatar(comment.author), 128 - const SizedBox(width: 8), 129 - Expanded( 130 - child: Text( 131 - '@${comment.author.handle}', 132 - style: TextStyle( 133 - color: AppColors.textPrimary.withValues( 134 - alpha: isCollapsed ? 0.7 : 0.5, 127 + // Author avatar and handle (tappable for profile) 128 + TappableAuthor( 129 + authorDid: comment.author.did, 130 + child: Row( 131 + mainAxisSize: MainAxisSize.min, 132 + children: [ 133 + // Author avatar 134 + _buildAuthorAvatar(comment.author), 135 + const SizedBox(width: 8), 136 + Text( 137 + '@${comment.author.handle}', 138 + style: TextStyle( 139 + color: AppColors.textPrimary.withValues( 140 + alpha: isCollapsed ? 0.7 : 0.5, 141 + ), 142 + fontSize: 13, 143 + fontWeight: FontWeight.w500, 144 + ), 135 145 ), 136 - fontSize: 13, 137 - fontWeight: FontWeight.w500, 138 - ), 146 + ], 139 147 ), 140 148 ), 149 + const Spacer(), 141 150 // Show collapsed count OR time ago 142 151 if (isCollapsed && collapsedCount > 0) 143 152 _buildCollapsedBadge()
+51 -35
lib/widgets/post_card.dart
··· 14 14 import 'fullscreen_video_player.dart'; 15 15 import 'post_card_actions.dart'; 16 16 import 'source_link_bar.dart'; 17 + import 'tappable_author.dart'; 17 18 18 19 /// Post card widget for displaying feed posts 19 20 /// ··· 100 101 children: [ 101 102 // Community handle with styled parts 102 103 _buildCommunityHandle(post.post.community), 103 - // Author handle 104 - Text( 105 - '@${post.post.author.handle}', 106 - style: const TextStyle( 107 - color: AppColors.textSecondary, 108 - fontSize: 12, 104 + // Author handle (tappable for profile navigation) 105 + TappableAuthor( 106 + authorDid: post.post.author.did, 107 + padding: const EdgeInsets.symmetric(vertical: 2), 108 + child: Text( 109 + '@${post.post.author.handle}', 110 + style: const TextStyle( 111 + color: AppColors.textSecondary, 112 + fontSize: 12, 113 + ), 109 114 ), 110 115 ), 111 116 ], ··· 133 138 crossAxisAlignment: CrossAxisAlignment.start, 134 139 children: [ 135 140 // Author info (shown in detail view, above title) 136 - if (showAuthorFooter) _buildAuthorFooter(), 141 + if (showAuthorFooter) _buildAuthorFooter(context), 137 142 138 143 // Title and text wrapped in InkWell for navigation 139 144 if (!disableNavigation && ··· 298 303 299 304 /// Builds the community handle with styled parts (name + instance) 300 305 Widget _buildCommunityHandle(CommunityRef community) { 301 - final displayHandle = 302 - CommunityHandleUtils.formatHandleForDisplay(community.handle); 306 + final displayHandle = CommunityHandleUtils.formatHandleForDisplay( 307 + community.handle, 308 + ); 303 309 304 310 // Fallback to raw handle or name if formatting fails 305 311 if (displayHandle == null || !displayHandle.contains('@')) { ··· 381 387 } 382 388 383 389 /// Builds author footer with avatar, handle, and timestamp 384 - Widget _buildAuthorFooter() { 390 + Widget _buildAuthorFooter(BuildContext context) { 385 391 final author = post.post.author; 386 392 387 393 return Padding( 388 394 padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), 389 395 child: Row( 390 396 children: [ 391 - // Author avatar (circular, small) 392 - if (author.avatar != null && author.avatar!.isNotEmpty) 393 - ClipRRect( 394 - borderRadius: BorderRadius.circular(10), 395 - child: CachedNetworkImage( 396 - imageUrl: author.avatar!, 397 - width: 20, 398 - height: 20, 399 - fit: BoxFit.cover, 400 - placeholder: 401 - (context, url) => _buildAuthorFallbackAvatar(author), 402 - errorWidget: 403 - (context, url, error) => _buildAuthorFallbackAvatar(author), 404 - ), 405 - ) 406 - else 407 - _buildAuthorFallbackAvatar(author), 408 - const SizedBox(width: 8), 397 + // Author avatar and handle (tappable for profile navigation) 398 + TappableAuthor( 399 + authorDid: author.did, 400 + child: Row( 401 + mainAxisSize: MainAxisSize.min, 402 + children: [ 403 + // Author avatar (circular, small) 404 + if (author.avatar != null && author.avatar!.isNotEmpty) 405 + ClipRRect( 406 + borderRadius: BorderRadius.circular(10), 407 + child: CachedNetworkImage( 408 + imageUrl: author.avatar!, 409 + width: 20, 410 + height: 20, 411 + fit: BoxFit.cover, 412 + placeholder: 413 + (context, url) => _buildAuthorFallbackAvatar(author), 414 + errorWidget: 415 + (context, url, error) => 416 + _buildAuthorFallbackAvatar(author), 417 + ), 418 + ) 419 + else 420 + _buildAuthorFallbackAvatar(author), 421 + const SizedBox(width: 8), 409 422 410 - // Author handle 411 - Text( 412 - '@${author.handle}', 413 - style: const TextStyle( 414 - color: AppColors.textPrimary, 415 - fontSize: 13, 423 + // Author handle 424 + Text( 425 + '@${author.handle}', 426 + style: const TextStyle( 427 + color: AppColors.textPrimary, 428 + fontSize: 13, 429 + ), 430 + overflow: TextOverflow.ellipsis, 431 + ), 432 + ], 416 433 ), 417 - overflow: TextOverflow.ellipsis, 418 434 ), 419 435 420 436 const SizedBox(width: 8),
+384
lib/widgets/profile_header.dart
··· 1 + import 'package:cached_network_image/cached_network_image.dart'; 2 + import 'package:flutter/material.dart'; 3 + 4 + import '../constants/app_colors.dart'; 5 + import '../models/user_profile.dart'; 6 + import '../utils/date_time_utils.dart'; 7 + 8 + /// Profile header widget displaying banner, avatar, and user info 9 + /// 10 + /// Layout matches Bluesky profile design: 11 + /// - Full-width banner image (~150px height) 12 + /// - Circular avatar (80px) overlapping banner at bottom-left 13 + /// - Display name, handle, and bio below 14 + /// - Stats row showing post/comment/community counts 15 + class ProfileHeader extends StatelessWidget { 16 + const ProfileHeader({ 17 + required this.profile, 18 + required this.isOwnProfile, 19 + this.onEditPressed, 20 + this.onMenuPressed, 21 + this.onSharePressed, 22 + super.key, 23 + }); 24 + 25 + final UserProfile? profile; 26 + final bool isOwnProfile; 27 + final VoidCallback? onEditPressed; 28 + final VoidCallback? onMenuPressed; 29 + final VoidCallback? onSharePressed; 30 + 31 + static const double bannerHeight = 150; 32 + 33 + @override 34 + Widget build(BuildContext context) { 35 + // Stack-based layout with banner image behind profile content 36 + return Stack( 37 + children: [ 38 + // Banner image (or gradient fallback) 39 + _buildBannerImage(), 40 + // Gradient overlay for text readability 41 + Positioned.fill( 42 + child: Container( 43 + decoration: BoxDecoration( 44 + gradient: LinearGradient( 45 + begin: Alignment.topCenter, 46 + end: Alignment.bottomCenter, 47 + colors: [ 48 + Colors.transparent, 49 + AppColors.background.withValues(alpha: 0.3), 50 + AppColors.background, 51 + ], 52 + stops: const [0.0, 0.5, 1.0], 53 + ), 54 + ), 55 + ), 56 + ), 57 + // Profile content - UnconstrainedBox allows content to be natural size 58 + // and clips overflow when SliverAppBar collapses 59 + SafeArea( 60 + bottom: false, 61 + child: Padding( 62 + padding: const EdgeInsets.only(top: kToolbarHeight), 63 + child: UnconstrainedBox( 64 + clipBehavior: Clip.hardEdge, 65 + alignment: Alignment.topLeft, 66 + constrainedAxis: Axis.horizontal, 67 + child: Column( 68 + crossAxisAlignment: CrossAxisAlignment.start, 69 + mainAxisSize: MainAxisSize.min, 70 + children: [ 71 + // Avatar and name row (side by side) 72 + _buildAvatarAndNameRow(), 73 + // Bio 74 + if (profile?.bio != null && profile!.bio!.isNotEmpty) ...[ 75 + Padding( 76 + padding: const EdgeInsets.symmetric(horizontal: 16), 77 + child: Text( 78 + profile!.bio!, 79 + style: const TextStyle( 80 + fontSize: 14, 81 + color: AppColors.textPrimary, 82 + height: 1.4, 83 + ), 84 + maxLines: 2, 85 + overflow: TextOverflow.ellipsis, 86 + ), 87 + ), 88 + ], 89 + // Stats row 90 + const SizedBox(height: 12), 91 + Padding( 92 + padding: const EdgeInsets.symmetric(horizontal: 16), 93 + child: _buildStatsRow(), 94 + ), 95 + // Member since date 96 + if (profile?.createdAt != null) ...[ 97 + const SizedBox(height: 8), 98 + Padding( 99 + padding: const EdgeInsets.symmetric(horizontal: 16), 100 + child: Row( 101 + children: [ 102 + const Icon( 103 + Icons.calendar_today_outlined, 104 + size: 14, 105 + color: AppColors.textSecondary, 106 + ), 107 + const SizedBox(width: 6), 108 + Text( 109 + DateTimeUtils.formatJoinedDate(profile!.createdAt!), 110 + style: const TextStyle( 111 + fontSize: 13, 112 + color: AppColors.textSecondary, 113 + ), 114 + ), 115 + ], 116 + ), 117 + ), 118 + ], 119 + ], 120 + ), 121 + ), 122 + ), 123 + ), 124 + ], 125 + ); 126 + } 127 + 128 + Widget _buildBannerImage() { 129 + if (profile?.banner != null && profile!.banner!.isNotEmpty) { 130 + return SizedBox( 131 + height: bannerHeight, 132 + width: double.infinity, 133 + child: CachedNetworkImage( 134 + imageUrl: profile!.banner!, 135 + fit: BoxFit.cover, 136 + placeholder: (context, url) => _buildDefaultBanner(), 137 + errorWidget: (context, url, error) => _buildDefaultBanner(), 138 + ), 139 + ); 140 + } 141 + return _buildDefaultBanner(); 142 + } 143 + 144 + Widget _buildDefaultBanner() { 145 + // TODO: Replace with Image.asset('assets/images/default_banner.png') 146 + // when the user provides the default banner asset 147 + return Container( 148 + height: bannerHeight, 149 + width: double.infinity, 150 + decoration: BoxDecoration( 151 + gradient: LinearGradient( 152 + begin: Alignment.topLeft, 153 + end: Alignment.bottomRight, 154 + colors: [ 155 + AppColors.primary.withValues(alpha: 0.6), 156 + AppColors.primary.withValues(alpha: 0.3), 157 + ], 158 + ), 159 + ), 160 + ); 161 + } 162 + 163 + Widget _buildAvatarAndNameRow() { 164 + const avatarSize = 80.0; 165 + 166 + return Padding( 167 + padding: const EdgeInsets.symmetric(horizontal: 16), 168 + child: Row( 169 + crossAxisAlignment: CrossAxisAlignment.start, 170 + children: [ 171 + // Avatar with drop shadow 172 + Container( 173 + width: avatarSize, 174 + height: avatarSize, 175 + decoration: BoxDecoration( 176 + shape: BoxShape.circle, 177 + border: Border.all( 178 + color: AppColors.background, 179 + width: 3, 180 + ), 181 + boxShadow: [ 182 + BoxShadow( 183 + color: Colors.black.withValues(alpha: 0.3), 184 + blurRadius: 8, 185 + offset: const Offset(0, 2), 186 + spreadRadius: 1, 187 + ), 188 + ], 189 + ), 190 + child: ClipOval( 191 + child: _buildAvatar(avatarSize - 6), 192 + ), 193 + ), 194 + const SizedBox(width: 12), 195 + // Handle and DID column 196 + Expanded( 197 + child: Column( 198 + crossAxisAlignment: CrossAxisAlignment.start, 199 + children: [ 200 + const SizedBox(height: 8), 201 + // Handle 202 + Text( 203 + profile?.handle != null 204 + ? '@${profile!.handle}' 205 + : 'Loading...', 206 + style: const TextStyle( 207 + fontSize: 20, 208 + fontWeight: FontWeight.bold, 209 + color: AppColors.textPrimary, 210 + ), 211 + maxLines: 1, 212 + overflow: TextOverflow.ellipsis, 213 + ), 214 + // DID with icon 215 + if (profile?.did != null) ...[ 216 + const SizedBox(height: 4), 217 + Row( 218 + children: [ 219 + const Icon( 220 + Icons.qr_code_2, 221 + size: 14, 222 + color: AppColors.textSecondary, 223 + ), 224 + const SizedBox(width: 4), 225 + Text( 226 + profile!.did, 227 + style: const TextStyle( 228 + fontSize: 12, 229 + color: AppColors.textSecondary, 230 + fontFamily: 'monospace', 231 + ), 232 + ), 233 + ], 234 + ), 235 + ], 236 + ], 237 + ), 238 + ), 239 + // Edit button for own profile 240 + if (isOwnProfile && onEditPressed != null) 241 + _ActionButton( 242 + icon: Icons.edit_outlined, 243 + onPressed: onEditPressed!, 244 + tooltip: 'Edit Profile', 245 + ), 246 + ], 247 + ), 248 + ); 249 + } 250 + 251 + Widget _buildAvatar(double size) { 252 + if (profile?.avatar != null) { 253 + return CachedNetworkImage( 254 + imageUrl: profile!.avatar!, 255 + width: size, 256 + height: size, 257 + fit: BoxFit.cover, 258 + placeholder: (context, url) => _buildAvatarLoading(size), 259 + errorWidget: (context, url, error) => _buildFallbackAvatar(size), 260 + ); 261 + } 262 + return _buildFallbackAvatar(size); 263 + } 264 + 265 + Widget _buildAvatarLoading(double size) { 266 + return Container( 267 + width: size, 268 + height: size, 269 + color: AppColors.backgroundSecondary, 270 + child: const Center( 271 + child: SizedBox( 272 + width: 24, 273 + height: 24, 274 + child: CircularProgressIndicator( 275 + strokeWidth: 2, 276 + color: AppColors.primary, 277 + ), 278 + ), 279 + ), 280 + ); 281 + } 282 + 283 + Widget _buildFallbackAvatar(double size) { 284 + return Container( 285 + width: size, 286 + height: size, 287 + color: AppColors.primary, 288 + child: Icon(Icons.person, size: size * 0.5, color: Colors.white), 289 + ); 290 + } 291 + 292 + Widget _buildStatsRow() { 293 + final stats = profile?.stats; 294 + 295 + return Wrap( 296 + spacing: 16, 297 + runSpacing: 8, 298 + children: [ 299 + _StatItem(label: 'Posts', value: stats?.postCount ?? 0), 300 + _StatItem(label: 'Comments', value: stats?.commentCount ?? 0), 301 + _StatItem(label: 'Memberships', value: stats?.membershipCount ?? 0), 302 + ], 303 + ); 304 + } 305 + } 306 + 307 + /// Small action button for profile actions 308 + class _ActionButton extends StatelessWidget { 309 + const _ActionButton({ 310 + required this.icon, 311 + required this.onPressed, 312 + this.tooltip, 313 + }); 314 + 315 + final IconData icon; 316 + final VoidCallback onPressed; 317 + final String? tooltip; 318 + 319 + @override 320 + Widget build(BuildContext context) { 321 + return Tooltip( 322 + message: tooltip ?? '', 323 + child: Material( 324 + color: AppColors.backgroundSecondary, 325 + borderRadius: BorderRadius.circular(8), 326 + child: InkWell( 327 + onTap: onPressed, 328 + borderRadius: BorderRadius.circular(8), 329 + child: Padding( 330 + padding: const EdgeInsets.all(8), 331 + child: Icon(icon, size: 20, color: AppColors.textSecondary), 332 + ), 333 + ), 334 + ), 335 + ); 336 + } 337 + } 338 + 339 + /// Stats item showing label and value 340 + class _StatItem extends StatelessWidget { 341 + const _StatItem({ 342 + required this.label, 343 + required this.value, 344 + }); 345 + 346 + final String label; 347 + final int value; 348 + 349 + @override 350 + Widget build(BuildContext context) { 351 + final valueText = _formatNumber(value); 352 + 353 + return RichText( 354 + text: TextSpan( 355 + children: [ 356 + TextSpan( 357 + text: valueText, 358 + style: const TextStyle( 359 + fontSize: 14, 360 + fontWeight: FontWeight.bold, 361 + color: AppColors.textPrimary, 362 + ), 363 + ), 364 + TextSpan( 365 + text: ' $label', 366 + style: const TextStyle( 367 + fontSize: 14, 368 + color: AppColors.textSecondary, 369 + ), 370 + ), 371 + ], 372 + ), 373 + ); 374 + } 375 + 376 + String _formatNumber(int value) { 377 + if (value >= 1000000) { 378 + return '${(value / 1000000).toStringAsFixed(1)}M'; 379 + } else if (value >= 1000) { 380 + return '${(value / 1000).toStringAsFixed(1)}K'; 381 + } 382 + return value.toString(); 383 + } 384 + }
+51
lib/widgets/tappable_author.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:go_router/go_router.dart'; 3 + 4 + /// Wraps a child widget to make it navigate to an author's profile on tap. 5 + /// 6 + /// This widget encapsulates the common pattern of tapping an author's avatar 7 + /// or name to navigate to their profile page. It handles the InkWell styling 8 + /// and navigation logic. 9 + /// 10 + /// Example: 11 + /// ```dart 12 + /// TappableAuthor( 13 + /// authorDid: post.author.did, 14 + /// child: Row( 15 + /// children: [ 16 + /// AuthorAvatar(author: post.author), 17 + /// Text('@${post.author.handle}'), 18 + /// ], 19 + /// ), 20 + /// ) 21 + /// ``` 22 + class TappableAuthor extends StatelessWidget { 23 + const TappableAuthor({ 24 + required this.authorDid, 25 + required this.child, 26 + this.borderRadius = 4.0, 27 + this.padding = const EdgeInsets.symmetric(horizontal: 4, vertical: 2), 28 + super.key, 29 + }); 30 + 31 + /// The DID of the author to navigate to 32 + final String authorDid; 33 + 34 + /// The child widget to wrap (typically avatar + handle row) 35 + final Widget child; 36 + 37 + /// Border radius for the InkWell splash effect 38 + final double borderRadius; 39 + 40 + /// Padding around the child 41 + final EdgeInsetsGeometry padding; 42 + 43 + @override 44 + Widget build(BuildContext context) { 45 + return InkWell( 46 + onTap: () => context.push('/profile/$authorDid'), 47 + borderRadius: BorderRadius.circular(borderRadius), 48 + child: Padding(padding: padding, child: child), 49 + ); 50 + } 51 + }
+1
pubspec.yaml
··· 94 94 - assets/logo/lil_dude.svg 95 95 - assets/icons/ 96 96 - assets/icons/atproto/ 97 + - assets/images/ 97 98 98 99 # An image asset can refer to one or more resolution-specific "variants", see 99 100 # https://flutter.dev/to/resolution-aware-images