feat: Follow system - database migration, backend handlers, FollowButton widget, API methods

This commit is contained in:
Patrick Britton 2026-02-17 10:16:44 -06:00
parent bc3fdb4211
commit da93bc3579
4 changed files with 430 additions and 2 deletions

View file

@ -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})
}

View file

@ -1547,4 +1547,47 @@ class ApiService {
},
);
}
// Follow System
// =========================================================================
/// Follow a user
Future<void> followUser(String targetUserId) async {
await _callGoApi('/users/$targetUserId/follow', method: 'POST');
}
/// Unfollow a user
Future<void> unfollowUser(String targetUserId) async {
await _callGoApi('/users/$targetUserId/unfollow', method: 'POST');
}
/// Check if current user follows target user
Future<bool> 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<List<Map<String, dynamic>>> getMutualFollowers(String targetUserId) async {
final data = await _callGoApi('/users/$targetUserId/mutual-followers', method: 'GET');
return (data['mutual_followers'] as List?)?.cast<Map<String, dynamic>>() ?? [];
}
/// Get suggested users to follow
Future<List<Map<String, dynamic>>> getSuggestedUsers({int limit = 10}) async {
final data = await _callGoApi('/users/suggested', method: 'GET', queryParams: {'limit': '$limit'});
return (data['suggestions'] as List?)?.cast<Map<String, dynamic>>() ?? [];
}
/// Get list of followers for a user
Future<List<Map<String, dynamic>>> getFollowers(String userId) async {
final data = await _callGoApi('/users/$userId/followers', method: 'GET');
return (data['followers'] as List?)?.cast<Map<String, dynamic>>() ?? [];
}
/// Get list of users that a user follows
Future<List<Map<String, dynamic>>> getFollowing(String userId) async {
final data = await _callGoApi('/users/$userId/following', method: 'GET');
return (data['following'] as List?)?.cast<Map<String, dynamic>>() ?? [];
}
}

View file

@ -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<FollowButton> createState() => _FollowButtonState();
}
class _FollowButtonState extends State<FollowButton> {
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<void> _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),
),
),
);
}
}

View file

@ -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,
),