diff --git a/lib/main.dart b/lib/main.dart index ca07bda..870fe3f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -9,10 +9,12 @@ import 'constants/app_colors.dart'; import 'models/post.dart'; import 'providers/auth_provider.dart'; import 'providers/multi_feed_provider.dart'; +import 'providers/user_profile_provider.dart'; import 'providers/vote_provider.dart'; import 'screens/auth/login_screen.dart'; import 'screens/home/main_shell_screen.dart'; import 'screens/home/post_detail_screen.dart'; +import 'screens/home/profile_screen.dart'; import 'screens/landing_screen.dart'; import 'services/comment_service.dart'; import 'services/comments_provider_cache.dart'; @@ -101,6 +103,15 @@ void main() async { ), // StreamableService for video embeds Provider(create: (_) => StreamableService()), + // UserProfileProvider for profile pages + ChangeNotifierProxyProvider( + create: (context) => UserProfileProvider(authProvider), + update: (context, auth, previous) { + // Propagate auth changes to existing provider + previous?.updateAuthProvider(auth); + return previous ?? UserProfileProvider(auth); + }, + ), ], child: const CovesApp(), ), @@ -140,6 +151,13 @@ GoRouter _createRouter(AuthProvider authProvider) { path: '/feed', builder: (context, state) => const MainShellScreen(), ), + GoRoute( + path: '/profile/:actor', + builder: (context, state) { + final actor = state.pathParameters['actor']!; + return ProfileScreen(actor: actor); + }, + ), GoRoute( path: '/post/:postUri', builder: (context, state) { diff --git a/lib/models/user_profile.dart b/lib/models/user_profile.dart new file mode 100644 index 0000000..b2ae8ab --- /dev/null +++ b/lib/models/user_profile.dart @@ -0,0 +1,322 @@ +// User profile data models for Coves +// +// These models match the backend response structure from: +// /xrpc/social.coves.actor.getprofile + +/// User profile with display information and stats +class UserProfile { + /// Creates a UserProfile with validation. + /// + /// Throws [ArgumentError] if [did] doesn't start with 'did:'. + factory UserProfile({ + required String did, + String? handle, + String? displayName, + String? bio, + String? avatar, + String? banner, + DateTime? createdAt, + ProfileStats? stats, + ProfileViewerState? viewer, + }) { + if (!did.startsWith('did:')) { + throw ArgumentError.value(did, 'did', 'Must start with "did:" prefix'); + } + return UserProfile._( + did: did, + handle: handle, + displayName: displayName, + bio: bio, + avatar: avatar, + banner: banner, + createdAt: createdAt, + stats: stats, + viewer: viewer, + ); + } + + /// Private constructor - validation happens in factory + const UserProfile._({ + required this.did, + this.handle, + this.displayName, + this.bio, + this.avatar, + this.banner, + this.createdAt, + this.stats, + this.viewer, + }); + + factory UserProfile.fromJson(Map json) { + final did = json['did'] as String?; + if (did == null || !did.startsWith('did:')) { + throw FormatException('Invalid or missing DID in profile: $did'); + } + + // Handle can be at top level or nested inside 'profile' object + // (backend returns nested structure) + final profileData = json['profile'] as Map?; + final handle = + json['handle'] as String? ?? profileData?['handle'] as String?; + final createdAtStr = + json['createdAt'] as String? ?? profileData?['createdAt'] as String?; + + return UserProfile._( + did: did, + handle: handle, + displayName: json['displayName'] as String?, + bio: json['bio'] as String?, + avatar: json['avatar'] as String?, + banner: json['banner'] as String?, + createdAt: createdAtStr != null ? DateTime.tryParse(createdAtStr) : null, + stats: + json['stats'] != null + ? ProfileStats.fromJson(json['stats'] as Map) + : null, + viewer: + json['viewer'] != null + ? ProfileViewerState.fromJson( + json['viewer'] as Map, + ) + : null, + ); + } + + final String did; + final String? handle; + final String? displayName; + final String? bio; + final String? avatar; + final String? banner; + final DateTime? createdAt; + final ProfileStats? stats; + final ProfileViewerState? viewer; + + /// Returns display name if available, otherwise handle, otherwise DID + String get displayNameOrHandle => displayName ?? handle ?? did; + + /// Returns handle with @ prefix if available + String? get formattedHandle => handle != null ? '@$handle' : null; + + /// Creates a copy with the given fields replaced. + /// + /// Note: [did] cannot be changed to an invalid value - validation still + /// applies via the factory constructor. + UserProfile copyWith({ + String? did, + String? handle, + String? displayName, + String? bio, + String? avatar, + String? banner, + DateTime? createdAt, + ProfileStats? stats, + ProfileViewerState? viewer, + }) { + return UserProfile( + did: did ?? this.did, + handle: handle ?? this.handle, + displayName: displayName ?? this.displayName, + bio: bio ?? this.bio, + avatar: avatar ?? this.avatar, + banner: banner ?? this.banner, + createdAt: createdAt ?? this.createdAt, + stats: stats ?? this.stats, + viewer: viewer ?? this.viewer, + ); + } + + Map toJson() => { + 'did': did, + if (handle != null) 'handle': handle, + if (displayName != null) 'displayName': displayName, + if (bio != null) 'bio': bio, + if (avatar != null) 'avatar': avatar, + if (banner != null) 'banner': banner, + if (createdAt != null) 'createdAt': createdAt!.toIso8601String(), + if (stats != null) 'stats': stats!.toJson(), + if (viewer != null) 'viewer': viewer!.toJson(), + }; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is UserProfile && + runtimeType == other.runtimeType && + did == other.did && + handle == other.handle && + displayName == other.displayName && + bio == other.bio && + avatar == other.avatar && + banner == other.banner && + createdAt == other.createdAt && + stats == other.stats && + viewer == other.viewer; + + @override + int get hashCode => Object.hash( + did, + handle, + displayName, + bio, + avatar, + banner, + createdAt, + stats, + viewer, + ); +} + +/// User profile statistics +/// +/// Contains counts for posts, comments, communities, and reputation. +/// All count fields are guaranteed to be non-negative. +class ProfileStats { + /// Creates ProfileStats with non-negative count validation. + const ProfileStats({ + this.postCount = 0, + this.commentCount = 0, + this.communityCount = 0, + this.reputation, + this.membershipCount = 0, + }); + + factory ProfileStats.fromJson(Map json) { + // Clamp values to ensure non-negative (defensive parsing) + const maxInt = 0x7FFFFFFF; // Max 32-bit signed int + return ProfileStats( + postCount: (json['postCount'] as int? ?? 0).clamp(0, maxInt), + commentCount: (json['commentCount'] as int? ?? 0).clamp(0, maxInt), + communityCount: (json['communityCount'] as int? ?? 0).clamp(0, maxInt), + reputation: json['reputation'] as int?, + membershipCount: (json['membershipCount'] as int? ?? 0).clamp(0, maxInt), + ); + } + + final int postCount; + final int commentCount; + final int communityCount; + final int? reputation; + final int membershipCount; + + ProfileStats copyWith({ + int? postCount, + int? commentCount, + int? communityCount, + int? reputation, + int? membershipCount, + }) { + return ProfileStats( + postCount: postCount ?? this.postCount, + commentCount: commentCount ?? this.commentCount, + communityCount: communityCount ?? this.communityCount, + reputation: reputation ?? this.reputation, + membershipCount: membershipCount ?? this.membershipCount, + ); + } + + Map toJson() => { + 'postCount': postCount, + 'commentCount': commentCount, + 'communityCount': communityCount, + if (reputation != null) 'reputation': reputation, + 'membershipCount': membershipCount, + }; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ProfileStats && + runtimeType == other.runtimeType && + postCount == other.postCount && + commentCount == other.commentCount && + communityCount == other.communityCount && + reputation == other.reputation && + membershipCount == other.membershipCount; + + @override + int get hashCode => Object.hash( + postCount, + commentCount, + communityCount, + reputation, + membershipCount, + ); +} + +/// Viewer-specific state for a profile (block status) +/// +/// Represents the relationship between the viewer and the profile owner. +/// Invariant: if [blocked] is true, [blockUri] must be non-null. +class ProfileViewerState { + /// Creates ProfileViewerState. + /// + /// Note: The factory enforces that blocked requires blockUri. + factory ProfileViewerState({ + bool blocked = false, + bool blockedBy = false, + String? blockUri, + }) { + // Enforce invariant: if blocked, must have blockUri + // Defensive: treat as not blocked if no URI + final effectiveBlocked = blocked && blockUri != null; + return ProfileViewerState._( + blocked: effectiveBlocked, + blockedBy: blockedBy, + blockUri: blockUri, + ); + } + + const ProfileViewerState._({ + required this.blocked, + required this.blockedBy, + this.blockUri, + }); + + factory ProfileViewerState.fromJson(Map json) { + final blocked = json['blocked'] as bool? ?? false; + final blockUri = json['blockUri'] as String?; + + return ProfileViewerState._( + // If blocked but no blockUri, treat as not blocked (defensive) + blocked: blocked && blockUri != null, + blockedBy: json['blockedBy'] as bool? ?? false, + blockUri: blockUri, + ); + } + + final bool blocked; + final bool blockedBy; + final String? blockUri; + + ProfileViewerState copyWith({ + bool? blocked, + bool? blockedBy, + String? blockUri, + }) { + return ProfileViewerState( + blocked: blocked ?? this.blocked, + blockedBy: blockedBy ?? this.blockedBy, + blockUri: blockUri ?? this.blockUri, + ); + } + + Map toJson() => { + 'blocked': blocked, + 'blockedBy': blockedBy, + if (blockUri != null) 'blockUri': blockUri, + }; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ProfileViewerState && + runtimeType == other.runtimeType && + blocked == other.blocked && + blockedBy == other.blockedBy && + blockUri == other.blockUri; + + @override + int get hashCode => Object.hash(blocked, blockedBy, blockUri); +} diff --git a/lib/providers/user_profile_provider.dart b/lib/providers/user_profile_provider.dart new file mode 100644 index 0000000..4db8cf7 --- /dev/null +++ b/lib/providers/user_profile_provider.dart @@ -0,0 +1,392 @@ +import 'package:flutter/foundation.dart'; + +import '../models/feed_state.dart'; +import '../models/post.dart'; +import '../models/user_profile.dart'; +import '../services/api_exceptions.dart'; +import '../services/coves_api_service.dart'; +import 'auth_provider.dart'; + +/// User Profile Provider +/// +/// Manages state for user profile pages including profile data and +/// author posts feed. Supports viewing both own profile and other users. +/// +/// IMPORTANT: Accepts AuthProvider reference to fetch fresh access +/// tokens before each authenticated request (critical for atProto OAuth +/// token rotation). +class UserProfileProvider with ChangeNotifier { + UserProfileProvider(AuthProvider authProvider, {CovesApiService? apiService}) + : _authProvider = authProvider { + _apiService = + apiService ?? + CovesApiService( + tokenGetter: _authProvider.getAccessToken, + tokenRefresher: _authProvider.refreshToken, + signOutHandler: _authProvider.signOut, + ); + + // Listen to auth state changes + _authProvider.addListener(_onAuthChanged); + } + + AuthProvider _authProvider; + + /// Update auth provider reference (called by ChangeNotifierProxyProvider) + /// + /// This ensures token refresh and sign-out handlers stay in sync when + /// auth state changes propagate through the provider tree. + void updateAuthProvider(AuthProvider newAuth) { + if (_authProvider != newAuth) { + _authProvider.removeListener(_onAuthChanged); + _authProvider = newAuth; + _authProvider.addListener(_onAuthChanged); + // Recreate API service with new auth callbacks + _apiService.dispose(); + _apiService = CovesApiService( + tokenGetter: _authProvider.getAccessToken, + tokenRefresher: _authProvider.refreshToken, + signOutHandler: _authProvider.signOut, + ); + } + } + + late CovesApiService _apiService; + + // Profile state + UserProfile? _profile; + bool _isLoadingProfile = false; + String? _profileError; + String? _currentProfileDid; + + // Posts feed state (reusing FeedState pattern) + FeedState _postsState = FeedState.initial(); + + // LRU profile cache keyed by DID (max 50 entries) + static const int _maxCacheSize = 50; + final Map _profileCache = {}; + final List _cacheAccessOrder = []; + + /// Add profile to cache with LRU eviction + void _cacheProfile(UserProfile profile) { + final did = profile.did; + + // Remove from current position in access order + _cacheAccessOrder.remove(did); + + // Add to end (most recently used) + _cacheAccessOrder.add(did); + _profileCache[did] = profile; + + // Evict oldest entries if over capacity + while (_cacheAccessOrder.length > _maxCacheSize) { + final oldestDid = _cacheAccessOrder.removeAt(0); + _profileCache.remove(oldestDid); + } + } + + /// Get profile from cache (updates access order) + UserProfile? _getCachedProfile(String did) { + final profile = _profileCache[did]; + if (profile != null) { + // Update access order (move to end) + _cacheAccessOrder.remove(did); + _cacheAccessOrder.add(did); + } + return profile; + } + + // Getters + UserProfile? get profile => _profile; + bool get isLoadingProfile => _isLoadingProfile; + String? get profileError => _profileError; + String? get currentProfileDid => _currentProfileDid; + FeedState get postsState => _postsState; + + /// Check if currently viewing own profile + bool get isOwnProfile { + if (_currentProfileDid == null) return false; + return _currentProfileDid == _authProvider.did; + } + + /// Handle auth state changes + void _onAuthChanged() { + // Clear profile cache on sign-out to prevent stale data + if (!_authProvider.isAuthenticated) { + if (kDebugMode) { + debugPrint('๐Ÿ”’ User signed out - clearing profile cache'); + } + _profileCache.clear(); + _cacheAccessOrder.clear(); + _profile = null; + _postsState = FeedState.initial(); + _currentProfileDid = null; + notifyListeners(); + } + } + + /// Load profile for a user + /// + /// Parameters: + /// - [actor]: User's DID or handle (required) + /// - [forceRefresh]: Bypass cache and fetch fresh data + Future loadProfile(String actor, {bool forceRefresh = false}) async { + // Check cache first (updates LRU access order) + final cachedProfile = _getCachedProfile(actor); + if (cachedProfile != null && !forceRefresh) { + _profile = cachedProfile; + _currentProfileDid = cachedProfile.did; + _profileError = null; + notifyListeners(); + return; + } + + if (_isLoadingProfile) return; + + _isLoadingProfile = true; + _profileError = null; + _currentProfileDid = actor.startsWith('did:') ? actor : null; + notifyListeners(); + + try { + final profile = await _apiService.getProfile(actor: actor); + + // Cache by DID with LRU eviction + _cacheProfile(profile); + + _profile = profile; + _currentProfileDid = profile.did; + _isLoadingProfile = false; + _profileError = null; + + if (kDebugMode) { + debugPrint('โœ… Profile loaded: ${profile.displayNameOrHandle}'); + } + } on NotFoundException { + _isLoadingProfile = false; + _profileError = 'User not found'; + _profile = null; + + if (kDebugMode) { + debugPrint('โŒ Profile not found: $actor'); + } + } on AuthenticationException { + _isLoadingProfile = false; + _profileError = 'Please sign in to view this profile'; + + if (kDebugMode) { + debugPrint('โŒ Auth required to load profile: $actor'); + } + } on NetworkException catch (e) { + _isLoadingProfile = false; + _profileError = 'Network error. Check your connection.'; + + if (kDebugMode) { + debugPrint('โŒ Network error loading profile: ${e.message}'); + } + } on ApiException catch (e) { + _isLoadingProfile = false; + _profileError = e.message; + + if (kDebugMode) { + debugPrint('โŒ Failed to load profile: ${e.message}'); + } + } on FormatException catch (e) { + _isLoadingProfile = false; + _profileError = 'Invalid data received from server'; + + if (kDebugMode) { + debugPrint('โŒ Format error loading profile: $e'); + } + } on Exception catch (e) { + // Catch-all for other exceptions + _isLoadingProfile = false; + _profileError = 'Failed to load profile. Please try again.'; + + if (kDebugMode) { + debugPrint('โŒ Unexpected error loading profile: $e'); + } + } + + notifyListeners(); + } + + /// Load posts by the current profile's author + /// + /// Parameters: + /// - [refresh]: Reload from beginning instead of paginating + Future loadPosts({bool refresh = false}) async { + if (_currentProfileDid == null) { + // Set error state instead of silently returning + _postsState = _postsState.copyWith( + error: 'No profile loaded', + isLoading: false, + isLoadingMore: false, + ); + notifyListeners(); + return; + } + if (_postsState.isLoading || _postsState.isLoadingMore) return; + + final currentState = _postsState; + + try { + if (refresh) { + _postsState = currentState.copyWith(isLoading: true, error: null); + } else { + if (!currentState.hasMore) return; + _postsState = currentState.copyWith(isLoadingMore: true); + } + notifyListeners(); + + final response = await _apiService.getAuthorPosts( + actor: _currentProfileDid!, + cursor: refresh ? null : currentState.cursor, + ); + + final List newPosts; + if (refresh) { + newPosts = response.feed; + } else { + newPosts = [...currentState.posts, ...response.feed]; + } + + _postsState = currentState.copyWith( + posts: newPosts, + cursor: response.cursor, + hasMore: response.cursor != null, + error: null, + isLoading: false, + isLoadingMore: false, + lastRefreshTime: + refresh ? DateTime.now() : currentState.lastRefreshTime, + ); + + if (kDebugMode) { + debugPrint('โœ… Author posts loaded: ${newPosts.length} posts total'); + } + } on AuthenticationException { + _postsState = currentState.copyWith( + error: 'Please sign in to view posts', + isLoading: false, + isLoadingMore: false, + ); + + if (kDebugMode) { + debugPrint('โŒ Auth required to load posts'); + } + } on NotFoundException { + // Author posts endpoint not implemented yet - show empty state + _postsState = currentState.copyWith( + posts: [], + hasMore: false, + error: null, + isLoading: false, + isLoadingMore: false, + ); + + if (kDebugMode) { + debugPrint('โš ๏ธ Author posts endpoint not available'); + } + } on NetworkException catch (e) { + _postsState = currentState.copyWith( + error: 'Network error. Check your connection.', + isLoading: false, + isLoadingMore: false, + ); + + if (kDebugMode) { + debugPrint('โŒ Network error loading posts: ${e.message}'); + } + } on ApiException catch (e) { + _postsState = currentState.copyWith( + error: e.message, + isLoading: false, + isLoadingMore: false, + ); + + if (kDebugMode) { + debugPrint('โŒ Failed to load author posts: ${e.message}'); + } + } on FormatException catch (e) { + _postsState = currentState.copyWith( + error: 'Invalid data received from server', + isLoading: false, + isLoadingMore: false, + ); + + if (kDebugMode) { + debugPrint('โŒ Format error loading posts: $e'); + } + } on Exception catch (e) { + // Catch-all for other exceptions + _postsState = currentState.copyWith( + error: 'Failed to load posts. Please try again.', + isLoading: false, + isLoadingMore: false, + ); + + if (kDebugMode) { + debugPrint('โŒ Unexpected error loading posts: $e'); + } + } + + notifyListeners(); + } + + /// Load more posts (pagination) + Future loadMorePosts() async { + await loadPosts(refresh: false); + } + + /// Clear current profile and reset state + void clearProfile() { + _profile = null; + _currentProfileDid = null; + _postsState = FeedState.initial(); + _profileError = null; + _isLoadingProfile = false; + notifyListeners(); + } + + /// Set an error message directly (for cases like missing actor) + void setError(String message) { + _profileError = message; + _isLoadingProfile = false; + notifyListeners(); + } + + /// Retry loading profile after error + /// + /// Returns: + /// - `true` if retry was initiated (profile DID was available) + /// - `false` if no profile DID is available to retry + /// + /// Note: A return of `true` does not mean the profile loaded successfully, + /// only that the retry attempt was started. Check [profileError] after + /// the operation completes to determine if it succeeded. + Future retryProfile() async { + if (_currentProfileDid == null) { + if (kDebugMode) { + debugPrint('โš ๏ธ retryProfile called but no profile DID available'); + } + return false; + } + await loadProfile(_currentProfileDid!, forceRefresh: true); + return true; + } + + /// Retry loading posts after error + Future retryPosts() async { + _postsState = _postsState.copyWith(error: null); + notifyListeners(); + await loadPosts(refresh: true); + } + + @override + void dispose() { + _authProvider.removeListener(_onAuthChanged); + _apiService.dispose(); + super.dispose(); + } +} diff --git a/lib/screens/home/profile_screen.dart b/lib/screens/home/profile_screen.dart index d62e208..0196e42 100644 --- a/lib/screens/home/profile_screen.dart +++ b/lib/screens/home/profile_screen.dart @@ -1,24 +1,324 @@ +import 'dart:ui'; + import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; +import 'package:share_plus/share_plus.dart'; import '../../constants/app_colors.dart'; import '../../providers/auth_provider.dart'; +import '../../providers/user_profile_provider.dart'; +import '../../widgets/loading_error_states.dart'; +import '../../widgets/post_card.dart'; import '../../widgets/primary_button.dart'; +import '../../widgets/profile_header.dart'; + +/// Profile screen displaying user profile with header and posts +/// +/// Supports viewing both own profile (via bottom nav) and other users +/// (via /profile/:actor route with DID or handle parameter). +class ProfileScreen extends StatefulWidget { + const ProfileScreen({this.actor, super.key}); + + /// User DID or handle to display. If null, shows current user's profile. + final String? actor; -class ProfileScreen extends StatelessWidget { - const ProfileScreen({super.key}); + @override + State createState() => _ProfileScreenState(); +} + +class _ProfileScreenState extends State { + int _selectedTabIndex = 0; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _loadProfile(); + }); + } + + @override + void didUpdateWidget(ProfileScreen oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.actor != widget.actor) { + _loadProfile(); + } + } + + Future _loadProfile() async { + final authProvider = context.read(); + final profileProvider = context.read(); + + // Determine which profile to load + final actor = widget.actor ?? authProvider.did; + + if (actor == null) { + // No actor available - set error state instead of silently failing + profileProvider.setError('Unable to determine profile to load'); + return; + } + + await profileProvider.loadProfile(actor); + + // Check mounted after async gap (CLAUDE.md requirement) + if (!mounted) return; + + // Only load posts if profile loaded successfully (no error) + if (profileProvider.profileError == null) { + await profileProvider.loadPosts(refresh: true); + } + } + + void _showMenuSheet(BuildContext context) { + showModalBottomSheet( + context: context, + backgroundColor: AppColors.backgroundSecondary, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (sheetContext) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Handle bar + Container( + margin: const EdgeInsets.only(top: 12), + width: 40, + height: 4, + decoration: BoxDecoration( + color: AppColors.textSecondary.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 16), + // Sign out option + ListTile( + leading: Icon( + Icons.logout, + color: Colors.red.shade400, + ), + title: Text( + 'Sign Out', + style: TextStyle( + color: Colors.red.shade400, + fontSize: 16, + ), + ), + onTap: () async { + Navigator.pop(sheetContext); + await _handleSignOut(); + }, + ), + const SizedBox(height: 8), + ], + ), + ); + }, + ); + } + + void _handleShare() { + final profile = context.read().profile; + if (profile == null) return; + + final handle = profile.handle; + final profileUrl = 'https://coves.social/profile/$handle'; + final subject = 'Check out ${profile.displayNameOrHandle} on Coves'; + Share.share(profileUrl, subject: subject); + } + + Future _handleSignOut() async { + final authProvider = context.read(); + await authProvider.signOut(); + + // Check mounted after async gap + if (!mounted) return; + + // Navigate to login screen + context.go('/login'); + } @override Widget build(BuildContext context) { - final authProvider = Provider.of(context); - final isAuthenticated = authProvider.isAuthenticated; + final authProvider = context.watch(); + final profileProvider = context.watch(); + + // If no actor specified and not authenticated, show sign-in prompt + if (widget.actor == null && !authProvider.isAuthenticated) { + return _buildSignInPrompt(context); + } + + // Show loading state + if (profileProvider.isLoadingProfile && profileProvider.profile == null) { + return Scaffold( + backgroundColor: AppColors.background, + appBar: _buildAppBar(context, null), + body: const FullScreenLoading(), + ); + } + + // Show error state + if (profileProvider.profileError != null && + profileProvider.profile == null) { + return Scaffold( + backgroundColor: AppColors.background, + appBar: _buildAppBar(context, null), + body: FullScreenError( + title: 'Failed to load profile', + message: profileProvider.profileError!, + onRetry: () => profileProvider.retryProfile(), + ), + ); + } return Scaffold( - backgroundColor: const Color(0xFF0B0F14), + backgroundColor: AppColors.background, + body: RefreshIndicator( + color: AppColors.primary, + backgroundColor: AppColors.backgroundSecondary, + onRefresh: () async { + final actor = widget.actor ?? authProvider.did; + if (actor != null) { + await profileProvider.loadProfile(actor, forceRefresh: true); + await profileProvider.loadPosts(refresh: true); + } + }, + child: CustomScrollView( + slivers: [ + // Collapsing app bar with profile header and frosted glass effect + SliverAppBar( + backgroundColor: Colors.transparent, + foregroundColor: AppColors.textPrimary, + expandedHeight: 220, + pinned: true, + stretch: true, + leading: + widget.actor != null + ? IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.pop(), + ) + : null, + automaticallyImplyLeading: widget.actor != null, + actions: profileProvider.isOwnProfile + ? [ + IconButton( + icon: const Icon(Icons.share_outlined), + onPressed: _handleShare, + tooltip: 'Share Profile', + ), + IconButton( + icon: const Icon(Icons.menu), + onPressed: () => _showMenuSheet(context), + tooltip: 'Menu', + ), + ] + : null, + flexibleSpace: LayoutBuilder( + builder: (context, constraints) { + // Calculate collapse progress (0 = expanded, 1 = collapsed) + const expandedHeight = 220.0; + final collapsedHeight = kToolbarHeight + + MediaQuery.of(context).padding.top; + final currentHeight = constraints.maxHeight; + final collapseProgress = 1 - + ((currentHeight - collapsedHeight) / + (expandedHeight - collapsedHeight)) + .clamp(0.0, 1.0); + + return Stack( + fit: StackFit.expand, + children: [ + // Profile header background (parallax effect) + Positioned( + top: 0, + left: 0, + right: 0, + bottom: 0, + child: ProfileHeader( + profile: profileProvider.profile, + isOwnProfile: profileProvider.isOwnProfile, + ), + ), + // Frosted glass overlay when collapsed + if (collapseProgress > 0) + Positioned( + top: 0, + left: 0, + right: 0, + height: collapsedHeight, + child: ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: 10 * collapseProgress, + sigmaY: 10 * collapseProgress, + ), + child: Container( + color: AppColors.background + .withValues(alpha: 0.7 * collapseProgress), + ), + ), + ), + ), + ], + ); + }, + ), + ), + // Tab bar header + SliverPersistentHeader( + pinned: true, + delegate: _ProfileTabBarDelegate( + child: Container( + color: AppColors.background, + child: _ProfileTabBar( + selectedIndex: _selectedTabIndex, + onTabChanged: (index) { + setState(() { + _selectedTabIndex = index; + }); + }, + ), + ), + ), + ), + // Content based on selected tab + if (_selectedTabIndex == 0) + _buildPostsList(profileProvider) + else + _buildComingSoonPlaceholder( + _selectedTabIndex == 1 ? 'Comments' : 'Likes', + ), + ], + ), + ), + ); + } + + AppBar _buildAppBar(BuildContext context, String? title) { + return AppBar( + backgroundColor: AppColors.background, + foregroundColor: AppColors.textPrimary, + title: Text(title ?? 'Profile'), + leading: + widget.actor != null + ? IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.pop(), + ) + : null, + automaticallyImplyLeading: widget.actor != null, + ); + } + + Widget _buildSignInPrompt(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, appBar: AppBar( - backgroundColor: const Color(0xFF0B0F14), - foregroundColor: Colors.white, + backgroundColor: AppColors.background, + foregroundColor: AppColors.textPrimary, title: const Text('Profile'), automaticallyImplyLeading: false, ), @@ -30,60 +330,264 @@ class ProfileScreen extends StatelessWidget { children: [ const Icon(Icons.person, size: 64, color: AppColors.primary), const SizedBox(height: 24), - Text( - isAuthenticated ? 'Your Profile' : 'Profile', - style: const TextStyle( + const Text( + 'Profile', + style: TextStyle( fontSize: 28, - color: Colors.white, + color: AppColors.textPrimary, fontWeight: FontWeight.bold, ), ), const SizedBox(height: 16), - if (isAuthenticated && authProvider.did != null) ...[ - Text( - 'Signed in as:', - style: TextStyle( - fontSize: 14, - color: Colors.white.withValues(alpha: 0.6), - ), + const Text( + 'Sign in to view your profile', + style: TextStyle(fontSize: 16, color: AppColors.textSecondary), + textAlign: TextAlign.center, + ), + const SizedBox(height: 48), + PrimaryButton( + title: 'Sign in', + onPressed: () => context.go('/login'), + ), + ], + ), + ), + ), + ); + } + + Widget _buildPostsList(UserProfileProvider profileProvider) { + final postsState = profileProvider.postsState; + + // Loading state for posts + if (postsState.isLoading && postsState.posts.isEmpty) { + return const SliverFillRemaining( + child: Center( + child: CircularProgressIndicator(color: AppColors.primary), + ), + ); + } + + // Error state for posts + if (postsState.error != null && postsState.posts.isEmpty) { + return SliverFillRemaining( + child: Center( + child: InlineError( + message: postsState.error!, + onRetry: () => profileProvider.retryPosts(), + ), + ), + ); + } + + // Empty state + if (postsState.posts.isEmpty && !postsState.isLoading) { + return const SliverFillRemaining( + child: Center( + child: Text( + 'No posts yet', + style: TextStyle(fontSize: 16, color: AppColors.textSecondary), + ), + ), + ); + } + + // Posts list + // Only add extra slot for loading/error indicators, not just hasMore + final showLoadingSlot = + postsState.isLoadingMore || postsState.error != null; + + return SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + // Load more when reaching end + if (index == postsState.posts.length - 3 && postsState.hasMore) { + profileProvider.loadMorePosts(); + } + + // Show loading indicator or error at the end + if (index == postsState.posts.length) { + if (postsState.isLoadingMore) { + return const InlineLoading(); + } + if (postsState.error != null) { + return InlineError( + message: postsState.error!, + onRetry: () => profileProvider.loadMorePosts(), + ); + } + // Shouldn't reach here due to showLoadingSlot check + return const SizedBox.shrink(); + } + + final feedViewPost = postsState.posts[index]; + return PostCard(post: feedViewPost); + }, childCount: postsState.posts.length + (showLoadingSlot ? 1 : 0)), + ); + } + + Widget _buildComingSoonPlaceholder(String feature) { + return SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + feature == 'Comments' + ? Icons.chat_bubble_outline + : Icons.favorite_outline, + size: 48, + color: AppColors.textSecondary, + ), + const SizedBox(height: 16), + Text( + '$feature coming soon', + style: const TextStyle( + fontSize: 16, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ); + } +} + +/// Tab bar for profile content with icons +class _ProfileTabBar extends StatelessWidget { + const _ProfileTabBar({ + required this.selectedIndex, + required this.onTabChanged, + }); + + final int selectedIndex; + final ValueChanged onTabChanged; + + @override + Widget build(BuildContext context) { + return Container( + height: 48, + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: AppColors.border)), + ), + child: Row( + children: [ + Expanded( + child: _TabItem( + label: 'Posts', + icon: Icons.grid_view, + isSelected: selectedIndex == 0, + onTap: () => onTabChanged(0), + ), + ), + Expanded( + child: _TabItem( + label: 'Comments', + icon: Icons.chat_bubble_outline, + isSelected: selectedIndex == 1, + onTap: () => onTabChanged(1), + ), + ), + Expanded( + child: _TabItem( + label: 'Likes', + icon: Icons.favorite_outline, + isSelected: selectedIndex == 2, + onTap: () => onTabChanged(2), + ), + ), + ], + ), + ); + } +} + +class _TabItem extends StatelessWidget { + const _TabItem({ + required this.label, + required this.icon, + required this.isSelected, + required this.onTap, + }); + + final String label; + final IconData icon; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Container( + alignment: Alignment.center, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 16, + color: isSelected + ? AppColors.textPrimary + : AppColors.textSecondary, ), - const SizedBox(height: 4), + const SizedBox(width: 6), Text( - authProvider.did!, - style: const TextStyle( - fontSize: 16, - color: Color(0xFFB6C2D2), - fontFamily: 'monospace', + label, + style: TextStyle( + fontSize: 13, + fontWeight: + isSelected ? FontWeight.bold : FontWeight.normal, + color: isSelected + ? AppColors.textPrimary + : AppColors.textSecondary, ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 48), - PrimaryButton( - title: 'Sign Out', - onPressed: () async { - await authProvider.signOut(); - if (context.mounted) { - context.go('/'); - } - }, - variant: ButtonVariant.outline, - ), - ] else ...[ - const Text( - 'Sign in to view your profile', - style: TextStyle(fontSize: 16, color: Color(0xFFB6C2D2)), - textAlign: TextAlign.center, - ), - const SizedBox(height: 48), - PrimaryButton( - title: 'Sign in', - onPressed: () => context.go('/login'), ), ], - ], - ), + ), + const SizedBox(height: 8), + Container( + height: 3, + width: 50, + decoration: BoxDecoration( + color: isSelected ? AppColors.primary : Colors.transparent, + borderRadius: BorderRadius.circular(2), + ), + ), + ], ), ), ); } } + +/// Delegate for pinned tab bar header +class _ProfileTabBarDelegate extends SliverPersistentHeaderDelegate { + _ProfileTabBarDelegate({required this.child}); + + final Widget child; + + @override + Widget build( + BuildContext context, + double shrinkOffset, + bool overlapsContent, + ) { + return child; + } + + @override + double get maxExtent => 48; + + @override + double get minExtent => 48; + + @override + bool shouldRebuild(covariant _ProfileTabBarDelegate oldDelegate) { + return child != oldDelegate.child; + } +} diff --git a/lib/services/coves_api_service.dart b/lib/services/coves_api_service.dart index 4bbcdb9..822e5e7 100644 --- a/lib/services/coves_api_service.dart +++ b/lib/services/coves_api_service.dart @@ -5,6 +5,7 @@ import '../config/environment_config.dart'; import '../models/comment.dart'; import '../models/community.dart'; import '../models/post.dart'; +import '../models/user_profile.dart'; import 'api_exceptions.dart'; /// Coves API Service @@ -388,10 +389,7 @@ class CovesApiService { debugPrint('๐Ÿ“ก Fetching communities: sort=$sort, limit=$limit'); } - final queryParams = { - 'limit': limit, - 'sort': sort, - }; + final queryParams = {'limit': limit, 'sort': sort}; if (cursor != null) { queryParams['cursor'] = cursor; @@ -448,9 +446,7 @@ class CovesApiService { } // Build request body with only non-null fields - final requestBody = { - 'community': community, - }; + final requestBody = {'community': community}; if (title != null) { requestBody['title'] = title; @@ -481,9 +477,7 @@ class CovesApiService { debugPrint('โœ… Post created successfully'); } - return CreatePostResponse.fromJson( - response.data as Map, - ); + return CreatePostResponse.fromJson(response.data as Map); } on DioException catch (e) { _handleDioException(e, 'create post'); } catch (e) { @@ -545,6 +539,128 @@ class CovesApiService { } } + /// Get user profile by DID or handle + /// + /// Fetches detailed profile information for a user. + /// Works with both DID (did:plc:...) or handle (user.bsky.social). + /// + /// Parameters: + /// - [actor]: User's DID or handle (required) + /// + /// Throws: + /// - `NotFoundException` if the user does not exist + /// - `UnauthorizedException` if authentication is required/expired + /// - `ApiException` for other API errors + Future getProfile({required String actor}) async { + try { + if (kDebugMode) { + debugPrint('๐Ÿ“ก Fetching profile for: $actor'); + } + + final response = await _dio.get( + '/xrpc/social.coves.actor.getprofile', + queryParameters: {'actor': actor}, + ); + + if (kDebugMode) { + debugPrint('โœ… Profile fetched for: $actor'); + } + + final data = response.data; + if (data is! Map) { + throw FormatException('Expected Map but got ${data.runtimeType}'); + } + return UserProfile.fromJson(data); + } on DioException catch (e) { + _handleDioException(e, 'profile'); // Never returns - always throws + } on FormatException { + rethrow; + } on Exception catch (e) { + if (kDebugMode) { + debugPrint('โŒ Error parsing profile response: $e'); + } + throw ApiException('Failed to parse server response', originalError: e); + } + } + + /// Get posts by a specific actor + /// + /// Fetches posts created by a specific user using the dedicated + /// actor posts endpoint. + /// + /// Parameters: + /// - [actor]: User's DID or handle (required) + /// - [filter]: Post filter type (optional): + /// - 'posts_with_replies': Include replies + /// - 'posts_no_replies': Exclude replies (default behavior) + /// - 'posts_with_media': Only posts with media attachments + /// - [community]: Filter to posts in a specific community (optional) + /// - [limit]: Number of posts per page (default: 15, max: 50) + /// - [cursor]: Pagination cursor from previous response + /// + /// Throws: + /// - `NotFoundException` if the actor does not exist + /// - `UnauthorizedException` if authentication is required/expired + /// - `ApiException` for other API errors + Future getAuthorPosts({ + required String actor, + String? filter, + String? community, + int limit = 15, + String? cursor, + }) async { + try { + if (kDebugMode) { + debugPrint('๐Ÿ“ก Fetching posts for actor: $actor'); + } + + final queryParams = { + 'actor': actor, + 'limit': limit, + }; + + if (filter != null) { + queryParams['filter'] = filter; + } + + if (community != null) { + queryParams['community'] = community; + } + + if (cursor != null) { + queryParams['cursor'] = cursor; + } + + final response = await _dio.get( + '/xrpc/social.coves.actor.getPosts', + queryParameters: queryParams, + ); + + final data = response.data; + if (data is! Map) { + throw FormatException('Expected Map but got ${data.runtimeType}'); + } + + if (kDebugMode) { + debugPrint( + 'โœ… Actor posts fetched: ' + '${data['feed']?.length ?? 0} posts', + ); + } + + return TimelineResponse.fromJson(data); + } on DioException catch (e) { + _handleDioException(e, 'actor posts'); // Never returns - always throws + } on FormatException { + rethrow; + } on Exception catch (e) { + if (kDebugMode) { + debugPrint('โŒ Error parsing actor posts response: $e'); + } + throw ApiException('Failed to parse server response', originalError: e); + } + } + /// Handle Dio exceptions with specific error types /// /// Converts generic DioException into specific typed exceptions @@ -561,8 +677,14 @@ class CovesApiService { // Handle specific HTTP status codes if (e.response != null) { final statusCode = e.response!.statusCode; - final message = - e.response!.data?['error'] ?? e.response!.data?['message']; + // Handle both JSON error responses and plain text responses + String? message; + final data = e.response!.data; + if (data is Map) { + message = data['error'] as String? ?? data['message'] as String?; + } else if (data is String && data.isNotEmpty) { + message = data; + } if (statusCode != null) { if (statusCode == 401) { diff --git a/lib/utils/date_time_utils.dart b/lib/utils/date_time_utils.dart index 24df3f7..2c9e9a5 100644 --- a/lib/utils/date_time_utils.dart +++ b/lib/utils/date_time_utils.dart @@ -81,4 +81,29 @@ class DateTimeUtils { return '$hour12:$minute$period ยท $month $day, $year'; } + + /// Format datetime as "Joined Month Year" string + /// + /// Example: "Joined January 2025" + /// + /// [dateTime] is the account creation date + static String formatJoinedDate(DateTime dateTime) { + const months = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ]; + assert(dateTime.month >= 1 && dateTime.month <= 12, 'Invalid month'); + final month = months[dateTime.month - 1]; + return 'Joined $month ${dateTime.year}'; + } } diff --git a/lib/widgets/comment_card.dart b/lib/widgets/comment_card.dart index 01ae886..b375482 100644 --- a/lib/widgets/comment_card.dart +++ b/lib/widgets/comment_card.dart @@ -13,6 +13,7 @@ import '../providers/vote_provider.dart'; import '../utils/date_time_utils.dart'; import 'icons/animated_heart_icon.dart'; import 'sign_in_dialog.dart'; +import 'tappable_author.dart'; /// Comment card widget for displaying individual comments /// @@ -123,21 +124,29 @@ class CommentCard extends StatelessWidget { // Author info row Row( children: [ - // Author avatar - _buildAuthorAvatar(comment.author), - const SizedBox(width: 8), - Expanded( - child: Text( - '@${comment.author.handle}', - style: TextStyle( - color: AppColors.textPrimary.withValues( - alpha: isCollapsed ? 0.7 : 0.5, + // Author avatar and handle (tappable for profile) + TappableAuthor( + authorDid: comment.author.did, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Author avatar + _buildAuthorAvatar(comment.author), + const SizedBox(width: 8), + Text( + '@${comment.author.handle}', + style: TextStyle( + color: AppColors.textPrimary.withValues( + alpha: isCollapsed ? 0.7 : 0.5, + ), + fontSize: 13, + fontWeight: FontWeight.w500, + ), ), - fontSize: 13, - fontWeight: FontWeight.w500, - ), + ], ), ), + const Spacer(), // Show collapsed count OR time ago if (isCollapsed && collapsedCount > 0) _buildCollapsedBadge() diff --git a/lib/widgets/post_card.dart b/lib/widgets/post_card.dart index 985a922..cafbc6d 100644 --- a/lib/widgets/post_card.dart +++ b/lib/widgets/post_card.dart @@ -14,6 +14,7 @@ import 'external_link_bar.dart'; import 'fullscreen_video_player.dart'; import 'post_card_actions.dart'; import 'source_link_bar.dart'; +import 'tappable_author.dart'; /// Post card widget for displaying feed posts /// @@ -100,12 +101,16 @@ class PostCard extends StatelessWidget { children: [ // Community handle with styled parts _buildCommunityHandle(post.post.community), - // Author handle - Text( - '@${post.post.author.handle}', - style: const TextStyle( - color: AppColors.textSecondary, - fontSize: 12, + // Author handle (tappable for profile navigation) + TappableAuthor( + authorDid: post.post.author.did, + padding: const EdgeInsets.symmetric(vertical: 2), + child: Text( + '@${post.post.author.handle}', + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 12, + ), ), ), ], @@ -133,7 +138,7 @@ class PostCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ // Author info (shown in detail view, above title) - if (showAuthorFooter) _buildAuthorFooter(), + if (showAuthorFooter) _buildAuthorFooter(context), // Title and text wrapped in InkWell for navigation if (!disableNavigation && @@ -298,8 +303,9 @@ class PostCard extends StatelessWidget { /// Builds the community handle with styled parts (name + instance) Widget _buildCommunityHandle(CommunityRef community) { - final displayHandle = - CommunityHandleUtils.formatHandleForDisplay(community.handle); + final displayHandle = CommunityHandleUtils.formatHandleForDisplay( + community.handle, + ); // Fallback to raw handle or name if formatting fails if (displayHandle == null || !displayHandle.contains('@')) { @@ -381,40 +387,50 @@ class PostCard extends StatelessWidget { } /// Builds author footer with avatar, handle, and timestamp - Widget _buildAuthorFooter() { + Widget _buildAuthorFooter(BuildContext context) { final author = post.post.author; return Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), child: Row( children: [ - // Author avatar (circular, small) - if (author.avatar != null && author.avatar!.isNotEmpty) - ClipRRect( - borderRadius: BorderRadius.circular(10), - child: CachedNetworkImage( - imageUrl: author.avatar!, - width: 20, - height: 20, - fit: BoxFit.cover, - placeholder: - (context, url) => _buildAuthorFallbackAvatar(author), - errorWidget: - (context, url, error) => _buildAuthorFallbackAvatar(author), - ), - ) - else - _buildAuthorFallbackAvatar(author), - const SizedBox(width: 8), - - // Author handle - Text( - '@${author.handle}', - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 13, + // Author avatar and handle (tappable for profile navigation) + TappableAuthor( + authorDid: author.did, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Author avatar (circular, small) + if (author.avatar != null && author.avatar!.isNotEmpty) + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: CachedNetworkImage( + imageUrl: author.avatar!, + width: 20, + height: 20, + fit: BoxFit.cover, + placeholder: + (context, url) => _buildAuthorFallbackAvatar(author), + errorWidget: + (context, url, error) => + _buildAuthorFallbackAvatar(author), + ), + ) + else + _buildAuthorFallbackAvatar(author), + const SizedBox(width: 8), + + // Author handle + Text( + '@${author.handle}', + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 13, + ), + overflow: TextOverflow.ellipsis, + ), + ], ), - overflow: TextOverflow.ellipsis, ), const SizedBox(width: 8), diff --git a/lib/widgets/profile_header.dart b/lib/widgets/profile_header.dart new file mode 100644 index 0000000..010360b --- /dev/null +++ b/lib/widgets/profile_header.dart @@ -0,0 +1,384 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; + +import '../constants/app_colors.dart'; +import '../models/user_profile.dart'; +import '../utils/date_time_utils.dart'; + +/// Profile header widget displaying banner, avatar, and user info +/// +/// Layout matches Bluesky profile design: +/// - Full-width banner image (~150px height) +/// - Circular avatar (80px) overlapping banner at bottom-left +/// - Display name, handle, and bio below +/// - Stats row showing post/comment/community counts +class ProfileHeader extends StatelessWidget { + const ProfileHeader({ + required this.profile, + required this.isOwnProfile, + this.onEditPressed, + this.onMenuPressed, + this.onSharePressed, + super.key, + }); + + final UserProfile? profile; + final bool isOwnProfile; + final VoidCallback? onEditPressed; + final VoidCallback? onMenuPressed; + final VoidCallback? onSharePressed; + + static const double bannerHeight = 150; + + @override + Widget build(BuildContext context) { + // Stack-based layout with banner image behind profile content + return Stack( + children: [ + // Banner image (or gradient fallback) + _buildBannerImage(), + // Gradient overlay for text readability + Positioned.fill( + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + AppColors.background.withValues(alpha: 0.3), + AppColors.background, + ], + stops: const [0.0, 0.5, 1.0], + ), + ), + ), + ), + // Profile content - UnconstrainedBox allows content to be natural size + // and clips overflow when SliverAppBar collapses + SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.only(top: kToolbarHeight), + child: UnconstrainedBox( + clipBehavior: Clip.hardEdge, + alignment: Alignment.topLeft, + constrainedAxis: Axis.horizontal, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Avatar and name row (side by side) + _buildAvatarAndNameRow(), + // Bio + if (profile?.bio != null && profile!.bio!.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + profile!.bio!, + style: const TextStyle( + fontSize: 14, + color: AppColors.textPrimary, + height: 1.4, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + // Stats row + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: _buildStatsRow(), + ), + // Member since date + if (profile?.createdAt != null) ...[ + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + const Icon( + Icons.calendar_today_outlined, + size: 14, + color: AppColors.textSecondary, + ), + const SizedBox(width: 6), + Text( + DateTimeUtils.formatJoinedDate(profile!.createdAt!), + style: const TextStyle( + fontSize: 13, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ], + ], + ), + ), + ), + ), + ], + ); + } + + Widget _buildBannerImage() { + if (profile?.banner != null && profile!.banner!.isNotEmpty) { + return SizedBox( + height: bannerHeight, + width: double.infinity, + child: CachedNetworkImage( + imageUrl: profile!.banner!, + fit: BoxFit.cover, + placeholder: (context, url) => _buildDefaultBanner(), + errorWidget: (context, url, error) => _buildDefaultBanner(), + ), + ); + } + return _buildDefaultBanner(); + } + + Widget _buildDefaultBanner() { + // TODO: Replace with Image.asset('assets/images/default_banner.png') + // when the user provides the default banner asset + return Container( + height: bannerHeight, + width: double.infinity, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + AppColors.primary.withValues(alpha: 0.6), + AppColors.primary.withValues(alpha: 0.3), + ], + ), + ), + ); + } + + Widget _buildAvatarAndNameRow() { + const avatarSize = 80.0; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Avatar with drop shadow + Container( + width: avatarSize, + height: avatarSize, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: AppColors.background, + width: 3, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 8, + offset: const Offset(0, 2), + spreadRadius: 1, + ), + ], + ), + child: ClipOval( + child: _buildAvatar(avatarSize - 6), + ), + ), + const SizedBox(width: 12), + // Handle and DID column + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + // Handle + Text( + profile?.handle != null + ? '@${profile!.handle}' + : 'Loading...', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + // DID with icon + if (profile?.did != null) ...[ + const SizedBox(height: 4), + Row( + children: [ + const Icon( + Icons.qr_code_2, + size: 14, + color: AppColors.textSecondary, + ), + const SizedBox(width: 4), + Text( + profile!.did, + style: const TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + fontFamily: 'monospace', + ), + ), + ], + ), + ], + ], + ), + ), + // Edit button for own profile + if (isOwnProfile && onEditPressed != null) + _ActionButton( + icon: Icons.edit_outlined, + onPressed: onEditPressed!, + tooltip: 'Edit Profile', + ), + ], + ), + ); + } + + Widget _buildAvatar(double size) { + if (profile?.avatar != null) { + return CachedNetworkImage( + imageUrl: profile!.avatar!, + width: size, + height: size, + fit: BoxFit.cover, + placeholder: (context, url) => _buildAvatarLoading(size), + errorWidget: (context, url, error) => _buildFallbackAvatar(size), + ); + } + return _buildFallbackAvatar(size); + } + + Widget _buildAvatarLoading(double size) { + return Container( + width: size, + height: size, + color: AppColors.backgroundSecondary, + child: const Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.primary, + ), + ), + ), + ); + } + + Widget _buildFallbackAvatar(double size) { + return Container( + width: size, + height: size, + color: AppColors.primary, + child: Icon(Icons.person, size: size * 0.5, color: Colors.white), + ); + } + + Widget _buildStatsRow() { + final stats = profile?.stats; + + return Wrap( + spacing: 16, + runSpacing: 8, + children: [ + _StatItem(label: 'Posts', value: stats?.postCount ?? 0), + _StatItem(label: 'Comments', value: stats?.commentCount ?? 0), + _StatItem(label: 'Memberships', value: stats?.membershipCount ?? 0), + ], + ); + } +} + +/// Small action button for profile actions +class _ActionButton extends StatelessWidget { + const _ActionButton({ + required this.icon, + required this.onPressed, + this.tooltip, + }); + + final IconData icon; + final VoidCallback onPressed; + final String? tooltip; + + @override + Widget build(BuildContext context) { + return Tooltip( + message: tooltip ?? '', + child: Material( + color: AppColors.backgroundSecondary, + borderRadius: BorderRadius.circular(8), + child: InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.all(8), + child: Icon(icon, size: 20, color: AppColors.textSecondary), + ), + ), + ), + ); + } +} + +/// Stats item showing label and value +class _StatItem extends StatelessWidget { + const _StatItem({ + required this.label, + required this.value, + }); + + final String label; + final int value; + + @override + Widget build(BuildContext context) { + final valueText = _formatNumber(value); + + return RichText( + text: TextSpan( + children: [ + TextSpan( + text: valueText, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + TextSpan( + text: ' $label', + style: const TextStyle( + fontSize: 14, + color: AppColors.textSecondary, + ), + ), + ], + ), + ); + } + + String _formatNumber(int value) { + if (value >= 1000000) { + return '${(value / 1000000).toStringAsFixed(1)}M'; + } else if (value >= 1000) { + return '${(value / 1000).toStringAsFixed(1)}K'; + } + return value.toString(); + } +} diff --git a/lib/widgets/tappable_author.dart b/lib/widgets/tappable_author.dart new file mode 100644 index 0000000..f83ae83 --- /dev/null +++ b/lib/widgets/tappable_author.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +/// Wraps a child widget to make it navigate to an author's profile on tap. +/// +/// This widget encapsulates the common pattern of tapping an author's avatar +/// or name to navigate to their profile page. It handles the InkWell styling +/// and navigation logic. +/// +/// Example: +/// ```dart +/// TappableAuthor( +/// authorDid: post.author.did, +/// child: Row( +/// children: [ +/// AuthorAvatar(author: post.author), +/// Text('@${post.author.handle}'), +/// ], +/// ), +/// ) +/// ``` +class TappableAuthor extends StatelessWidget { + const TappableAuthor({ + required this.authorDid, + required this.child, + this.borderRadius = 4.0, + this.padding = const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + super.key, + }); + + /// The DID of the author to navigate to + final String authorDid; + + /// The child widget to wrap (typically avatar + handle row) + final Widget child; + + /// Border radius for the InkWell splash effect + final double borderRadius; + + /// Padding around the child + final EdgeInsetsGeometry padding; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () => context.push('/profile/$authorDid'), + borderRadius: BorderRadius.circular(borderRadius), + child: Padding(padding: padding, child: child), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 862e8a2..14077a7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -94,6 +94,7 @@ flutter: - assets/logo/lil_dude.svg - assets/icons/ - assets/icons/atproto/ + - assets/images/ # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images