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),
|
||||
),
|
||||
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,
|
||||
),
|
||||
|
|
|
|||
Loading…
Reference in a new issue