feat: Follow system - database migration, backend handlers, FollowButton widget, API methods
This commit is contained in:
parent
bc3fdb4211
commit
da93bc3579
235
go-backend/internal/handlers/follow_handler.go
Normal file
235
go-backend/internal/handlers/follow_handler.go
Normal 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})
|
||||||
|
}
|
||||||
|
|
@ -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>>() ?? [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
150
sojorn_app/lib/widgets/follow_button.dart
Normal file
150
sojorn_app/lib/widgets/follow_button.dart
Normal 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -176,12 +176,12 @@ class _WelcomePage extends StatelessWidget {
|
||||||
child: const Icon(Icons.shield_outlined, color: Colors.white, size: 40),
|
child: const Icon(Icons.shield_outlined, color: Colors.white, size: 40),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 28),
|
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,
|
fontSize: 22, fontWeight: FontWeight.w800, color: AppTheme.navyBlue,
|
||||||
), textAlign: TextAlign.center),
|
), textAlign: TextAlign.center),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
Text(
|
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(
|
style: TextStyle(
|
||||||
fontSize: 14, color: SojornColors.postContentLight, height: 1.5,
|
fontSize: 14, color: SojornColors.postContentLight, height: 1.5,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue