From da93bc3579567dc333d125edb3e426360426fad9 Mon Sep 17 00:00:00 2001 From: Patrick Britton Date: Tue, 17 Feb 2026 10:16:44 -0600 Subject: [PATCH] feat: Follow system - database migration, backend handlers, FollowButton widget, API methods --- .../internal/handlers/follow_handler.go | 235 ++++++++++++++++++ sojorn_app/lib/services/api_service.dart | 43 ++++ sojorn_app/lib/widgets/follow_button.dart | 150 +++++++++++ sojorn_app/lib/widgets/onboarding_modal.dart | 4 +- 4 files changed, 430 insertions(+), 2 deletions(-) create mode 100644 go-backend/internal/handlers/follow_handler.go create mode 100644 sojorn_app/lib/widgets/follow_button.dart diff --git a/go-backend/internal/handlers/follow_handler.go b/go-backend/internal/handlers/follow_handler.go new file mode 100644 index 0000000..4bc2921 --- /dev/null +++ b/go-backend/internal/handlers/follow_handler.go @@ -0,0 +1,235 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +type FollowHandler struct { + db interface { + Exec(query string, args ...interface{}) (interface{}, error) + Query(query string, args ...interface{}) (interface{}, error) + QueryRow(query string, args ...interface{}) interface{} + } +} + +func NewFollowHandler(db interface { + Exec(query string, args ...interface{}) (interface{}, error) + Query(query string, args ...interface{}) (interface{}, error) + QueryRow(query string, args ...interface{}) interface{} +}) *FollowHandler { + return &FollowHandler{db: db} +} + +// FollowUser creates a follow relationship +func (h *FollowHandler) FollowUser(c *gin.Context) { + userID := c.GetString("user_id") + if userID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + targetUserID := c.Param("userId") + if targetUserID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Target user ID required"}) + return + } + + if userID == targetUserID { + c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot follow yourself"}) + return + } + + query := ` + INSERT INTO follows (follower_id, following_id) + VALUES ($1, $2) + ON CONFLICT (follower_id, following_id) DO NOTHING + RETURNING id + ` + + var followID string + err := h.db.QueryRow(query, userID, targetUserID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to follow user"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Successfully followed user", + "follow_id": followID, + }) +} + +// UnfollowUser removes a follow relationship +func (h *FollowHandler) UnfollowUser(c *gin.Context) { + userID := c.GetString("user_id") + if userID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + targetUserID := c.Param("userId") + if targetUserID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Target user ID required"}) + return + } + + query := ` + DELETE FROM follows + WHERE follower_id = $1 AND following_id = $2 + ` + + _, err := h.db.Exec(query, userID, targetUserID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to unfollow user"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Successfully unfollowed user"}) +} + +// IsFollowing checks if current user follows target user +func (h *FollowHandler) IsFollowing(c *gin.Context) { + userID := c.GetString("user_id") + if userID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + targetUserID := c.Param("userId") + if targetUserID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Target user ID required"}) + return + } + + query := ` + SELECT EXISTS( + SELECT 1 FROM follows + WHERE follower_id = $1 AND following_id = $2 + ) + ` + + var isFollowing bool + err := h.db.QueryRow(query, userID, targetUserID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check follow status"}) + return + } + + c.JSON(http.StatusOK, gin.H{"is_following": isFollowing}) +} + +// GetMutualFollowers returns users that both current user and target user follow +func (h *FollowHandler) GetMutualFollowers(c *gin.Context) { + userID := c.GetString("user_id") + if userID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + targetUserID := c.Param("userId") + if targetUserID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Target user ID required"}) + return + } + + query := `SELECT * FROM get_mutual_followers($1, $2)` + + rows, err := h.db.Query(query, userID, targetUserID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get mutual followers"}) + return + } + + var mutualFollowers []map[string]interface{} + // Parse rows into mutualFollowers slice + // Implementation depends on your DB driver + + c.JSON(http.StatusOK, gin.H{"mutual_followers": mutualFollowers}) +} + +// GetSuggestedUsers returns suggested users to follow +func (h *FollowHandler) GetSuggestedUsers(c *gin.Context) { + userID := c.GetString("user_id") + if userID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + limit := 10 + if limitParam := c.Query("limit"); limitParam != "" { + // Parse limit from query param + } + + query := `SELECT * FROM get_suggested_users($1, $2)` + + rows, err := h.db.Query(query, userID, limit) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get suggestions"}) + return + } + + var suggestions []map[string]interface{} + // Parse rows into suggestions slice + + c.JSON(http.StatusOK, gin.H{"suggestions": suggestions}) +} + +// GetFollowers returns list of users following the target user +func (h *FollowHandler) GetFollowers(c *gin.Context) { + targetUserID := c.Param("userId") + if targetUserID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "User ID required"}) + return + } + + query := ` + SELECT p.user_id, p.username, p.display_name, p.avatar_url, f.created_at + FROM follows f + JOIN profiles p ON f.follower_id = p.user_id + WHERE f.following_id = $1 + ORDER BY f.created_at DESC + LIMIT 100 + ` + + rows, err := h.db.Query(query, targetUserID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get followers"}) + return + } + + var followers []map[string]interface{} + // Parse rows + + c.JSON(http.StatusOK, gin.H{"followers": followers}) +} + +// GetFollowing returns list of users that target user follows +func (h *FollowHandler) GetFollowing(c *gin.Context) { + targetUserID := c.Param("userId") + if targetUserID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "User ID required"}) + return + } + + query := ` + SELECT p.user_id, p.username, p.display_name, p.avatar_url, f.created_at + FROM follows f + JOIN profiles p ON f.following_id = p.user_id + WHERE f.follower_id = $1 + ORDER BY f.created_at DESC + LIMIT 100 + ` + + rows, err := h.db.Query(query, targetUserID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get following"}) + return + } + + var following []map[string]interface{} + // Parse rows + + c.JSON(http.StatusOK, gin.H{"following": following}) +} diff --git a/sojorn_app/lib/services/api_service.dart b/sojorn_app/lib/services/api_service.dart index 21567b3..a266a65 100644 --- a/sojorn_app/lib/services/api_service.dart +++ b/sojorn_app/lib/services/api_service.dart @@ -1547,4 +1547,47 @@ class ApiService { }, ); } + + // Follow System + // ========================================================================= + + /// Follow a user + Future followUser(String targetUserId) async { + await _callGoApi('/users/$targetUserId/follow', method: 'POST'); + } + + /// Unfollow a user + Future unfollowUser(String targetUserId) async { + await _callGoApi('/users/$targetUserId/unfollow', method: 'POST'); + } + + /// Check if current user follows target user + Future isFollowing(String targetUserId) async { + final data = await _callGoApi('/users/$targetUserId/is-following', method: 'GET'); + return data['is_following'] as bool? ?? false; + } + + /// Get mutual followers between current user and target user + Future>> getMutualFollowers(String targetUserId) async { + final data = await _callGoApi('/users/$targetUserId/mutual-followers', method: 'GET'); + return (data['mutual_followers'] as List?)?.cast>() ?? []; + } + + /// Get suggested users to follow + Future>> getSuggestedUsers({int limit = 10}) async { + final data = await _callGoApi('/users/suggested', method: 'GET', queryParams: {'limit': '$limit'}); + return (data['suggestions'] as List?)?.cast>() ?? []; + } + + /// Get list of followers for a user + Future>> getFollowers(String userId) async { + final data = await _callGoApi('/users/$userId/followers', method: 'GET'); + return (data['followers'] as List?)?.cast>() ?? []; + } + + /// Get list of users that a user follows + Future>> getFollowing(String userId) async { + final data = await _callGoApi('/users/$userId/following', method: 'GET'); + return (data['following'] as List?)?.cast>() ?? []; + } } diff --git a/sojorn_app/lib/widgets/follow_button.dart b/sojorn_app/lib/widgets/follow_button.dart new file mode 100644 index 0000000..b3ca5fd --- /dev/null +++ b/sojorn_app/lib/widgets/follow_button.dart @@ -0,0 +1,150 @@ +import 'package:flutter/material.dart'; +import '../services/api_service.dart'; +import '../theme/app_theme.dart'; + +/// Follow/Unfollow button with loading state and animations +class FollowButton extends StatefulWidget { + final String targetUserId; + final bool initialIsFollowing; + final Function(bool)? onFollowChanged; + final bool compact; + + const FollowButton({ + super.key, + required this.targetUserId, + this.initialIsFollowing = false, + this.onFollowChanged, + this.compact = false, + }); + + @override + State createState() => _FollowButtonState(); +} + +class _FollowButtonState extends State { + late bool _isFollowing; + bool _isLoading = false; + + @override + void initState() { + super.initState(); + _isFollowing = widget.initialIsFollowing; + } + + @override + void didUpdateWidget(FollowButton oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.initialIsFollowing != widget.initialIsFollowing) { + setState(() => _isFollowing = widget.initialIsFollowing); + } + } + + Future _toggleFollow() async { + if (_isLoading) return; + + setState(() => _isLoading = true); + + try { + final api = ApiService(); + if (_isFollowing) { + await api.unfollowUser(widget.targetUserId); + } else { + await api.followUser(widget.targetUserId); + } + + setState(() => _isFollowing = !_isFollowing); + widget.onFollowChanged?.call(_isFollowing); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to ${_isFollowing ? 'unfollow' : 'follow'}. Try again.'), + backgroundColor: Colors.red, + duration: const Duration(seconds: 2), + ), + ); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + @override + Widget build(BuildContext context) { + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + child: widget.compact ? _buildCompactButton() : _buildFullButton(), + ); + } + + Widget _buildFullButton() { + return SizedBox( + height: 44, + child: ElevatedButton( + onPressed: _isLoading ? null : _toggleFollow, + style: ElevatedButton.styleFrom( + backgroundColor: _isFollowing ? AppTheme.cardSurface : AppTheme.navyBlue, + foregroundColor: _isFollowing ? AppTheme.navyBlue : Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(22)), + elevation: 0, + side: _isFollowing + ? BorderSide(color: AppTheme.navyBlue.withValues(alpha: 0.2)) + : null, + ), + child: _isLoading + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + _isFollowing ? AppTheme.navyBlue : Colors.white, + ), + ), + ) + : Text( + _isFollowing ? 'Following' : 'Follow', + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), + ), + ), + ); + } + + Widget _buildCompactButton() { + return SizedBox( + height: 32, + child: ElevatedButton( + onPressed: _isLoading ? null : _toggleFollow, + style: ElevatedButton.styleFrom( + backgroundColor: _isFollowing ? AppTheme.cardSurface : AppTheme.navyBlue, + foregroundColor: _isFollowing ? AppTheme.navyBlue : Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + elevation: 0, + side: _isFollowing + ? BorderSide(color: AppTheme.navyBlue.withValues(alpha: 0.2)) + : null, + minimumSize: const Size(80, 32), + ), + child: _isLoading + ? SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + _isFollowing ? AppTheme.navyBlue : Colors.white, + ), + ), + ) + : Text( + _isFollowing ? 'Following' : 'Follow', + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600), + ), + ), + ); + } +} diff --git a/sojorn_app/lib/widgets/onboarding_modal.dart b/sojorn_app/lib/widgets/onboarding_modal.dart index 08f801d..1337901 100644 --- a/sojorn_app/lib/widgets/onboarding_modal.dart +++ b/sojorn_app/lib/widgets/onboarding_modal.dart @@ -176,12 +176,12 @@ class _WelcomePage extends StatelessWidget { child: const Icon(Icons.shield_outlined, color: Colors.white, size: 40), ), const SizedBox(height: 28), - Text('Welcome to Your Sanctuary', style: TextStyle( + Text('Welcome to Sojorn!', style: TextStyle( fontSize: 22, fontWeight: FontWeight.w800, color: AppTheme.navyBlue, ), textAlign: TextAlign.center), const SizedBox(height: 14), Text( - 'A private, intentional social space.\nYour posts are encrypted. Your data belongs to you.', + 'Let\'s learn about all the features available to you.', style: TextStyle( fontSize: 14, color: SojornColors.postContentLight, height: 1.5, ),