Fix UUID casting issues in post, notification, and category repositories
- Replace NULLIF with CASE WHEN for proper UUID casting - Fix missing ::uuid casting in WHERE clauses - Resolve 'operator does not exist: uuid = text' errors - Focus on post_repository.go, notification_repository.go, and category_repository.go
309
_tmp_server/main.go
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
aws "github.com/aws/aws-sdk-go-v2/aws"
|
||||
awsconfig "github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/patbritton/sojorn-backend/internal/config"
|
||||
"github.com/patbritton/sojorn-backend/internal/handlers"
|
||||
"github.com/patbritton/sojorn-backend/internal/middleware"
|
||||
"github.com/patbritton/sojorn-backend/internal/realtime"
|
||||
"github.com/patbritton/sojorn-backend/internal/repository"
|
||||
"github.com/patbritton/sojorn-backend/internal/services"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Load Config
|
||||
cfg := config.LoadConfig()
|
||||
|
||||
// Logger setup
|
||||
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339})
|
||||
|
||||
// Database Connection
|
||||
// Check if DATABASE_URL is set, if not try to load from .env
|
||||
if cfg.DatabaseURL == "" {
|
||||
log.Fatal().Msg("DATABASE_URL is not set")
|
||||
}
|
||||
|
||||
pgxConfig, err := pgxpool.ParseConfig(cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Unable to parse database config")
|
||||
}
|
||||
|
||||
dbPool, err := pgxpool.NewWithConfig(context.Background(), pgxConfig)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Unable to connect to database")
|
||||
}
|
||||
defer dbPool.Close()
|
||||
|
||||
if err := dbPool.Ping(context.Background()); err != nil {
|
||||
log.Fatal().Err(err).Msg("Unable to ping database")
|
||||
}
|
||||
|
||||
// Initialize Gin
|
||||
r := gin.Default()
|
||||
|
||||
allowedOrigins := strings.Split(cfg.CORSOrigins, ",")
|
||||
allowAllOrigins := false
|
||||
allowedOriginSet := make(map[string]struct{}, len(allowedOrigins))
|
||||
for _, origin := range allowedOrigins {
|
||||
trimmed := strings.TrimSpace(origin)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if trimmed == "*" {
|
||||
allowAllOrigins = true
|
||||
break
|
||||
}
|
||||
allowedOriginSet[trimmed] = struct{}{}
|
||||
}
|
||||
|
||||
// Use CORS middleware
|
||||
r.Use(cors.New(cors.Config{
|
||||
AllowOriginFunc: func(origin string) bool {
|
||||
log.Debug().Msgf("CORS origin: %s", origin)
|
||||
if allowAllOrigins {
|
||||
return true
|
||||
}
|
||||
// Always allow localhost/loopback for dev tools & Flutter web debug
|
||||
if strings.HasPrefix(origin, "http://localhost") ||
|
||||
strings.HasPrefix(origin, "https://localhost") ||
|
||||
strings.HasPrefix(origin, "http://127.0.0.1") ||
|
||||
strings.HasPrefix(origin, "https://127.0.0.1") {
|
||||
return true
|
||||
}
|
||||
_, ok := allowedOriginSet[origin]
|
||||
return ok
|
||||
},
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||||
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Request-ID"},
|
||||
ExposeHeaders: []string{"Content-Length"},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 12 * time.Hour,
|
||||
}))
|
||||
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
log.Debug().Msgf("No route found for %s %s", c.Request.Method, c.Request.URL.Path)
|
||||
c.JSON(404, gin.H{"error": "route not found", "path": c.Request.URL.Path, "method": c.Request.Method})
|
||||
})
|
||||
|
||||
// Initialize Repositories
|
||||
userRepo := repository.NewUserRepository(dbPool)
|
||||
postRepo := repository.NewPostRepository(dbPool)
|
||||
chatRepo := repository.NewChatRepository(dbPool)
|
||||
categoryRepo := repository.NewCategoryRepository(dbPool)
|
||||
notifRepo := repository.NewNotificationRepository(dbPool)
|
||||
|
||||
// Initialize Services
|
||||
assetService := services.NewAssetService(cfg.R2SigningSecret, cfg.R2PublicBaseURL, cfg.R2ImgDomain, cfg.R2VidDomain)
|
||||
feedService := services.NewFeedService(postRepo, assetService)
|
||||
|
||||
pushService, err := services.NewPushService(userRepo, cfg.FirebaseCredentialsFile)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to initialize PushService")
|
||||
}
|
||||
|
||||
emailService := services.NewEmailService(cfg)
|
||||
|
||||
// Initialize Realtime
|
||||
hub := realtime.NewHub()
|
||||
jwtSecrets := []string{cfg.JWTSecret}
|
||||
if cfg.SecondaryJWTSecret != "" {
|
||||
jwtSecrets = append(jwtSecrets, cfg.SecondaryJWTSecret)
|
||||
}
|
||||
wsHandler := handlers.NewWSHandler(hub, jwtSecrets)
|
||||
|
||||
// Initialize Handlers
|
||||
userHandler := handlers.NewUserHandler(userRepo, postRepo, pushService, assetService)
|
||||
postHandler := handlers.NewPostHandler(postRepo, userRepo, feedService, assetService)
|
||||
chatHandler := handlers.NewChatHandler(chatRepo, pushService, hub)
|
||||
authHandler := handlers.NewAuthHandler(userRepo, cfg, emailService)
|
||||
categoryHandler := handlers.NewCategoryHandler(categoryRepo)
|
||||
keyHandler := handlers.NewKeyHandler(userRepo)
|
||||
functionProxyHandler := handlers.NewFunctionProxyHandler()
|
||||
settingsHandler := handlers.NewSettingsHandler(userRepo, notifRepo)
|
||||
analysisHandler := handlers.NewAnalysisHandler()
|
||||
|
||||
// Setup Media Handler (R2)
|
||||
var s3Client *s3.Client
|
||||
if cfg.R2AccessKey != "" && cfg.R2SecretKey != "" && cfg.R2Endpoint != "" {
|
||||
resolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...interface{}) (aws.Endpoint, error) {
|
||||
return aws.Endpoint{URL: cfg.R2Endpoint, PartitionID: "aws", SigningRegion: "auto"}, nil
|
||||
})
|
||||
awsCfg, err := awsconfig.LoadDefaultConfig(
|
||||
context.Background(),
|
||||
awsconfig.WithRegion("auto"),
|
||||
awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(cfg.R2AccessKey, cfg.R2SecretKey, "")),
|
||||
awsconfig.WithEndpointResolverWithOptions(resolver),
|
||||
)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to load AWS/R2 config, falling back to R2 API token flow")
|
||||
} else {
|
||||
s3Client = s3.NewFromConfig(awsCfg)
|
||||
}
|
||||
}
|
||||
|
||||
mediaHandler := handlers.NewMediaHandler(
|
||||
s3Client,
|
||||
cfg.R2AccountID,
|
||||
cfg.R2APIToken,
|
||||
cfg.R2MediaBucket,
|
||||
cfg.R2VideoBucket,
|
||||
cfg.R2ImgDomain,
|
||||
cfg.R2VidDomain,
|
||||
)
|
||||
|
||||
// WebSocket Route
|
||||
r.GET("/ws", wsHandler.ServeWS)
|
||||
|
||||
// API Groups
|
||||
r.GET("/ping", func(c *gin.Context) {
|
||||
c.String(200, "pong")
|
||||
})
|
||||
|
||||
// Liveness/healthcheck endpoint (no auth)
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
v1 := r.Group("/api/v1")
|
||||
{
|
||||
// Public routes
|
||||
log.Info().Msg("Registering public auth routes")
|
||||
auth := v1.Group("/auth")
|
||||
auth.Use(middleware.RateLimit(0.5, 3)) // 3 requests bust, then 1 every 2 seconds
|
||||
{
|
||||
auth.POST("/register", authHandler.Register)
|
||||
auth.POST("/signup", authHandler.Register) // Alias for Supabase compatibility/legacy
|
||||
auth.POST("/login", authHandler.Login)
|
||||
auth.POST("/refresh", authHandler.RefreshSession) // Added
|
||||
auth.POST("/resend-verification", authHandler.ResendVerificationEmail)
|
||||
auth.GET("/verify", authHandler.VerifyEmail)
|
||||
auth.POST("/forgot-password", authHandler.ForgotPassword)
|
||||
auth.POST("/reset-password", authHandler.ResetPassword)
|
||||
}
|
||||
|
||||
// Authenticated routes
|
||||
authorized := v1.Group("")
|
||||
authorized.Use(middleware.AuthMiddleware(jwtSecrets))
|
||||
{
|
||||
authorized.GET("/profiles/:id", userHandler.GetProfile)
|
||||
authorized.GET("/profile", userHandler.GetProfile)
|
||||
authorized.PATCH("/profile", userHandler.UpdateProfile)
|
||||
authorized.POST("/complete-onboarding", authHandler.CompleteOnboarding)
|
||||
|
||||
// Settings Routes
|
||||
settings := authorized.Group("/settings")
|
||||
{
|
||||
settings.GET("/privacy", settingsHandler.GetPrivacySettings)
|
||||
settings.PATCH("/privacy", settingsHandler.UpdatePrivacySettings)
|
||||
settings.GET("/user", settingsHandler.GetUserSettings)
|
||||
settings.PATCH("/user", settingsHandler.UpdateUserSettings)
|
||||
}
|
||||
|
||||
users := authorized.Group("/users")
|
||||
{
|
||||
users.POST("/:id/follow", userHandler.Follow)
|
||||
users.DELETE("/:id/follow", userHandler.Unfollow)
|
||||
users.POST("/:id/accept", userHandler.AcceptFollowRequest)
|
||||
users.DELETE("/:id/reject", userHandler.RejectFollowRequest)
|
||||
users.GET("/requests", userHandler.GetPendingFollowRequests) // Or /me/requests
|
||||
users.GET("/:id/posts", postHandler.GetProfilePosts)
|
||||
// Interaction Lists
|
||||
users.GET("/me/saved", userHandler.GetSavedPosts)
|
||||
users.GET("/me/liked", userHandler.GetLikedPosts)
|
||||
}
|
||||
|
||||
authorized.POST("/posts", postHandler.CreatePost)
|
||||
authorized.GET("/posts/:id", postHandler.GetPost)
|
||||
authorized.GET("/posts/:id/chain", postHandler.GetPostChain)
|
||||
authorized.GET("/posts/:id/focus-context", postHandler.GetPostFocusContext)
|
||||
authorized.PATCH("/posts/:id", postHandler.UpdatePost)
|
||||
authorized.DELETE("/posts/:id", postHandler.DeletePost)
|
||||
authorized.POST("/posts/:id/pin", postHandler.PinPost)
|
||||
authorized.PATCH("/posts/:id/visibility", postHandler.UpdateVisibility)
|
||||
authorized.POST("/posts/:id/like", postHandler.LikePost)
|
||||
authorized.DELETE("/posts/:id/like", postHandler.UnlikePost)
|
||||
authorized.POST("/posts/:id/save", postHandler.SavePost)
|
||||
authorized.DELETE("/posts/:id/save", postHandler.UnsavePost)
|
||||
authorized.POST("/posts/:id/reactions/toggle", postHandler.ToggleReaction)
|
||||
authorized.POST("/posts/:id/comments", postHandler.CreateComment)
|
||||
authorized.GET("/feed", postHandler.GetFeed)
|
||||
authorized.GET("/beacons/nearby", postHandler.GetNearbyBeacons)
|
||||
authorized.GET("/categories", categoryHandler.GetCategories)
|
||||
authorized.POST("/categories/settings", categoryHandler.SetUserCategorySettings)
|
||||
authorized.GET("/categories/settings", categoryHandler.GetUserCategorySettings)
|
||||
authorized.POST("/analysis/tone", analysisHandler.CheckTone)
|
||||
|
||||
// Chat routes
|
||||
authorized.GET("/conversations", chatHandler.GetConversations)
|
||||
authorized.GET("/conversation", chatHandler.GetOrCreateConversation)
|
||||
authorized.POST("/messages", chatHandler.SendMessage)
|
||||
authorized.GET("/conversations/:id/messages", chatHandler.GetMessages)
|
||||
authorized.GET("/mutual-follows", chatHandler.GetMutualFollows)
|
||||
|
||||
// Key routes
|
||||
authorized.POST("/keys", keyHandler.PublishKeys)
|
||||
authorized.GET("/keys/:id", keyHandler.GetKeyBundle)
|
||||
|
||||
// Supabase Function Proxy
|
||||
authorized.Any("/functions/:name", functionProxyHandler.ProxyFunction)
|
||||
|
||||
// Media routes
|
||||
authorized.POST("/upload", mediaHandler.Upload)
|
||||
|
||||
// Search route
|
||||
searchHandler := handlers.NewSearchHandler(userRepo, postRepo, assetService)
|
||||
authorized.GET("/search", searchHandler.Search)
|
||||
|
||||
// Notifications
|
||||
notificationHandler := handlers.NewNotificationHandler(notifRepo)
|
||||
authorized.GET("/notifications", notificationHandler.GetNotifications)
|
||||
authorized.POST("/notifications/device", settingsHandler.RegisterDevice)
|
||||
authorized.DELETE("/notifications/device", settingsHandler.UnregisterDevice)
|
||||
}
|
||||
}
|
||||
|
||||
// Start server
|
||||
srv := &http.Server{
|
||||
Addr: "127.0.0.1:" + cfg.Port,
|
||||
Handler: r,
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatal().Err(err).Msg("Failed to start server")
|
||||
}
|
||||
}()
|
||||
|
||||
log.Info().Msgf("Server started on port %s", cfg.Port)
|
||||
|
||||
// Wait for interrupt signal to gracefully shutdown the server with
|
||||
// a timeout of 5 seconds.
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
log.Info().Msg("Shutting down server...")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
log.Fatal().Err(err).Msg("Server forced to shutdown")
|
||||
}
|
||||
|
||||
log.Info().Msg("Server exiting")
|
||||
}
|
||||
494
_tmp_server/post_handler.go
Normal file
|
|
@ -0,0 +1,494 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/patbritton/sojorn-backend/internal/models"
|
||||
"github.com/patbritton/sojorn-backend/internal/repository"
|
||||
"github.com/patbritton/sojorn-backend/internal/services"
|
||||
"github.com/patbritton/sojorn-backend/pkg/utils"
|
||||
)
|
||||
|
||||
type PostHandler struct {
|
||||
postRepo *repository.PostRepository
|
||||
userRepo *repository.UserRepository
|
||||
feedService *services.FeedService
|
||||
assetService *services.AssetService
|
||||
}
|
||||
|
||||
func NewPostHandler(postRepo *repository.PostRepository, userRepo *repository.UserRepository, feedService *services.FeedService, assetService *services.AssetService) *PostHandler {
|
||||
return &PostHandler{
|
||||
postRepo: postRepo,
|
||||
userRepo: userRepo,
|
||||
feedService: feedService,
|
||||
assetService: assetService,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PostHandler) CreateComment(c *gin.Context) {
|
||||
userIDStr, _ := c.Get("user_id")
|
||||
userID, _ := uuid.Parse(userIDStr.(string))
|
||||
postID := c.Param("id")
|
||||
|
||||
var req struct {
|
||||
Body string `json:"body" binding:"required,max=500"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
comment := &models.Comment{
|
||||
PostID: postID,
|
||||
AuthorID: userID,
|
||||
Body: req.Body,
|
||||
Status: "active",
|
||||
}
|
||||
|
||||
if err := h.postRepo.CreateComment(c.Request.Context(), comment); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create comment", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"comment": comment})
|
||||
}
|
||||
|
||||
func (h *PostHandler) GetNearbyBeacons(c *gin.Context) {
|
||||
lat := utils.GetQueryFloat(c, "lat", 0)
|
||||
long := utils.GetQueryFloat(c, "long", 0)
|
||||
radius := utils.GetQueryInt(c, "radius", 16000)
|
||||
|
||||
beacons, err := h.postRepo.GetNearbyBeacons(c.Request.Context(), lat, long, radius)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch nearby beacons", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"beacons": beacons})
|
||||
}
|
||||
|
||||
func (h *PostHandler) CreatePost(c *gin.Context) {
|
||||
userIDStr, _ := c.Get("user_id")
|
||||
userID, _ := uuid.Parse(userIDStr.(string))
|
||||
|
||||
var req struct {
|
||||
CategoryID *string `json:"category_id"`
|
||||
Body string `json:"body" binding:"required,max=500"`
|
||||
ImageURL *string `json:"image_url"`
|
||||
VideoURL *string `json:"video_url"`
|
||||
Thumbnail *string `json:"thumbnail_url"`
|
||||
DurationMS *int `json:"duration_ms"`
|
||||
IsBeacon bool `json:"is_beacon"`
|
||||
BeaconType *string `json:"beacon_type"`
|
||||
BeaconLat *float64 `json:"beacon_lat"`
|
||||
BeaconLong *float64 `json:"beacon_long"`
|
||||
TTLHours *int `json:"ttl_hours"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 1. Check rate limit (Simplification)
|
||||
trustState, err := h.userRepo.GetTrustState(c.Request.Context(), userID.String())
|
||||
if err == nil && trustState.PostsToday >= 50 { // Example hard limit
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"error": "Daily post limit reached"})
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Extract tags
|
||||
tags := utils.ExtractHashtags(req.Body)
|
||||
|
||||
// 3. Mock Tone Check (In production, this would call a service or AI model)
|
||||
tone := "neutral"
|
||||
cis := 0.8
|
||||
|
||||
// 4. Resolve TTL
|
||||
var expiresAt *time.Time
|
||||
if req.TTLHours != nil && *req.TTLHours > 0 {
|
||||
t := time.Now().Add(time.Duration(*req.TTLHours) * time.Hour)
|
||||
expiresAt = &t
|
||||
}
|
||||
|
||||
duration := 0
|
||||
if req.DurationMS != nil {
|
||||
duration = *req.DurationMS
|
||||
}
|
||||
|
||||
post := &models.Post{
|
||||
AuthorID: userID,
|
||||
Body: req.Body,
|
||||
Status: "active",
|
||||
ToneLabel: &tone,
|
||||
CISScore: &cis,
|
||||
ImageURL: req.ImageURL,
|
||||
VideoURL: req.VideoURL,
|
||||
ThumbnailURL: req.Thumbnail,
|
||||
DurationMS: duration,
|
||||
BodyFormat: "plain",
|
||||
Tags: tags,
|
||||
IsBeacon: req.IsBeacon,
|
||||
BeaconType: req.BeaconType,
|
||||
Confidence: 0.5, // Initial confidence
|
||||
IsActiveBeacon: req.IsBeacon,
|
||||
AllowChain: !req.IsBeacon,
|
||||
Visibility: "public",
|
||||
ExpiresAt: expiresAt,
|
||||
Lat: req.BeaconLat,
|
||||
Long: req.BeaconLong,
|
||||
}
|
||||
|
||||
if req.CategoryID != nil {
|
||||
catID, _ := uuid.Parse(*req.CategoryID)
|
||||
post.CategoryID = &catID
|
||||
}
|
||||
|
||||
// Create post
|
||||
err = h.postRepo.CreatePost(c.Request.Context(), post)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create post", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"post": post,
|
||||
"tags": tags,
|
||||
"tone_analysis": gin.H{
|
||||
"tone": tone,
|
||||
"cis": cis,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *PostHandler) GetFeed(c *gin.Context) {
|
||||
userIDStr, _ := c.Get("user_id")
|
||||
|
||||
limit := utils.GetQueryInt(c, "limit", 20)
|
||||
offset := utils.GetQueryInt(c, "offset", 0)
|
||||
category := c.Query("category")
|
||||
hasVideo := c.Query("has_video") == "true"
|
||||
|
||||
posts, err := h.feedService.GetFeed(c.Request.Context(), userIDStr.(string), category, hasVideo, limit, offset)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch feed", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"posts": posts})
|
||||
}
|
||||
|
||||
func (h *PostHandler) GetProfilePosts(c *gin.Context) {
|
||||
authorID := c.Param("id")
|
||||
if authorID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Author ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
limit := utils.GetQueryInt(c, "limit", 20)
|
||||
offset := utils.GetQueryInt(c, "offset", 0)
|
||||
|
||||
viewerID := ""
|
||||
if val, exists := c.Get("user_id"); exists {
|
||||
viewerID = val.(string)
|
||||
}
|
||||
|
||||
posts, err := h.postRepo.GetPostsByAuthor(c.Request.Context(), authorID, viewerID, limit, offset)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch profile posts", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"posts": posts})
|
||||
}
|
||||
|
||||
func (h *PostHandler) GetPost(c *gin.Context) {
|
||||
postID := c.Param("id")
|
||||
userIDStr, _ := c.Get("user_id")
|
||||
|
||||
post, err := h.postRepo.GetPostByID(c.Request.Context(), postID, userIDStr.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Post not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Sign URL
|
||||
if post.ImageURL != nil {
|
||||
signed := h.assetService.SignImageURL(*post.ImageURL)
|
||||
post.ImageURL = &signed
|
||||
}
|
||||
if post.VideoURL != nil {
|
||||
signed := h.assetService.SignVideoURL(*post.VideoURL)
|
||||
post.VideoURL = &signed
|
||||
}
|
||||
if post.ThumbnailURL != nil {
|
||||
signed := h.assetService.SignImageURL(*post.ThumbnailURL)
|
||||
post.ThumbnailURL = &signed
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"post": post})
|
||||
}
|
||||
|
||||
func (h *PostHandler) UpdatePost(c *gin.Context) {
|
||||
postID := c.Param("id")
|
||||
userIDStr, _ := c.Get("user_id")
|
||||
|
||||
var req struct {
|
||||
Body string `json:"body" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
err := h.postRepo.UpdatePost(c.Request.Context(), postID, userIDStr.(string), req.Body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update post", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Post updated"})
|
||||
}
|
||||
|
||||
func (h *PostHandler) DeletePost(c *gin.Context) {
|
||||
postID := c.Param("id")
|
||||
userIDStr, _ := c.Get("user_id")
|
||||
|
||||
err := h.postRepo.DeletePost(c.Request.Context(), postID, userIDStr.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete post", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Post deleted"})
|
||||
}
|
||||
|
||||
func (h *PostHandler) PinPost(c *gin.Context) {
|
||||
postID := c.Param("id")
|
||||
userIDStr, _ := c.Get("user_id")
|
||||
|
||||
var req struct {
|
||||
Pinned bool `json:"pinned"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
err := h.postRepo.PinPost(c.Request.Context(), postID, userIDStr.(string), req.Pinned)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to pin/unpin post", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Post pin status updated"})
|
||||
}
|
||||
|
||||
func (h *PostHandler) UpdateVisibility(c *gin.Context) {
|
||||
postID := c.Param("id")
|
||||
userIDStr, _ := c.Get("user_id")
|
||||
|
||||
var req struct {
|
||||
Visibility string `json:"visibility" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
err := h.postRepo.UpdateVisibility(c.Request.Context(), postID, userIDStr.(string), req.Visibility)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update visibility", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Post visibility updated"})
|
||||
}
|
||||
|
||||
func (h *PostHandler) LikePost(c *gin.Context) {
|
||||
postID := c.Param("id")
|
||||
userIDStr, _ := c.Get("user_id")
|
||||
|
||||
err := h.postRepo.LikePost(c.Request.Context(), postID, userIDStr.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to like post", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Post liked"})
|
||||
}
|
||||
|
||||
func (h *PostHandler) UnlikePost(c *gin.Context) {
|
||||
postID := c.Param("id")
|
||||
userIDStr, _ := c.Get("user_id")
|
||||
|
||||
err := h.postRepo.UnlikePost(c.Request.Context(), postID, userIDStr.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to unlike post", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Post unliked"})
|
||||
}
|
||||
|
||||
func (h *PostHandler) SavePost(c *gin.Context) {
|
||||
postID := c.Param("id")
|
||||
userIDStr, _ := c.Get("user_id")
|
||||
|
||||
err := h.postRepo.SavePost(c.Request.Context(), postID, userIDStr.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save post", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Post saved"})
|
||||
}
|
||||
|
||||
func (h *PostHandler) UnsavePost(c *gin.Context) {
|
||||
postID := c.Param("id")
|
||||
userIDStr, _ := c.Get("user_id")
|
||||
|
||||
err := h.postRepo.UnsavePost(c.Request.Context(), postID, userIDStr.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to unsave post", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Post unsaved"})
|
||||
}
|
||||
|
||||
func (h *PostHandler) ToggleReaction(c *gin.Context) {
|
||||
postID := c.Param("id")
|
||||
userIDStr, _ := c.Get("user_id")
|
||||
|
||||
var req struct {
|
||||
Emoji string `json:"emoji" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
emoji := strings.TrimSpace(req.Emoji)
|
||||
if emoji == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Emoji is required"})
|
||||
return
|
||||
}
|
||||
|
||||
counts, myReactions, err := h.postRepo.ToggleReaction(c.Request.Context(), postID, userIDStr.(string), emoji)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to toggle reaction", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"reactions": counts,
|
||||
"my_reactions": myReactions,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *PostHandler) GetSavedPosts(c *gin.Context) {
|
||||
userID := c.Param("id")
|
||||
if userID == "" || userID == "me" {
|
||||
userIDStr, exists := c.Get("user_id")
|
||||
if exists {
|
||||
userID = userIDStr.(string)
|
||||
}
|
||||
}
|
||||
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "User ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
limit := utils.GetQueryInt(c, "limit", 20)
|
||||
offset := utils.GetQueryInt(c, "offset", 0)
|
||||
|
||||
posts, err := h.postRepo.GetSavedPosts(c.Request.Context(), userID, limit, offset)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch saved posts", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"posts": posts})
|
||||
}
|
||||
|
||||
func (h *PostHandler) GetLikedPosts(c *gin.Context) {
|
||||
userID := c.Param("id")
|
||||
if userID == "" || userID == "me" {
|
||||
userIDStr, exists := c.Get("user_id")
|
||||
if exists {
|
||||
userID = userIDStr.(string)
|
||||
}
|
||||
}
|
||||
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "User ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
limit := utils.GetQueryInt(c, "limit", 20)
|
||||
offset := utils.GetQueryInt(c, "offset", 0)
|
||||
|
||||
posts, err := h.postRepo.GetLikedPosts(c.Request.Context(), userID, limit, offset)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch liked posts", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"posts": posts})
|
||||
}
|
||||
|
||||
func (h *PostHandler) GetPostChain(c *gin.Context) {
|
||||
postID := c.Param("id")
|
||||
|
||||
posts, err := h.postRepo.GetPostChain(c.Request.Context(), postID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch post chain", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"chain": posts})
|
||||
}
|
||||
|
||||
func (h *PostHandler) GetPostFocusContext(c *gin.Context) {
|
||||
postID := c.Param("id")
|
||||
userIDStr, _ := c.Get("user_id")
|
||||
|
||||
focusContext, err := h.postRepo.GetPostFocusContext(c.Request.Context(), postID, userIDStr.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch focus context", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
h.signPostMedia(focusContext.TargetPost)
|
||||
h.signPostMedia(focusContext.ParentPost)
|
||||
for i := range focusContext.Children {
|
||||
h.signPostMedia(&focusContext.Children[i])
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, focusContext)
|
||||
}
|
||||
|
||||
func (h *PostHandler) signPostMedia(post *models.Post) {
|
||||
if post == nil {
|
||||
return
|
||||
}
|
||||
if post.ImageURL != nil {
|
||||
signed := h.assetService.SignImageURL(*post.ImageURL)
|
||||
post.ImageURL = &signed
|
||||
}
|
||||
if post.VideoURL != nil {
|
||||
signed := h.assetService.SignVideoURL(*post.VideoURL)
|
||||
post.VideoURL = &signed
|
||||
}
|
||||
if post.ThumbnailURL != nil {
|
||||
signed := h.assetService.SignImageURL(*post.ThumbnailURL)
|
||||
post.ThumbnailURL = &signed
|
||||
}
|
||||
}
|
||||
912
_tmp_server/post_repository.go
Normal file
|
|
@ -0,0 +1,912 @@
|
|||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/patbritton/sojorn-backend/internal/models"
|
||||
)
|
||||
|
||||
type PostRepository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewPostRepository(pool *pgxpool.Pool) *PostRepository {
|
||||
return &PostRepository{pool: pool}
|
||||
}
|
||||
|
||||
func (r *PostRepository) CreatePost(ctx context.Context, post *models.Post) error {
|
||||
// Calculate confidence score if it's a beacon
|
||||
if post.IsBeacon {
|
||||
var harmonyScore int
|
||||
err := r.pool.QueryRow(ctx, "SELECT harmony_score FROM public.trust_state WHERE user_id = $1", post.AuthorID).Scan(&harmonyScore)
|
||||
if err == nil {
|
||||
// Logic: confidence = harmony_score / 100.0 (legacy parity)
|
||||
post.Confidence = float64(harmonyScore) / 100.0
|
||||
} else {
|
||||
post.Confidence = 0.5 // Default fallback
|
||||
}
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO public.posts (
|
||||
author_id, category_id, body, status, tone_label, cis_score,
|
||||
image_url, video_url, thumbnail_url, duration_ms, body_format, background_id, tags,
|
||||
is_beacon, beacon_type, location, confidence_score,
|
||||
is_active_beacon, allow_chain, chain_parent_id, visibility, expires_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
|
||||
$14, $15,
|
||||
CASE WHEN ($16::double precision) IS NOT NULL AND ($17::double precision) IS NOT NULL
|
||||
THEN ST_SetSRID(ST_MakePoint(($17::double precision), ($16::double precision)), 4326)::geography
|
||||
ELSE NULL END,
|
||||
$18, $19, $20, $21, $22, $23
|
||||
) RETURNING id, created_at
|
||||
`
|
||||
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
err = tx.QueryRow(ctx, query,
|
||||
post.AuthorID, post.CategoryID, post.Body, post.Status, post.ToneLabel, post.CISScore,
|
||||
post.ImageURL, post.VideoURL, post.ThumbnailURL, post.DurationMS, post.BodyFormat, post.BackgroundID, post.Tags,
|
||||
post.IsBeacon, post.BeaconType, post.Lat, post.Long, post.Confidence,
|
||||
post.IsActiveBeacon, post.AllowChain, post.ChainParentID, post.Visibility, post.ExpiresAt,
|
||||
).Scan(&post.ID, &post.CreatedAt)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create post: %w", err)
|
||||
}
|
||||
|
||||
// Initialize metrics
|
||||
if _, err := tx.Exec(ctx, "INSERT INTO public.post_metrics (post_id) VALUES ($1)", post.ID); err != nil {
|
||||
return fmt.Errorf("failed to initialize post metrics: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("failed to commit post transaction: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *PostRepository) GetRandomSponsoredPost(ctx context.Context, userID string) (*models.Post, error) {
|
||||
query := `
|
||||
SELECT
|
||||
p.id, p.author_id, p.category_id, p.body, COALESCE(p.image_url, ''), COALESCE(p.video_url, ''), COALESCE(p.thumbnail_url, ''), p.duration_ms, COALESCE(p.tags, ARRAY[]::text[]), p.created_at,
|
||||
pr.handle as author_handle, pr.display_name as author_display_name, COALESCE(pr.avatar_url, '') as author_avatar_url,
|
||||
COALESCE(m.like_count, 0) as like_count, COALESCE(m.comment_count, 0) as comment_count,
|
||||
FALSE as is_liked,
|
||||
sp.advertiser_name
|
||||
FROM public.sponsored_posts sp
|
||||
JOIN public.posts p ON sp.post_id = p.id
|
||||
JOIN public.profiles pr ON p.author_id = pr.id
|
||||
LEFT JOIN public.post_metrics m ON p.id = m.post_id
|
||||
WHERE p.deleted_at IS NULL AND p.status = 'active'
|
||||
AND (
|
||||
p.category_id IS NULL OR EXISTS (
|
||||
SELECT 1 FROM public.user_category_settings ucs
|
||||
WHERE ucs.user_id = $1 AND ucs.category_id = p.category_id AND ucs.enabled = true
|
||||
)
|
||||
)
|
||||
ORDER BY RANDOM()
|
||||
LIMIT 1
|
||||
`
|
||||
var p models.Post
|
||||
var advertiserName string
|
||||
err := r.pool.QueryRow(ctx, query, userID).Scan(
|
||||
&p.ID, &p.AuthorID, &p.CategoryID, &p.Body, &p.ImageURL, &p.VideoURL, &p.ThumbnailURL, &p.DurationMS, &p.Tags, &p.CreatedAt,
|
||||
&p.AuthorHandle, &p.AuthorDisplayName, &p.AuthorAvatarURL,
|
||||
&p.LikeCount, &p.CommentCount, &p.IsLiked,
|
||||
&advertiserName,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.Author = &models.AuthorProfile{
|
||||
ID: p.AuthorID,
|
||||
Handle: p.AuthorHandle,
|
||||
DisplayName: advertiserName, // Display advertiser name for ads
|
||||
AvatarURL: p.AuthorAvatarURL,
|
||||
}
|
||||
p.IsSponsored = true
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func (r *PostRepository) GetFeed(ctx context.Context, userID string, categorySlug string, hasVideo bool, limit int, offset int) ([]models.Post, error) {
|
||||
query := `
|
||||
SELECT
|
||||
p.id,
|
||||
p.author_id,
|
||||
p.category_id,
|
||||
p.body,
|
||||
COALESCE(p.image_url, ''),
|
||||
CASE
|
||||
WHEN COALESCE(p.video_url, '') <> '' THEN p.video_url
|
||||
WHEN COALESCE(p.image_url, '') ILIKE '%.mp4' THEN p.image_url
|
||||
ELSE ''
|
||||
END AS resolved_video_url,
|
||||
COALESCE(NULLIF(p.thumbnail_url, ''), p.image_url, '') AS resolved_thumbnail_url,
|
||||
COALESCE(p.duration_ms, 0),
|
||||
COALESCE(p.tags, ARRAY[]::text[]),
|
||||
p.created_at,
|
||||
pr.handle as author_handle, pr.display_name as author_display_name, COALESCE(pr.avatar_url, '') as author_avatar_url,
|
||||
COALESCE(m.like_count, 0) as like_count, COALESCE(m.comment_count, 0) as comment_count,
|
||||
FALSE as is_liked
|
||||
FROM public.posts p
|
||||
JOIN public.profiles pr ON p.author_id = pr.id
|
||||
LEFT JOIN public.post_metrics m ON p.id = m.post_id
|
||||
LEFT JOIN public.categories c ON p.category_id = c.id
|
||||
WHERE p.deleted_at IS NULL
|
||||
AND (
|
||||
p.author_id = $4 -- My own posts
|
||||
OR pr.is_private = FALSE -- Public profiles
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM public.follows f
|
||||
WHERE f.follower_id = $4 AND f.following_id = p.author_id AND f.status = 'accepted'
|
||||
)
|
||||
)
|
||||
AND ($3 = FALSE OR (COALESCE(p.video_url, '') <> '' OR (COALESCE(p.image_url, '') ILIKE '%%.mp4')))
|
||||
ORDER BY p.created_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`
|
||||
rows, err := r.pool.Query(ctx, query, limit, offset, hasVideo, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
posts := []models.Post{}
|
||||
for rows.Next() {
|
||||
var p models.Post
|
||||
err := rows.Scan(
|
||||
&p.ID, &p.AuthorID, &p.CategoryID, &p.Body, &p.ImageURL, &p.VideoURL, &p.ThumbnailURL, &p.DurationMS, &p.Tags, &p.CreatedAt,
|
||||
&p.AuthorHandle, &p.AuthorDisplayName, &p.AuthorAvatarURL,
|
||||
&p.LikeCount, &p.CommentCount, &p.IsLiked,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.Author = &models.AuthorProfile{
|
||||
ID: p.AuthorID,
|
||||
Handle: p.AuthorHandle,
|
||||
DisplayName: p.AuthorDisplayName,
|
||||
AvatarURL: p.AuthorAvatarURL,
|
||||
}
|
||||
posts = append(posts, p)
|
||||
}
|
||||
return posts, nil
|
||||
}
|
||||
|
||||
func (r *PostRepository) GetCategories(ctx context.Context) ([]models.Category, error) {
|
||||
query := `SELECT id, slug, name, description, is_sensitive, created_at FROM public.categories ORDER BY name ASC`
|
||||
rows, err := r.pool.Query(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var categories []models.Category
|
||||
for rows.Next() {
|
||||
var c models.Category
|
||||
err := rows.Scan(&c.ID, &c.Slug, &c.Name, &c.Description, &c.IsSensitive, &c.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
categories = append(categories, c)
|
||||
}
|
||||
return categories, nil
|
||||
}
|
||||
|
||||
func (r *PostRepository) GetPostsByAuthor(ctx context.Context, authorID string, viewerID string, limit int, offset int) ([]models.Post, error) {
|
||||
query := `
|
||||
SELECT
|
||||
p.id,
|
||||
p.author_id,
|
||||
p.category_id,
|
||||
p.body,
|
||||
COALESCE(p.image_url, ''),
|
||||
CASE
|
||||
WHEN COALESCE(p.video_url, '') <> '' THEN p.video_url
|
||||
WHEN COALESCE(p.image_url, '') ILIKE '%.mp4' THEN p.image_url
|
||||
ELSE ''
|
||||
END AS resolved_video_url,
|
||||
COALESCE(NULLIF(p.thumbnail_url, ''), p.image_url, '') AS resolved_thumbnail_url,
|
||||
COALESCE(p.duration_ms, 0),
|
||||
COALESCE(p.tags, ARRAY[]::text[]),
|
||||
p.created_at,
|
||||
pr.handle as author_handle, pr.display_name as author_display_name, COALESCE(pr.avatar_url, '') as author_avatar_url,
|
||||
COALESCE(m.like_count, 0) as like_count, COALESCE(m.comment_count, 0) as comment_count,
|
||||
FALSE as is_liked
|
||||
FROM posts p
|
||||
JOIN profiles pr ON p.author_id = pr.id
|
||||
LEFT JOIN post_metrics m ON p.id = m.post_id
|
||||
WHERE p.author_id = $1 AND p.deleted_at IS NULL AND p.status = 'active'
|
||||
AND (
|
||||
p.author_id = $4 -- Viewer is author
|
||||
OR pr.is_private = FALSE -- Public profile
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM public.follows f
|
||||
WHERE f.follower_id = $4 AND f.following_id = p.author_id AND f.status = 'accepted'
|
||||
)
|
||||
)
|
||||
ORDER BY p.created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
`
|
||||
rows, err := r.pool.Query(ctx, query, authorID, limit, offset, viewerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var posts []models.Post
|
||||
for rows.Next() {
|
||||
var p models.Post
|
||||
err := rows.Scan(
|
||||
&p.ID, &p.AuthorID, &p.CategoryID, &p.Body, &p.ImageURL, &p.VideoURL, &p.ThumbnailURL, &p.DurationMS, &p.Tags, &p.CreatedAt,
|
||||
&p.AuthorHandle, &p.AuthorDisplayName, &p.AuthorAvatarURL,
|
||||
&p.LikeCount, &p.CommentCount, &p.IsLiked,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.Author = &models.AuthorProfile{
|
||||
ID: p.AuthorID,
|
||||
Handle: p.AuthorHandle,
|
||||
DisplayName: p.AuthorDisplayName,
|
||||
AvatarURL: p.AuthorAvatarURL,
|
||||
}
|
||||
posts = append(posts, p)
|
||||
}
|
||||
return posts, nil
|
||||
}
|
||||
|
||||
func (r *PostRepository) GetPostByID(ctx context.Context, postID string, userID string) (*models.Post, error) {
|
||||
query := `
|
||||
SELECT
|
||||
p.id,
|
||||
p.author_id,
|
||||
p.category_id,
|
||||
p.body,
|
||||
COALESCE(p.image_url, ''),
|
||||
CASE
|
||||
WHEN COALESCE(p.video_url, '') <> '' THEN p.video_url
|
||||
WHEN COALESCE(p.image_url, '') ILIKE '%.mp4' THEN p.image_url
|
||||
ELSE ''
|
||||
END AS resolved_video_url,
|
||||
COALESCE(NULLIF(p.thumbnail_url, ''), p.image_url, '') AS resolved_thumbnail_url,
|
||||
p.duration_ms,
|
||||
COALESCE(p.tags, ARRAY[]::text[]),
|
||||
p.created_at,
|
||||
p.chain_parent_id,
|
||||
pr.handle as author_handle, pr.display_name as author_display_name, COALESCE(pr.avatar_url, '') as author_avatar_url,
|
||||
COALESCE(m.like_count, 0) as like_count, COALESCE(m.comment_count, 0) as comment_count,
|
||||
CASE WHEN $2 != '' THEN EXISTS(SELECT 1 FROM public.post_likes WHERE post_id = p.id AND user_id = $2) ELSE FALSE END as is_liked,
|
||||
p.allow_chain, p.visibility
|
||||
FROM public.posts p
|
||||
JOIN public.profiles pr ON p.author_id = pr.id
|
||||
LEFT JOIN public.post_metrics m ON p.id = m.post_id
|
||||
WHERE p.id = $1 AND p.deleted_at IS NULL
|
||||
AND (
|
||||
p.author_id = $2
|
||||
OR pr.is_private = FALSE
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM public.follows f
|
||||
WHERE f.follower_id = $2 AND f.following_id = p.author_id AND f.status = 'accepted'
|
||||
)
|
||||
)
|
||||
`
|
||||
var p models.Post
|
||||
err := r.pool.QueryRow(ctx, query, postID, userID).Scan(
|
||||
&p.ID, &p.AuthorID, &p.CategoryID, &p.Body, &p.ImageURL, &p.VideoURL, &p.ThumbnailURL, &p.DurationMS, &p.Tags, &p.CreatedAt,
|
||||
&p.ChainParentID,
|
||||
&p.AuthorHandle, &p.AuthorDisplayName, &p.AuthorAvatarURL,
|
||||
&p.LikeCount, &p.CommentCount, &p.IsLiked,
|
||||
&p.AllowChain, &p.Visibility,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.Author = &models.AuthorProfile{
|
||||
ID: p.AuthorID,
|
||||
Handle: p.AuthorHandle,
|
||||
DisplayName: p.AuthorDisplayName,
|
||||
AvatarURL: p.AuthorAvatarURL,
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func (r *PostRepository) UpdatePost(ctx context.Context, postID string, authorID string, body string) error {
|
||||
query := `UPDATE public.posts SET body = $1, edited_at = NOW() WHERE id = $2 AND author_id = $3 AND deleted_at IS NULL`
|
||||
res, err := r.pool.Exec(ctx, query, body, postID, authorID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.RowsAffected() == 0 {
|
||||
return fmt.Errorf("post not found or unauthorized")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *PostRepository) DeletePost(ctx context.Context, postID string, authorID string) error {
|
||||
query := `UPDATE public.posts SET deleted_at = NOW() WHERE id = $1 AND author_id = $2 AND deleted_at IS NULL`
|
||||
res, err := r.pool.Exec(ctx, query, postID, authorID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.RowsAffected() == 0 {
|
||||
return fmt.Errorf("post not found or unauthorized")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *PostRepository) PinPost(ctx context.Context, postID string, authorID string, pinned bool) error {
|
||||
var val *time.Time
|
||||
if pinned {
|
||||
t := time.Now()
|
||||
val = &t
|
||||
}
|
||||
query := `UPDATE public.posts SET pinned_at = $1 WHERE id = $2 AND author_id = $3 AND deleted_at IS NULL`
|
||||
res, err := r.pool.Exec(ctx, query, val, postID, authorID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.RowsAffected() == 0 {
|
||||
return fmt.Errorf("post not found or unauthorized")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *PostRepository) UpdateVisibility(ctx context.Context, postID string, authorID string, visibility string) error {
|
||||
query := `UPDATE public.posts SET visibility = $1 WHERE id = $2 AND author_id = $3 AND deleted_at IS NULL`
|
||||
res, err := r.pool.Exec(ctx, query, visibility, postID, authorID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.RowsAffected() == 0 {
|
||||
return fmt.Errorf("post not found or unauthorized")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *PostRepository) LikePost(ctx context.Context, postID string, userID string) error {
|
||||
query := `INSERT INTO public.post_likes (post_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`
|
||||
_, err := r.pool.Exec(ctx, query, postID, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *PostRepository) UnlikePost(ctx context.Context, postID string, userID string) error {
|
||||
query := `DELETE FROM public.post_likes WHERE post_id = $1 AND user_id = $2`
|
||||
_, err := r.pool.Exec(ctx, query, postID, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *PostRepository) SavePost(ctx context.Context, postID string, userID string) error {
|
||||
query := `INSERT INTO public.post_saves (post_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`
|
||||
_, err := r.pool.Exec(ctx, query, postID, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *PostRepository) UnsavePost(ctx context.Context, postID string, userID string) error {
|
||||
query := `DELETE FROM public.post_saves WHERE post_id = $1 AND user_id = $2`
|
||||
_, err := r.pool.Exec(ctx, query, postID, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *PostRepository) CreateComment(ctx context.Context, comment *models.Comment) error {
|
||||
query := `
|
||||
INSERT INTO public.comments (post_id, author_id, body, status, created_at)
|
||||
VALUES ($1, $2, $3, $4, NOW())
|
||||
RETURNING id, created_at
|
||||
`
|
||||
err := r.pool.QueryRow(ctx, query, comment.PostID, comment.AuthorID, comment.Body, comment.Status).Scan(&comment.ID, &comment.CreatedAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Increment comment count in metrics
|
||||
_, _ = r.pool.Exec(ctx, "UPDATE public.post_metrics SET comment_count = comment_count + 1 WHERE post_id = $1", comment.PostID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *PostRepository) GetNearbyBeacons(ctx context.Context, lat float64, long float64, radius int) ([]models.Post, error) {
|
||||
query := `
|
||||
SELECT
|
||||
p.id, p.author_id, p.category_id, p.body, COALESCE(p.image_url, ''), p.tags, p.created_at,
|
||||
p.beacon_type, p.confidence_score, p.is_active_beacon,
|
||||
ST_Y(p.location::geometry) as lat, ST_X(p.location::geometry) as long,
|
||||
pr.handle as author_handle, pr.display_name as author_display_name, COALESCE(pr.avatar_url, '') as author_avatar_url
|
||||
FROM public.posts p
|
||||
JOIN public.profiles pr ON p.author_id = pr.id
|
||||
WHERE p.is_beacon = true
|
||||
AND ST_DWithin(p.location, ST_SetSRID(ST_Point($2, $1), 4326)::geography, $3)
|
||||
AND p.status = 'active'
|
||||
ORDER BY p.created_at DESC
|
||||
`
|
||||
rows, err := r.pool.Query(ctx, query, lat, long, radius)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var beacons []models.Post
|
||||
for rows.Next() {
|
||||
var p models.Post
|
||||
err := rows.Scan(
|
||||
&p.ID, &p.AuthorID, &p.CategoryID, &p.Body, &p.ImageURL, &p.Tags, &p.CreatedAt,
|
||||
&p.BeaconType, &p.Confidence, &p.IsActiveBeacon, &p.Lat, &p.Long,
|
||||
&p.AuthorHandle, &p.AuthorDisplayName, &p.AuthorAvatarURL,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.Author = &models.AuthorProfile{
|
||||
ID: p.AuthorID,
|
||||
Handle: p.AuthorHandle,
|
||||
DisplayName: p.AuthorDisplayName,
|
||||
AvatarURL: p.AuthorAvatarURL,
|
||||
}
|
||||
beacons = append(beacons, p)
|
||||
}
|
||||
return beacons, nil
|
||||
}
|
||||
|
||||
func (r *PostRepository) GetSavedPosts(ctx context.Context, userID string, limit int, offset int) ([]models.Post, error) {
|
||||
query := `
|
||||
SELECT
|
||||
p.id, p.author_id, p.category_id, p.body, p.image_url, p.tags, p.created_at,
|
||||
pr.handle as author_handle, pr.display_name as author_display_name, COALESCE(pr.avatar_url, '') as author_avatar_url,
|
||||
COALESCE(m.like_count, 0) as like_count, COALESCE(m.comment_count, 0) as comment_count,
|
||||
EXISTS(SELECT 1 FROM public.post_likes WHERE post_id = p.id AND user_id = $1) as is_liked
|
||||
FROM public.post_saves ps
|
||||
JOIN public.posts p ON ps.post_id = p.id
|
||||
JOIN public.profiles pr ON p.author_id = pr.id
|
||||
LEFT JOIN public.post_metrics m ON p.id = m.post_id
|
||||
WHERE ps.user_id = $1 AND p.deleted_at IS NULL
|
||||
ORDER BY ps.created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
`
|
||||
rows, err := r.pool.Query(ctx, query, userID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var posts []models.Post
|
||||
for rows.Next() {
|
||||
var p models.Post
|
||||
err := rows.Scan(
|
||||
&p.ID, &p.AuthorID, &p.CategoryID, &p.Body, &p.ImageURL, &p.Tags, &p.CreatedAt,
|
||||
&p.AuthorHandle, &p.AuthorDisplayName, &p.AuthorAvatarURL,
|
||||
&p.LikeCount, &p.CommentCount, &p.IsLiked,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.Author = &models.AuthorProfile{
|
||||
ID: p.AuthorID,
|
||||
Handle: p.AuthorHandle,
|
||||
DisplayName: p.AuthorDisplayName,
|
||||
AvatarURL: p.AuthorAvatarURL,
|
||||
}
|
||||
posts = append(posts, p)
|
||||
}
|
||||
return posts, nil
|
||||
}
|
||||
|
||||
func (r *PostRepository) GetLikedPosts(ctx context.Context, userID string, limit int, offset int) ([]models.Post, error) {
|
||||
query := `
|
||||
SELECT
|
||||
p.id, p.author_id, p.category_id, p.body, p.image_url, p.tags, p.created_at,
|
||||
pr.handle as author_handle, pr.display_name as author_display_name, COALESCE(pr.avatar_url, '') as author_avatar_url,
|
||||
COALESCE(m.like_count, 0) as like_count, COALESCE(m.comment_count, 0) as comment_count,
|
||||
TRUE as is_liked
|
||||
FROM public.post_likes pl
|
||||
JOIN public.posts p ON pl.post_id = p.id
|
||||
JOIN public.profiles pr ON p.author_id = pr.id
|
||||
LEFT JOIN public.post_metrics m ON p.id = m.post_id
|
||||
WHERE pl.user_id = $1 AND p.deleted_at IS NULL
|
||||
ORDER BY pl.created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
`
|
||||
rows, err := r.pool.Query(ctx, query, userID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var posts []models.Post
|
||||
for rows.Next() {
|
||||
var p models.Post
|
||||
err := rows.Scan(
|
||||
&p.ID, &p.AuthorID, &p.CategoryID, &p.Body, &p.ImageURL, &p.Tags, &p.CreatedAt,
|
||||
&p.AuthorHandle, &p.AuthorDisplayName, &p.AuthorAvatarURL,
|
||||
&p.LikeCount, &p.CommentCount, &p.IsLiked,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.Author = &models.AuthorProfile{
|
||||
ID: p.AuthorID,
|
||||
Handle: p.AuthorHandle,
|
||||
DisplayName: p.AuthorDisplayName,
|
||||
AvatarURL: p.AuthorAvatarURL,
|
||||
}
|
||||
posts = append(posts, p)
|
||||
}
|
||||
return posts, nil
|
||||
}
|
||||
|
||||
func (r *PostRepository) GetPostChain(ctx context.Context, rootID string) ([]models.Post, error) {
|
||||
// Recursive CTE to get the chain
|
||||
query := `
|
||||
WITH RECURSIVE object_chain AS (
|
||||
-- Anchor member: select the root post
|
||||
SELECT
|
||||
p.id, p.author_id, p.category_id, p.body, p.image_url, p.tags, p.created_at, p.chain_parent_id,
|
||||
pr.handle as author_handle, pr.display_name as author_display_name, COALESCE(pr.avatar_url, '') as author_avatar_url,
|
||||
COALESCE(m.like_count, 0) as like_count, COALESCE(m.comment_count, 0) as comment_count,
|
||||
1 as level
|
||||
FROM public.posts p
|
||||
JOIN public.profiles pr ON p.author_id = pr.id
|
||||
LEFT JOIN public.post_metrics m ON p.id = m.post_id
|
||||
WHERE p.id = $1 AND p.deleted_at IS NULL
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Recursive member: select children
|
||||
SELECT
|
||||
p.id, p.author_id, p.category_id, p.body, p.image_url, p.tags, p.created_at, p.chain_parent_id,
|
||||
pr.handle as author_handle, pr.display_name as author_display_name, COALESCE(pr.avatar_url, '') as author_avatar_url,
|
||||
COALESCE(m.like_count, 0) as like_count, COALESCE(m.comment_count, 0) as comment_count,
|
||||
oc.level + 1
|
||||
FROM public.posts p
|
||||
JOIN public.profiles pr ON p.author_id = pr.id
|
||||
LEFT JOIN public.post_metrics m ON p.id = m.post_id
|
||||
JOIN object_chain oc ON p.chain_parent_id = oc.id
|
||||
WHERE p.deleted_at IS NULL
|
||||
)
|
||||
SELECT
|
||||
id, author_id, category_id, body, image_url, tags, created_at,
|
||||
author_handle, author_display_name, author_avatar_url,
|
||||
like_count, comment_count
|
||||
FROM object_chain
|
||||
ORDER BY level ASC, created_at ASC;
|
||||
`
|
||||
rows, err := r.pool.Query(ctx, query, rootID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var posts []models.Post
|
||||
for rows.Next() {
|
||||
var p models.Post
|
||||
err := rows.Scan(
|
||||
&p.ID, &p.AuthorID, &p.CategoryID, &p.Body, &p.ImageURL, &p.Tags, &p.CreatedAt,
|
||||
&p.AuthorHandle, &p.AuthorDisplayName, &p.AuthorAvatarURL,
|
||||
&p.LikeCount, &p.CommentCount,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.Author = &models.AuthorProfile{
|
||||
ID: p.AuthorID,
|
||||
Handle: p.AuthorHandle,
|
||||
DisplayName: p.AuthorDisplayName,
|
||||
AvatarURL: p.AuthorAvatarURL,
|
||||
}
|
||||
posts = append(posts, p)
|
||||
}
|
||||
return posts, nil
|
||||
}
|
||||
|
||||
func (r *PostRepository) SearchPosts(ctx context.Context, query string, viewerID string, limit int) ([]models.Post, error) {
|
||||
searchQuery := "%" + query + "%"
|
||||
sql := `
|
||||
SELECT
|
||||
p.id, p.author_id, p.category_id, p.body, COALESCE(p.image_url, ''), COALESCE(p.video_url, ''), COALESCE(p.thumbnail_url, ''), p.duration_ms, COALESCE(p.tags, ARRAY[]::text[]), p.created_at,
|
||||
pr.handle as author_handle, pr.display_name as author_display_name, COALESCE(pr.avatar_url, '') as author_avatar_url,
|
||||
COALESCE(m.like_count, 0) as like_count, COALESCE(m.comment_count, 0) as comment_count,
|
||||
FALSE as is_liked
|
||||
FROM public.posts p
|
||||
JOIN public.profiles pr ON p.author_id = pr.id
|
||||
LEFT JOIN public.post_metrics m ON p.id = m.post_id
|
||||
WHERE (p.body ILIKE $1 OR $2 = ANY(p.tags))
|
||||
AND p.deleted_at IS NULL AND p.status = 'active'
|
||||
AND (
|
||||
p.author_id = $4
|
||||
OR pr.is_private = FALSE
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM public.follows f
|
||||
WHERE f.follower_id = $4 AND f.following_id = p.author_id AND f.status = 'accepted'
|
||||
)
|
||||
)
|
||||
ORDER BY p.created_at DESC
|
||||
LIMIT $3
|
||||
`
|
||||
rows, err := r.pool.Query(ctx, sql, searchQuery, query, limit, viewerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var posts []models.Post
|
||||
for rows.Next() {
|
||||
var p models.Post
|
||||
err := rows.Scan(
|
||||
&p.ID, &p.AuthorID, &p.CategoryID, &p.Body, &p.ImageURL, &p.VideoURL, &p.ThumbnailURL, &p.DurationMS, &p.Tags, &p.CreatedAt,
|
||||
&p.AuthorHandle, &p.AuthorDisplayName, &p.AuthorAvatarURL,
|
||||
&p.LikeCount, &p.CommentCount, &p.IsLiked,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.Author = &models.AuthorProfile{
|
||||
ID: p.AuthorID,
|
||||
Handle: p.AuthorHandle,
|
||||
DisplayName: p.AuthorDisplayName,
|
||||
AvatarURL: p.AuthorAvatarURL,
|
||||
}
|
||||
posts = append(posts, p)
|
||||
}
|
||||
return posts, nil
|
||||
}
|
||||
|
||||
func (r *PostRepository) SearchTags(ctx context.Context, query string, limit int) ([]models.TagResult, error) {
|
||||
searchQuery := "%" + query + "%"
|
||||
sql := `
|
||||
SELECT tag, COUNT(*) as count
|
||||
FROM (
|
||||
SELECT unnest(tags) as tag FROM public.posts WHERE deleted_at IS NULL AND status = 'active'
|
||||
) t
|
||||
WHERE tag ILIKE $1
|
||||
GROUP BY tag
|
||||
ORDER BY count DESC
|
||||
LIMIT $2
|
||||
`
|
||||
rows, err := r.pool.Query(ctx, sql, searchQuery, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var tags []models.TagResult
|
||||
for rows.Next() {
|
||||
var t models.TagResult
|
||||
if err := rows.Scan(&t.Tag, &t.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tags = append(tags, t)
|
||||
}
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
// GetPostFocusContext retrieves minimal data for Focus-Context view
|
||||
// Returns: Target Post, Direct Parent (if any), and Direct Children (1st layer only)
|
||||
func (r *PostRepository) GetPostFocusContext(ctx context.Context, postID string, userID string) (*models.FocusContext, error) {
|
||||
// Get target post
|
||||
targetPost, err := r.GetPostByID(ctx, postID, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get target post: %w", err)
|
||||
}
|
||||
|
||||
var parentPost *models.Post
|
||||
var children []models.Post
|
||||
var parentChildren []models.Post
|
||||
|
||||
// Get parent post if chain_parent_id exists
|
||||
if targetPost.ChainParentID != nil {
|
||||
parentPost, err = r.GetPostByID(ctx, targetPost.ChainParentID.String(), userID)
|
||||
if err != nil {
|
||||
// Parent might not exist or be inaccessible - continue without it
|
||||
parentPost = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Get direct children (1st layer replies only)
|
||||
childrenQuery := `
|
||||
SELECT
|
||||
p.id,
|
||||
p.author_id,
|
||||
p.category_id,
|
||||
p.body,
|
||||
COALESCE(p.image_url, ''),
|
||||
CASE
|
||||
WHEN COALESCE(p.video_url, '') <> '' THEN p.video_url
|
||||
WHEN COALESCE(p.image_url, '') ILIKE '%.mp4' THEN p.image_url
|
||||
ELSE ''
|
||||
END AS resolved_video_url,
|
||||
COALESCE(NULLIF(p.thumbnail_url, ''), p.image_url, '') AS resolved_thumbnail_url,
|
||||
p.duration_ms,
|
||||
COALESCE(p.tags, ARRAY[]::text[]),
|
||||
p.created_at,
|
||||
pr.handle as author_handle, pr.display_name as author_display_name, COALESCE(pr.avatar_url, '') as author_avatar_url,
|
||||
COALESCE(m.like_count, 0) as like_count, COALESCE(m.comment_count, 0) as comment_count,
|
||||
CASE WHEN $2 != '' THEN EXISTS(SELECT 1 FROM public.post_likes WHERE post_id = p.id AND user_id = $2) ELSE FALSE END as is_liked,
|
||||
p.allow_chain, p.visibility
|
||||
FROM public.posts p
|
||||
JOIN public.profiles pr ON p.author_id = pr.id
|
||||
LEFT JOIN public.post_metrics m ON p.id = m.post_id
|
||||
WHERE p.chain_parent_id = $1 AND p.deleted_at IS NULL AND p.status = 'active'
|
||||
AND (
|
||||
p.author_id = $2
|
||||
OR pr.is_private = FALSE
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM public.follows f
|
||||
WHERE f.follower_id = $2 AND f.following_id = p.author_id AND f.status = 'accepted'
|
||||
)
|
||||
)
|
||||
ORDER BY p.created_at ASC
|
||||
`
|
||||
|
||||
rows, err := r.pool.Query(ctx, childrenQuery, postID, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get children posts: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var p models.Post
|
||||
err := rows.Scan(
|
||||
&p.ID, &p.AuthorID, &p.CategoryID, &p.Body, &p.ImageURL, &p.VideoURL, &p.ThumbnailURL, &p.DurationMS, &p.Tags, &p.CreatedAt,
|
||||
&p.AuthorHandle, &p.AuthorDisplayName, &p.AuthorAvatarURL,
|
||||
&p.LikeCount, &p.CommentCount, &p.IsLiked,
|
||||
&p.AllowChain, &p.Visibility,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan child post: %w", err)
|
||||
}
|
||||
p.Author = &models.AuthorProfile{
|
||||
ID: p.AuthorID,
|
||||
Handle: p.AuthorHandle,
|
||||
DisplayName: p.AuthorDisplayName,
|
||||
AvatarURL: p.AuthorAvatarURL,
|
||||
}
|
||||
children = append(children, p)
|
||||
}
|
||||
|
||||
// If we have a parent, fetch its direct children (siblings + current)
|
||||
if parentPost != nil {
|
||||
siblingRows, err := r.pool.Query(ctx, childrenQuery, parentPost.ID.String(), userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get parent children: %w", err)
|
||||
}
|
||||
defer siblingRows.Close()
|
||||
|
||||
for siblingRows.Next() {
|
||||
var p models.Post
|
||||
err := siblingRows.Scan(
|
||||
&p.ID, &p.AuthorID, &p.CategoryID, &p.Body, &p.ImageURL, &p.VideoURL, &p.ThumbnailURL, &p.DurationMS, &p.Tags, &p.CreatedAt,
|
||||
&p.AuthorHandle, &p.AuthorDisplayName, &p.AuthorAvatarURL,
|
||||
&p.LikeCount, &p.CommentCount, &p.IsLiked,
|
||||
&p.AllowChain, &p.Visibility,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan parent child post: %w", err)
|
||||
}
|
||||
p.Author = &models.AuthorProfile{
|
||||
ID: p.AuthorID,
|
||||
Handle: p.AuthorHandle,
|
||||
DisplayName: p.AuthorDisplayName,
|
||||
AvatarURL: p.AuthorAvatarURL,
|
||||
}
|
||||
parentChildren = append(parentChildren, p)
|
||||
}
|
||||
}
|
||||
|
||||
return &models.FocusContext{
|
||||
TargetPost: targetPost,
|
||||
ParentPost: parentPost,
|
||||
Children: children,
|
||||
ParentChildren: parentChildren,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *PostRepository) ToggleReaction(ctx context.Context, postID string, userID string, emoji string) (map[string]int, []string, error) {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to start transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var exists bool
|
||||
err = tx.QueryRow(
|
||||
ctx,
|
||||
`SELECT EXISTS(
|
||||
SELECT 1 FROM public.post_reactions
|
||||
WHERE post_id = $1 AND user_id = $2 AND emoji = $3
|
||||
)`,
|
||||
postID,
|
||||
userID,
|
||||
emoji,
|
||||
).Scan(&exists)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to check reaction: %w", err)
|
||||
}
|
||||
|
||||
if exists {
|
||||
if _, err := tx.Exec(
|
||||
ctx,
|
||||
`DELETE FROM public.post_reactions
|
||||
WHERE post_id = $1 AND user_id = $2 AND emoji = $3`,
|
||||
postID,
|
||||
userID,
|
||||
emoji,
|
||||
); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to remove reaction: %w", err)
|
||||
}
|
||||
} else {
|
||||
if _, err := tx.Exec(
|
||||
ctx,
|
||||
`INSERT INTO public.post_reactions (post_id, user_id, emoji)
|
||||
VALUES ($1, $2, $3)`,
|
||||
postID,
|
||||
userID,
|
||||
emoji,
|
||||
); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to add reaction: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := tx.Query(
|
||||
ctx,
|
||||
`SELECT emoji, COUNT(*) FROM public.post_reactions
|
||||
WHERE post_id = $1
|
||||
GROUP BY emoji`,
|
||||
postID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to load reaction counts: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
counts := make(map[string]int)
|
||||
for rows.Next() {
|
||||
var reaction string
|
||||
var count int
|
||||
if err := rows.Scan(&reaction, &count); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to scan reaction counts: %w", err)
|
||||
}
|
||||
counts[reaction] = count
|
||||
}
|
||||
if rows.Err() != nil {
|
||||
return nil, nil, fmt.Errorf("failed to iterate reaction counts: %w", rows.Err())
|
||||
}
|
||||
|
||||
userRows, err := tx.Query(
|
||||
ctx,
|
||||
`SELECT emoji FROM public.post_reactions
|
||||
WHERE post_id = $1 AND user_id = $2`,
|
||||
postID,
|
||||
userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to load user reactions: %w", err)
|
||||
}
|
||||
defer userRows.Close()
|
||||
|
||||
myReactions := []string{}
|
||||
for userRows.Next() {
|
||||
var reaction string
|
||||
if err := userRows.Scan(&reaction); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to scan user reactions: %w", err)
|
||||
}
|
||||
myReactions = append(myReactions, reaction)
|
||||
}
|
||||
if userRows.Err() != nil {
|
||||
return nil, nil, fmt.Errorf("failed to iterate user reactions: %w", userRows.Err())
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to commit reaction toggle: %w", err)
|
||||
}
|
||||
|
||||
return counts, myReactions, nil
|
||||
}
|
||||
|
|
@ -209,6 +209,7 @@ func main() {
|
|||
authorized.GET("/posts/:id", postHandler.GetPost)
|
||||
authorized.GET("/posts/:id/chain", postHandler.GetPostChain)
|
||||
authorized.GET("/posts/:id/thread", postHandler.GetPostChain)
|
||||
authorized.GET("/posts/:id/focus-context", postHandler.GetPostFocusContext)
|
||||
authorized.PATCH("/posts/:id", postHandler.UpdatePost)
|
||||
authorized.DELETE("/posts/:id", postHandler.DeletePost)
|
||||
authorized.POST("/posts/:id/pin", postHandler.PinPost)
|
||||
|
|
@ -217,6 +218,7 @@ func main() {
|
|||
authorized.DELETE("/posts/:id/like", postHandler.UnlikePost)
|
||||
authorized.POST("/posts/:id/save", postHandler.SavePost)
|
||||
authorized.DELETE("/posts/:id/save", postHandler.UnsavePost)
|
||||
authorized.POST("/posts/:id/reactions/toggle", postHandler.ToggleReaction)
|
||||
authorized.POST("/posts/:id/comments", postHandler.CreateComment)
|
||||
authorized.GET("/feed", postHandler.GetFeed)
|
||||
authorized.GET("/beacons/nearby", postHandler.GetNearbyBeacons)
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE IF EXISTS post_reactions;
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
CREATE TABLE IF NOT EXISTS post_reactions (
|
||||
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||
emoji TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (user_id, post_id, emoji)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_post_reactions_post_id ON post_reactions(post_id);
|
||||
|
|
@ -96,6 +96,16 @@ CREATE TABLE IF NOT EXISTS public.post_saves (
|
|||
PRIMARY KEY (post_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.post_reactions (
|
||||
post_id UUID REFERENCES public.posts(id) ON DELETE CASCADE,
|
||||
user_id UUID REFERENCES public.profiles(id) ON DELETE CASCADE,
|
||||
emoji TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
PRIMARY KEY (post_id, user_id, emoji)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_post_reactions_post_id ON public.post_reactions(post_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.follows (
|
||||
follower_id UUID REFERENCES public.profiles(id) ON DELETE CASCADE,
|
||||
following_id UUID REFERENCES public.profiles(id) ON DELETE CASCADE,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package handlers
|
|||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
|
@ -56,18 +57,18 @@ func (h *PostHandler) CreateComment(c *gin.Context) {
|
|||
cis := 0.8
|
||||
|
||||
post := &models.Post{
|
||||
AuthorID: userID,
|
||||
Body: req.Body,
|
||||
Status: "active",
|
||||
ToneLabel: &tone,
|
||||
CISScore: &cis,
|
||||
BodyFormat: "plain",
|
||||
Tags: tags,
|
||||
IsBeacon: false,
|
||||
IsActiveBeacon:false,
|
||||
AllowChain: true,
|
||||
Visibility: "public",
|
||||
ChainParentID: &parentUUID,
|
||||
AuthorID: userID,
|
||||
Body: req.Body,
|
||||
Status: "active",
|
||||
ToneLabel: &tone,
|
||||
CISScore: &cis,
|
||||
BodyFormat: "plain",
|
||||
Tags: tags,
|
||||
IsBeacon: false,
|
||||
IsActiveBeacon: false,
|
||||
AllowChain: true,
|
||||
Visibility: "public",
|
||||
ChainParentID: &parentUUID,
|
||||
}
|
||||
|
||||
if err := h.postRepo.CreatePost(c.Request.Context(), post); err != nil {
|
||||
|
|
@ -138,19 +139,19 @@ func (h *PostHandler) CreatePost(c *gin.Context) {
|
|||
userID, _ := uuid.Parse(userIDStr.(string))
|
||||
|
||||
var req struct {
|
||||
CategoryID *string `json:"category_id"`
|
||||
Body string `json:"body" binding:"required,max=500"`
|
||||
ImageURL *string `json:"image_url"`
|
||||
VideoURL *string `json:"video_url"`
|
||||
Thumbnail *string `json:"thumbnail_url"`
|
||||
DurationMS *int `json:"duration_ms"`
|
||||
AllowChain *bool `json:"allow_chain"`
|
||||
ChainParentID *string `json:"chain_parent_id"`
|
||||
IsBeacon bool `json:"is_beacon"`
|
||||
BeaconType *string `json:"beacon_type"`
|
||||
BeaconLat *float64 `json:"beacon_lat"`
|
||||
BeaconLong *float64 `json:"beacon_long"`
|
||||
TTLHours *int `json:"ttl_hours"`
|
||||
CategoryID *string `json:"category_id"`
|
||||
Body string `json:"body" binding:"required,max=500"`
|
||||
ImageURL *string `json:"image_url"`
|
||||
VideoURL *string `json:"video_url"`
|
||||
Thumbnail *string `json:"thumbnail_url"`
|
||||
DurationMS *int `json:"duration_ms"`
|
||||
AllowChain *bool `json:"allow_chain"`
|
||||
ChainParentID *string `json:"chain_parent_id"`
|
||||
IsBeacon bool `json:"is_beacon"`
|
||||
BeaconType *string `json:"beacon_type"`
|
||||
BeaconLat *float64 `json:"beacon_lat"`
|
||||
BeaconLong *float64 `json:"beacon_long"`
|
||||
TTLHours *int `json:"ttl_hours"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
|
|
@ -528,6 +529,43 @@ func (h *PostHandler) GetPostChain(c *gin.Context) {
|
|||
c.JSON(http.StatusOK, gin.H{"posts": posts})
|
||||
}
|
||||
|
||||
func (h *PostHandler) GetPostFocusContext(c *gin.Context) {
|
||||
postID := c.Param("id")
|
||||
userIDStr, _ := c.Get("user_id")
|
||||
|
||||
focusContext, err := h.postRepo.GetPostFocusContext(c.Request.Context(), postID, userIDStr.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch focus context", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
h.signPostMedia(focusContext.TargetPost)
|
||||
h.signPostMedia(focusContext.ParentPost)
|
||||
for i := range focusContext.Children {
|
||||
h.signPostMedia(&focusContext.Children[i])
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, focusContext)
|
||||
}
|
||||
|
||||
func (h *PostHandler) signPostMedia(post *models.Post) {
|
||||
if post == nil {
|
||||
return
|
||||
}
|
||||
if post.ImageURL != nil {
|
||||
signed := h.assetService.SignImageURL(*post.ImageURL)
|
||||
post.ImageURL = &signed
|
||||
}
|
||||
if post.VideoURL != nil {
|
||||
signed := h.assetService.SignVideoURL(*post.VideoURL)
|
||||
post.VideoURL = &signed
|
||||
}
|
||||
if post.ThumbnailURL != nil {
|
||||
signed := h.assetService.SignImageURL(*post.ThumbnailURL)
|
||||
post.ThumbnailURL = &signed
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PostHandler) VouchBeacon(c *gin.Context) {
|
||||
beaconID := c.Param("id")
|
||||
userIDStr, _ := c.Get("user_id")
|
||||
|
|
@ -618,3 +656,33 @@ func (h *PostHandler) RemoveBeaconVote(c *gin.Context) {
|
|||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Beacon vote removed successfully"})
|
||||
}
|
||||
|
||||
func (h *PostHandler) ToggleReaction(c *gin.Context) {
|
||||
postID := c.Param("id")
|
||||
userIDStr, _ := c.Get("user_id")
|
||||
|
||||
var req struct {
|
||||
Emoji string `json:"emoji" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
emoji := strings.TrimSpace(req.Emoji)
|
||||
if emoji == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Emoji is required"})
|
||||
return
|
||||
}
|
||||
|
||||
counts, myReactions, err := h.postRepo.ToggleReaction(c.Request.Context(), postID, userIDStr.(string), emoji)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to toggle reaction", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"reactions": counts,
|
||||
"my_reactions": myReactions,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,3 +88,11 @@ type TagResult struct {
|
|||
Tag string `json:"tag"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// FocusContext represents the minimal data needed for the Focus-Context view
|
||||
type FocusContext struct {
|
||||
TargetPost *Post `json:"target_post"`
|
||||
ParentPost *Post `json:"parent_post,omitempty"`
|
||||
Children []Post `json:"children"`
|
||||
ParentChildren []Post `json:"parent_children,omitempty"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ func (r *PGCategoryRepository) SetUserCategorySettings(ctx context.Context, user
|
|||
}
|
||||
|
||||
func (r *PGCategoryRepository) GetUserCategorySettings(ctx context.Context, userID string) ([]CategorySetting, error) {
|
||||
query := `SELECT category_id, enabled FROM public.user_category_settings WHERE user_id = $1`
|
||||
query := `SELECT category_id, enabled FROM public.user_category_settings WHERE user_id = $1::uuid`
|
||||
|
||||
rows, err := r.pool.Query(ctx, query, userID)
|
||||
if err != nil {
|
||||
|
|
@ -140,7 +140,7 @@ func (r *PGCategoryRepository) GetUserCategorySettings(ctx context.Context, user
|
|||
func (r *PGCategoryRepository) GetEnabledCategoryIDs(ctx context.Context, userID string) ([]string, error) {
|
||||
query := `
|
||||
SELECT category_id FROM public.user_category_settings
|
||||
WHERE user_id = $1 AND enabled = true
|
||||
WHERE user_id = $1::uuid AND enabled = true
|
||||
`
|
||||
rows, err := r.pool.Query(ctx, query, userID)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ func (r *NotificationRepository) GetFCMTokensForUser(ctx context.Context, userID
|
|||
query := `
|
||||
SELECT token
|
||||
FROM public.user_fcm_tokens
|
||||
WHERE user_id = $1
|
||||
WHERE user_id = $1::uuid
|
||||
`
|
||||
rows, err := r.pool.Query(ctx, query, userID)
|
||||
if err != nil {
|
||||
|
|
@ -62,7 +62,7 @@ func (r *NotificationRepository) GetFCMTokensForUser(ctx context.Context, userID
|
|||
func (r *NotificationRepository) DeleteFCMToken(ctx context.Context, userID string, token string) error {
|
||||
commandTag, err := r.pool.Exec(ctx, `
|
||||
DELETE FROM public.user_fcm_tokens
|
||||
WHERE user_id = $1 AND token = $2
|
||||
WHERE user_id = $1::uuid AND token = $2
|
||||
`, userID, token)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -82,7 +82,7 @@ func (r *NotificationRepository) GetNotifications(ctx context.Context, userID st
|
|||
FROM public.notifications n
|
||||
JOIN public.profiles pr ON n.actor_id = pr.id
|
||||
LEFT JOIN public.posts po ON n.post_id = po.id
|
||||
WHERE n.user_id = $1
|
||||
WHERE n.user_id = $1::uuid
|
||||
ORDER BY n.created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
`
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ func (r *PostRepository) CreatePost(ctx context.Context, post *models.Post) erro
|
|||
// Calculate confidence score if it's a beacon
|
||||
if post.IsBeacon {
|
||||
var harmonyScore int
|
||||
err := r.pool.QueryRow(ctx, "SELECT harmony_score FROM public.trust_state WHERE user_id = $1", post.AuthorID).Scan(&harmonyScore)
|
||||
err := r.pool.QueryRow(ctx, "SELECT harmony_score FROM public.trust_state WHERE user_id = $1::uuid", post.AuthorID).Scan(&harmonyScore)
|
||||
if err == nil {
|
||||
// Logic: confidence = harmony_score / 100.0 (legacy parity)
|
||||
post.Confidence = float64(harmonyScore) / 100.0
|
||||
|
|
@ -91,7 +91,7 @@ func (r *PostRepository) GetRandomSponsoredPost(ctx context.Context, userID stri
|
|||
AND (
|
||||
p.category_id IS NULL OR EXISTS (
|
||||
SELECT 1 FROM public.user_category_settings ucs
|
||||
WHERE ucs.user_id = NULLIF($1::text, '')::uuid AND ucs.category_id = p.category_id AND ucs.enabled = true
|
||||
WHERE ucs.user_id = CASE WHEN $1::text != '' THEN $1::text::uuid ELSE NULL END AND ucs.category_id = p.category_id AND ucs.enabled = true
|
||||
)
|
||||
)
|
||||
ORDER BY RANDOM()
|
||||
|
|
@ -134,7 +134,7 @@ func (r *PostRepository) GetFeed(ctx context.Context, userID string, categorySlu
|
|||
p.created_at,
|
||||
pr.handle as author_handle, pr.display_name as author_display_name, COALESCE(pr.avatar_url, '') as author_avatar_url,
|
||||
COALESCE(m.like_count, 0) as like_count, COALESCE(m.comment_count, 0) as comment_count,
|
||||
CASE WHEN ($4::text) != '' THEN EXISTS(SELECT 1 FROM public.post_likes WHERE post_id = p.id AND user_id = NULLIF($4::text, '')::uuid) ELSE FALSE END as is_liked,
|
||||
CASE WHEN ($4::text) != '' THEN EXISTS(SELECT 1 FROM public.post_likes WHERE post_id = p.id AND user_id = $4::text::uuid) ELSE FALSE END as is_liked,
|
||||
p.allow_chain, p.visibility
|
||||
FROM public.posts p
|
||||
JOIN public.profiles pr ON p.author_id = pr.id
|
||||
|
|
@ -142,11 +142,11 @@ func (r *PostRepository) GetFeed(ctx context.Context, userID string, categorySlu
|
|||
LEFT JOIN public.categories c ON p.category_id = c.id
|
||||
WHERE p.deleted_at IS NULL AND p.status = 'active'
|
||||
AND (
|
||||
p.author_id = NULLIF($4::text, '')::uuid -- My own posts
|
||||
p.author_id = CASE WHEN $4::text != '' THEN $4::text::uuid ELSE NULL END -- My own posts
|
||||
OR pr.is_private = FALSE -- Public profiles
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM public.follows f
|
||||
WHERE f.follower_id = NULLIF($4::text, '')::uuid AND f.following_id = p.author_id AND f.status = 'accepted'
|
||||
WHERE f.follower_id = CASE WHEN $4::text != '' THEN $4::text::uuid ELSE NULL END AND f.following_id = p.author_id AND f.status = 'accepted'
|
||||
)
|
||||
)
|
||||
AND ($3 = FALSE OR (COALESCE(p.video_url, '') <> '' OR (COALESCE(p.image_url, '') ILIKE '%.mp4')))
|
||||
|
|
@ -225,11 +225,11 @@ func (r *PostRepository) GetPostsByAuthor(ctx context.Context, authorID string,
|
|||
LEFT JOIN public.post_metrics m ON p.id = m.post_id
|
||||
WHERE p.author_id = $1::uuid AND p.deleted_at IS NULL AND p.status = 'active'
|
||||
AND (
|
||||
p.author_id = NULLIF($4, '')::uuid -- Viewer is author
|
||||
p.author_id = CASE WHEN $4 != '' THEN $4::uuid ELSE NULL END -- Viewer is author
|
||||
OR pr.is_private = FALSE -- Public profile
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM public.follows f
|
||||
WHERE f.follower_id = NULLIF($4, '')::uuid AND f.following_id = p.author_id AND f.status = 'accepted'
|
||||
WHERE f.follower_id = CASE WHEN $4 != '' THEN $4::uuid ELSE NULL END AND f.following_id = p.author_id AND f.status = 'accepted'
|
||||
)
|
||||
)
|
||||
ORDER BY p.created_at DESC
|
||||
|
|
@ -280,27 +280,31 @@ func (r *PostRepository) GetPostByID(ctx context.Context, postID string, userID
|
|||
p.duration_ms,
|
||||
COALESCE(p.tags, ARRAY[]::text[]),
|
||||
p.created_at,
|
||||
p.chain_parent_id,
|
||||
pr.handle as author_handle, pr.display_name as author_display_name, COALESCE(pr.avatar_url, '') as author_avatar_url,
|
||||
COALESCE(m.like_count, 0) as like_count, COALESCE(m.comment_count, 0) as comment_count,
|
||||
CASE WHEN $2 != '' THEN EXISTS(SELECT 1 FROM public.post_likes WHERE post_id = p.id AND user_id = $2::uuid) ELSE FALSE END as is_liked
|
||||
CASE WHEN $2 != '' THEN EXISTS(SELECT 1 FROM public.post_likes WHERE post_id = p.id AND user_id = $2::uuid) ELSE FALSE END as is_liked,
|
||||
p.allow_chain, p.visibility
|
||||
FROM public.posts p
|
||||
JOIN public.profiles pr ON p.author_id = pr.id
|
||||
LEFT JOIN public.post_metrics m ON p.id = m.post_id
|
||||
WHERE p.id = $1::uuid AND p.deleted_at IS NULL
|
||||
AND (
|
||||
p.author_id = NULLIF($2, '')::uuid
|
||||
p.author_id = CASE WHEN $2 != '' THEN $2::uuid ELSE NULL END
|
||||
OR pr.is_private = FALSE
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM public.follows f
|
||||
WHERE f.follower_id = NULLIF($2, '')::uuid AND f.following_id = p.author_id AND f.status = 'accepted'
|
||||
WHERE f.follower_id = CASE WHEN $2 != '' THEN $2::uuid ELSE NULL END AND f.following_id = p.author_id AND f.status = 'accepted'
|
||||
)
|
||||
)
|
||||
`
|
||||
var p models.Post
|
||||
err := r.pool.QueryRow(ctx, query, postID, userID).Scan(
|
||||
&p.ID, &p.AuthorID, &p.CategoryID, &p.Body, &p.ImageURL, &p.VideoURL, &p.ThumbnailURL, &p.DurationMS, &p.Tags, &p.CreatedAt,
|
||||
&p.ChainParentID,
|
||||
&p.AuthorHandle, &p.AuthorDisplayName, &p.AuthorAvatarURL,
|
||||
&p.LikeCount, &p.CommentCount, &p.IsLiked,
|
||||
&p.AllowChain, &p.Visibility,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -368,25 +372,63 @@ func (r *PostRepository) UpdateVisibility(ctx context.Context, postID string, au
|
|||
}
|
||||
|
||||
func (r *PostRepository) LikePost(ctx context.Context, postID string, userID string) error {
|
||||
query := `INSERT INTO public.post_likes (post_id, user_id) VALUES ($1::uuid, $2::uuid) ON CONFLICT DO NOTHING`
|
||||
query := `
|
||||
WITH inserted AS (
|
||||
INSERT INTO public.post_likes (post_id, user_id)
|
||||
VALUES ($1::uuid, $2::uuid)
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING 1
|
||||
)
|
||||
UPDATE public.post_metrics
|
||||
SET like_count = like_count + (SELECT COUNT(*) FROM inserted)
|
||||
WHERE post_id = $1::uuid
|
||||
`
|
||||
_, err := r.pool.Exec(ctx, query, postID, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *PostRepository) UnlikePost(ctx context.Context, postID string, userID string) error {
|
||||
query := `DELETE FROM public.post_likes WHERE post_id = $1::uuid AND user_id = $2::uuid`
|
||||
query := `
|
||||
WITH deleted AS (
|
||||
DELETE FROM public.post_likes
|
||||
WHERE post_id = $1::uuid AND user_id = $2::uuid
|
||||
RETURNING 1
|
||||
)
|
||||
UPDATE public.post_metrics
|
||||
SET like_count = GREATEST(like_count - (SELECT COUNT(*) FROM deleted), 0)
|
||||
WHERE post_id = $1::uuid
|
||||
`
|
||||
_, err := r.pool.Exec(ctx, query, postID, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *PostRepository) SavePost(ctx context.Context, postID string, userID string) error {
|
||||
query := `INSERT INTO public.post_saves (post_id, user_id) VALUES ($1::uuid, $2::uuid) ON CONFLICT DO NOTHING`
|
||||
query := `
|
||||
WITH inserted AS (
|
||||
INSERT INTO public.post_saves (post_id, user_id)
|
||||
VALUES ($1::uuid, $2::uuid)
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING 1
|
||||
)
|
||||
UPDATE public.post_metrics
|
||||
SET save_count = save_count + (SELECT COUNT(*) FROM inserted)
|
||||
WHERE post_id = $1::uuid
|
||||
`
|
||||
_, err := r.pool.Exec(ctx, query, postID, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *PostRepository) UnsavePost(ctx context.Context, postID string, userID string) error {
|
||||
query := `DELETE FROM public.post_saves WHERE post_id = $1::uuid AND user_id = $2::uuid`
|
||||
query := `
|
||||
WITH deleted AS (
|
||||
DELETE FROM public.post_saves
|
||||
WHERE post_id = $1::uuid AND user_id = $2::uuid
|
||||
RETURNING 1
|
||||
)
|
||||
UPDATE public.post_metrics
|
||||
SET save_count = GREATEST(save_count - (SELECT COUNT(*) FROM deleted), 0)
|
||||
WHERE post_id = $1::uuid
|
||||
`
|
||||
_, err := r.pool.Exec(ctx, query, postID, userID)
|
||||
return err
|
||||
}
|
||||
|
|
@ -462,12 +504,12 @@ func (r *PostRepository) GetSavedPosts(ctx context.Context, userID string, limit
|
|||
p.created_at,
|
||||
pr.handle as author_handle, pr.display_name as author_display_name, COALESCE(pr.avatar_url, '') as author_avatar_url,
|
||||
COALESCE(m.like_count, 0) as like_count, COALESCE(m.comment_count, 0) as comment_count,
|
||||
EXISTS(SELECT 1 FROM public.post_likes WHERE post_id = p.id AND user_id = $1) as is_liked
|
||||
EXISTS(SELECT 1 FROM public.post_likes WHERE post_id = p.id AND user_id = $1::uuid) as is_liked
|
||||
FROM public.post_saves ps
|
||||
JOIN public.posts p ON ps.post_id = p.id
|
||||
JOIN public.profiles pr ON p.author_id = pr.id
|
||||
LEFT JOIN public.post_metrics m ON p.id = m.post_id
|
||||
WHERE ps.user_id = $1 AND p.deleted_at IS NULL
|
||||
WHERE ps.user_id = $1::uuid AND p.deleted_at IS NULL
|
||||
ORDER BY ps.created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
`
|
||||
|
|
@ -516,7 +558,7 @@ func (r *PostRepository) GetLikedPosts(ctx context.Context, userID string, limit
|
|||
JOIN public.posts p ON pl.post_id = p.id
|
||||
JOIN public.profiles pr ON p.author_id = pr.id
|
||||
LEFT JOIN public.post_metrics m ON p.id = m.post_id
|
||||
WHERE pl.user_id = $1 AND p.deleted_at IS NULL
|
||||
WHERE pl.user_id = $1::uuid AND p.deleted_at IS NULL
|
||||
ORDER BY pl.created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
`
|
||||
|
|
@ -567,7 +609,7 @@ func (r *PostRepository) GetPostChain(ctx context.Context, rootID string) ([]mod
|
|||
FROM public.posts p
|
||||
JOIN public.profiles pr ON p.author_id = pr.id
|
||||
LEFT JOIN public.post_metrics m ON p.id = m.post_id
|
||||
WHERE p.id = $1 AND p.deleted_at IS NULL
|
||||
WHERE p.id = $1::uuid AND p.deleted_at IS NULL
|
||||
|
||||
UNION ALL
|
||||
|
||||
|
|
@ -676,11 +718,11 @@ func (r *PostRepository) SearchPosts(ctx context.Context, query string, viewerID
|
|||
WHERE (p.body ILIKE $1 OR $2 = ANY(p.tags))
|
||||
AND p.deleted_at IS NULL AND p.status = 'active'
|
||||
AND (
|
||||
p.author_id = NULLIF($4, '')::uuid
|
||||
p.author_id = CASE WHEN $4 != '' THEN $4::uuid ELSE NULL END
|
||||
OR pr.is_private = FALSE
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM public.follows f
|
||||
WHERE f.follower_id = NULLIF($4, '')::uuid AND f.following_id = p.author_id AND f.status = 'accepted'
|
||||
WHERE f.follower_id = CASE WHEN $4 != '' THEN $4::uuid ELSE NULL END AND f.following_id = p.author_id AND f.status = 'accepted'
|
||||
)
|
||||
)
|
||||
ORDER BY p.created_at DESC
|
||||
|
|
@ -857,3 +899,223 @@ func (r *PostRepository) RemoveBeaconVote(ctx context.Context, beaconID string,
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPostFocusContext retrieves minimal data for Focus-Context view
|
||||
// Returns: Target Post, Direct Parent (if any), and Direct Children (1st layer only)
|
||||
func (r *PostRepository) GetPostFocusContext(ctx context.Context, postID string, userID string) (*models.FocusContext, error) {
|
||||
// Get target post
|
||||
targetPost, err := r.GetPostByID(ctx, postID, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get target post: %w", err)
|
||||
}
|
||||
|
||||
var parentPost *models.Post
|
||||
var children []models.Post
|
||||
var parentChildren []models.Post
|
||||
|
||||
// Get parent post if chain_parent_id exists
|
||||
if targetPost.ChainParentID != nil {
|
||||
parentPost, err = r.GetPostByID(ctx, targetPost.ChainParentID.String(), userID)
|
||||
if err != nil {
|
||||
// Parent might not exist or be inaccessible - continue without it
|
||||
parentPost = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Get direct children (1st layer replies only)
|
||||
childrenQuery := `
|
||||
SELECT
|
||||
p.id, p.author_id, p.category_id, p.body,
|
||||
COALESCE(p.image_url, ''),
|
||||
CASE
|
||||
WHEN COALESCE(p.video_url, '') <> '' THEN p.video_url
|
||||
WHEN COALESCE(p.image_url, '') ILIKE '%.mp4' THEN p.image_url
|
||||
ELSE ''
|
||||
END AS resolved_video_url,
|
||||
COALESCE(NULLIF(p.thumbnail_url, ''), p.image_url, '') AS resolved_thumbnail_url,
|
||||
p.duration_ms,
|
||||
COALESCE(p.tags, ARRAY[]::text[]),
|
||||
p.created_at,
|
||||
pr.handle as author_handle, pr.display_name as author_display_name, COALESCE(pr.avatar_url, '') as author_avatar_url,
|
||||
COALESCE(m.like_count, 0) as like_count, COALESCE(m.comment_count, 0) as comment_count,
|
||||
CASE WHEN $2 != '' THEN EXISTS(SELECT 1 FROM public.post_likes WHERE post_id = p.id AND user_id = $2::uuid) ELSE FALSE END as is_liked,
|
||||
p.allow_chain, p.visibility
|
||||
FROM public.posts p
|
||||
JOIN public.profiles pr ON p.author_id = pr.id
|
||||
LEFT JOIN public.post_metrics m ON p.id = m.post_id
|
||||
WHERE p.chain_parent_id = $1::uuid AND p.deleted_at IS NULL AND p.status = 'active'
|
||||
AND (
|
||||
p.author_id = CASE WHEN $2 != '' THEN $2::uuid ELSE NULL END
|
||||
OR pr.is_private = FALSE
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM public.follows f
|
||||
WHERE f.follower_id = CASE WHEN $2 != '' THEN $2::uuid ELSE NULL END AND f.following_id = p.author_id AND f.status = 'accepted'
|
||||
)
|
||||
)
|
||||
ORDER BY p.created_at ASC
|
||||
`
|
||||
|
||||
rows, err := r.pool.Query(ctx, childrenQuery, postID, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get children posts: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var p models.Post
|
||||
err := rows.Scan(
|
||||
&p.ID, &p.AuthorID, &p.CategoryID, &p.Body, &p.ImageURL, &p.VideoURL, &p.ThumbnailURL, &p.DurationMS, &p.Tags, &p.CreatedAt,
|
||||
&p.AuthorHandle, &p.AuthorDisplayName, &p.AuthorAvatarURL,
|
||||
&p.LikeCount, &p.CommentCount, &p.IsLiked,
|
||||
&p.AllowChain, &p.Visibility,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan child post: %w", err)
|
||||
}
|
||||
p.Author = &models.AuthorProfile{
|
||||
ID: p.AuthorID,
|
||||
Handle: p.AuthorHandle,
|
||||
DisplayName: p.AuthorDisplayName,
|
||||
AvatarURL: p.AuthorAvatarURL,
|
||||
}
|
||||
children = append(children, p)
|
||||
}
|
||||
|
||||
// If we have a parent, fetch its direct children (siblings + current)
|
||||
if parentPost != nil {
|
||||
siblingRows, err := r.pool.Query(ctx, childrenQuery, parentPost.ID.String(), userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get parent children: %w", err)
|
||||
}
|
||||
defer siblingRows.Close()
|
||||
|
||||
for siblingRows.Next() {
|
||||
var p models.Post
|
||||
err := siblingRows.Scan(
|
||||
&p.ID, &p.AuthorID, &p.CategoryID, &p.Body, &p.ImageURL, &p.VideoURL, &p.ThumbnailURL, &p.DurationMS, &p.Tags, &p.CreatedAt,
|
||||
&p.AuthorHandle, &p.AuthorDisplayName, &p.AuthorAvatarURL,
|
||||
&p.LikeCount, &p.CommentCount, &p.IsLiked,
|
||||
&p.AllowChain, &p.Visibility,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan parent child post: %w", err)
|
||||
}
|
||||
p.Author = &models.AuthorProfile{
|
||||
ID: p.AuthorID,
|
||||
Handle: p.AuthorHandle,
|
||||
DisplayName: p.AuthorDisplayName,
|
||||
AvatarURL: p.AuthorAvatarURL,
|
||||
}
|
||||
parentChildren = append(parentChildren, p)
|
||||
}
|
||||
}
|
||||
|
||||
return &models.FocusContext{
|
||||
TargetPost: targetPost,
|
||||
ParentPost: parentPost,
|
||||
Children: children,
|
||||
ParentChildren: parentChildren,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *PostRepository) ToggleReaction(ctx context.Context, postID string, userID string, emoji string) (map[string]int, []string, error) {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to start transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var exists bool
|
||||
err = tx.QueryRow(
|
||||
ctx,
|
||||
`SELECT EXISTS(
|
||||
SELECT 1 FROM public.post_reactions
|
||||
WHERE post_id = $1::uuid AND user_id = $2::uuid AND emoji = $3
|
||||
)`,
|
||||
postID,
|
||||
userID,
|
||||
emoji,
|
||||
).Scan(&exists)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to check reaction: %w", err)
|
||||
}
|
||||
|
||||
if exists {
|
||||
if _, err := tx.Exec(
|
||||
ctx,
|
||||
`DELETE FROM public.post_reactions
|
||||
WHERE post_id = $1::uuid AND user_id = $2::uuid AND emoji = $3`,
|
||||
postID,
|
||||
userID,
|
||||
emoji,
|
||||
); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to remove reaction: %w", err)
|
||||
}
|
||||
} else {
|
||||
if _, err := tx.Exec(
|
||||
ctx,
|
||||
`INSERT INTO public.post_reactions (post_id, user_id, emoji)
|
||||
VALUES ($1::uuid, $2::uuid, $3)`,
|
||||
postID,
|
||||
userID,
|
||||
emoji,
|
||||
); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to add reaction: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := tx.Query(
|
||||
ctx,
|
||||
`SELECT emoji, COUNT(*) FROM public.post_reactions
|
||||
WHERE post_id = $1::uuid
|
||||
GROUP BY emoji`,
|
||||
postID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to load reaction counts: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
counts := make(map[string]int)
|
||||
for rows.Next() {
|
||||
var reaction string
|
||||
var count int
|
||||
if err := rows.Scan(&reaction, &count); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to scan reaction counts: %w", err)
|
||||
}
|
||||
counts[reaction] = count
|
||||
}
|
||||
if rows.Err() != nil {
|
||||
return nil, nil, fmt.Errorf("failed to iterate reaction counts: %w", rows.Err())
|
||||
}
|
||||
|
||||
userRows, err := tx.Query(
|
||||
ctx,
|
||||
`SELECT emoji FROM public.post_reactions
|
||||
WHERE post_id = $1::uuid AND user_id = $2::uuid`,
|
||||
postID,
|
||||
userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to load user reactions: %w", err)
|
||||
}
|
||||
defer userRows.Close()
|
||||
|
||||
myReactions := []string{}
|
||||
for userRows.Next() {
|
||||
var reaction string
|
||||
if err := userRows.Scan(&reaction); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to scan user reactions: %w", err)
|
||||
}
|
||||
myReactions = append(myReactions, reaction)
|
||||
}
|
||||
if userRows.Err() != nil {
|
||||
return nil, nil, fmt.Errorf("failed to iterate user reactions: %w", userRows.Err())
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to commit reaction toggle: %w", err)
|
||||
}
|
||||
|
||||
return counts, myReactions, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ func (r *UserRepository) UpdateProfile(ctx context.Context, profile *models.Prof
|
|||
is_private = COALESCE($12, is_private),
|
||||
is_official = COALESCE($13, is_official),
|
||||
updated_at = NOW()
|
||||
WHERE id = $14
|
||||
WHERE id = $14::uuid
|
||||
`
|
||||
_, err := r.pool.Exec(ctx, query,
|
||||
profile.Handle, profile.DisplayName, profile.Bio, profile.AvatarURL,
|
||||
|
|
@ -889,7 +889,7 @@ func (r *UserRepository) DeletePasswordResetToken(ctx context.Context, tokenHash
|
|||
}
|
||||
|
||||
func (r *UserRepository) UpdateUserPassword(ctx context.Context, userID string, passwordHash string) error {
|
||||
query := `UPDATE public.users SET password_hash = $1, updated_at = NOW() WHERE id = $2`
|
||||
query := `UPDATE public.users SET password_hash = $1, updated_at = NOW() WHERE id = $2::uuid`
|
||||
_, err := r.pool.Exec(ctx, query, passwordHash, userID)
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
BIN
go-backend/sojorn-api
Normal file
BIN
sojorn_app/assets/images/applogo.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
0
sojorn_app/assets/reactions/.gitkeep
Normal file
|
|
@ -69,6 +69,9 @@ class Post {
|
|||
final String? bodyFormat;
|
||||
final String? backgroundId;
|
||||
final List<String>? tags;
|
||||
final Map<String, int>? reactions;
|
||||
final List<String>? myReactions;
|
||||
final Map<String, List<String>>? reactionUsers;
|
||||
|
||||
final bool? isBeacon;
|
||||
final BeaconType? beaconType;
|
||||
|
|
@ -117,6 +120,9 @@ class Post {
|
|||
this.bodyFormat,
|
||||
this.backgroundId,
|
||||
this.tags,
|
||||
this.reactions,
|
||||
this.myReactions,
|
||||
this.reactionUsers,
|
||||
this.isBeacon = false,
|
||||
this.beaconType,
|
||||
this.confidenceScore,
|
||||
|
|
@ -152,6 +158,35 @@ class Post {
|
|||
return null;
|
||||
}
|
||||
|
||||
static Map<String, int>? _parseReactions(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is Map<String, dynamic>) {
|
||||
return value.map((key, val) => MapEntry(key, _parseInt(val) ?? 0));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<String>? _parseReactionsList(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is List) {
|
||||
return value.map((item) => item.toString()).toList();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Map<String, List<String>>? _parseReactionUsers(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is Map<String, dynamic>) {
|
||||
return value.map((key, val) {
|
||||
if (val is List) {
|
||||
return MapEntry(key, val.map((item) => item.toString()).toList());
|
||||
}
|
||||
return MapEntry(key, <String>[]);
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static double _defaultCis(String tone) {
|
||||
switch (tone) {
|
||||
case 'positive':
|
||||
|
|
@ -220,6 +255,12 @@ class Post {
|
|||
bodyFormat: json['body_format'] as String?,
|
||||
backgroundId: json['background_id'] as String?,
|
||||
tags: _parseTags(json['tags']),
|
||||
reactions: _parseReactions(
|
||||
json['reactions'] ?? json['reaction_counts'] ?? json['reaction_map']),
|
||||
myReactions: _parseReactionsList(
|
||||
json['my_reactions'] ?? json['myReactions']),
|
||||
reactionUsers: _parseReactionUsers(
|
||||
json['reaction_users'] ?? json['reaction_users_preview']),
|
||||
isBeacon: json['is_beacon'] as bool?,
|
||||
beaconType: json['beacon_type'] != null ? BeaconType.fromString(json['beacon_type'] as String) : null,
|
||||
confidenceScore: _parseDouble(json['confidence_score']),
|
||||
|
|
@ -277,6 +318,9 @@ class Post {
|
|||
'duration_ms': durationMs,
|
||||
'has_video_content': hasVideoContent,
|
||||
'tags': tags,
|
||||
'reactions': reactions,
|
||||
'my_reactions': myReactions,
|
||||
'reaction_users': reactionUsers,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -387,3 +431,40 @@ extension PostBeaconExtension on Post {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// FocusContext represents the minimal data needed for the Focus-Context view
|
||||
class FocusContext {
|
||||
final Post targetPost;
|
||||
final Post? parentPost;
|
||||
final List<Post> children;
|
||||
final List<Post> parentChildren;
|
||||
|
||||
const FocusContext({
|
||||
required this.targetPost,
|
||||
this.parentPost,
|
||||
required this.children,
|
||||
this.parentChildren = const [],
|
||||
});
|
||||
|
||||
factory FocusContext.fromJson(Map<String, dynamic> json) {
|
||||
return FocusContext(
|
||||
targetPost: Post.fromJson(json['target_post']),
|
||||
parentPost: json['parent_post'] != null ? Post.fromJson(json['parent_post']) : null,
|
||||
children: (json['children'] as List?)
|
||||
?.map((child) => Post.fromJson(child))
|
||||
.toList() ?? [],
|
||||
parentChildren: (json['parent_children'] as List?)
|
||||
?.map((child) => Post.fromJson(child))
|
||||
.toList() ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'target_post': targetPost.toJson(),
|
||||
'parent_post': parentPost?.toJson(),
|
||||
'children': children.map((child) => child.toJson()).toList(),
|
||||
'parent_children': parentChildren.map((child) => child.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
112
sojorn_app/lib/providers/header_state_provider.dart
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
enum HeaderMode { feed, context }
|
||||
|
||||
class HeaderState {
|
||||
final HeaderMode mode;
|
||||
final String title;
|
||||
final VoidCallback? onBack;
|
||||
final VoidCallback? onRefresh;
|
||||
final List<Widget> trailingActions;
|
||||
|
||||
const HeaderState({
|
||||
required this.mode,
|
||||
required this.title,
|
||||
this.onBack,
|
||||
this.onRefresh,
|
||||
this.trailingActions = const [],
|
||||
});
|
||||
|
||||
HeaderState copyWith({
|
||||
HeaderMode? mode,
|
||||
String? title,
|
||||
VoidCallback? onBack,
|
||||
VoidCallback? onRefresh,
|
||||
List<Widget>? trailingActions,
|
||||
}) {
|
||||
return HeaderState(
|
||||
mode: mode ?? this.mode,
|
||||
title: title ?? this.title,
|
||||
onBack: onBack ?? this.onBack,
|
||||
onRefresh: onRefresh ?? this.onRefresh,
|
||||
trailingActions: trailingActions ?? this.trailingActions,
|
||||
);
|
||||
}
|
||||
|
||||
factory HeaderState.feed({
|
||||
VoidCallback? onRefresh,
|
||||
List<Widget> trailingActions = const [],
|
||||
}) {
|
||||
return HeaderState(
|
||||
mode: HeaderMode.feed,
|
||||
title: 'sojorn',
|
||||
onRefresh: onRefresh,
|
||||
trailingActions: trailingActions,
|
||||
);
|
||||
}
|
||||
|
||||
factory HeaderState.context({
|
||||
required String title,
|
||||
VoidCallback? onBack,
|
||||
VoidCallback? onRefresh,
|
||||
List<Widget> trailingActions = const [],
|
||||
}) {
|
||||
return HeaderState(
|
||||
mode: HeaderMode.context,
|
||||
title: title,
|
||||
onBack: onBack,
|
||||
onRefresh: onRefresh,
|
||||
trailingActions: trailingActions,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class HeaderController extends ChangeNotifier {
|
||||
HeaderState _state = HeaderState.feed();
|
||||
VoidCallback? _feedRefresh;
|
||||
List<Widget> _feedTrailingActions = const [];
|
||||
|
||||
HeaderState get state => _state;
|
||||
|
||||
void configureFeed({
|
||||
VoidCallback? onRefresh,
|
||||
List<Widget> trailingActions = const [],
|
||||
}) {
|
||||
_feedRefresh = onRefresh;
|
||||
_feedTrailingActions = trailingActions;
|
||||
if (_state.mode == HeaderMode.feed) {
|
||||
_state = HeaderState.feed(
|
||||
onRefresh: _feedRefresh,
|
||||
trailingActions: _feedTrailingActions,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void setFeed() {
|
||||
_state = HeaderState.feed(
|
||||
onRefresh: _feedRefresh,
|
||||
trailingActions: _feedTrailingActions,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setContext({
|
||||
required String title,
|
||||
VoidCallback? onBack,
|
||||
VoidCallback? onRefresh,
|
||||
List<Widget> trailingActions = const [],
|
||||
}) {
|
||||
_state = HeaderState.context(
|
||||
title: title,
|
||||
onBack: onBack,
|
||||
onRefresh: onRefresh,
|
||||
trailingActions: trailingActions,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
final headerControllerProvider =
|
||||
ChangeNotifierProvider<HeaderController>((ref) => HeaderController());
|
||||
|
|
@ -16,7 +16,7 @@ import '../screens/quips/feed/quips_feed_screen.dart';
|
|||
import '../screens/profile/profile_screen.dart';
|
||||
import '../screens/profile/viewable_profile_screen.dart';
|
||||
import '../screens/auth/auth_gate.dart';
|
||||
import '../screens/secure_chat/secure_chat_list_screen.dart';
|
||||
import '../screens/secure_chat/secure_chat_full_screen.dart';
|
||||
|
||||
/// App routing config (GoRouter).
|
||||
class AppRoutes {
|
||||
|
|
@ -61,7 +61,7 @@ class AppRoutes {
|
|||
GoRoute(
|
||||
path: secureChat,
|
||||
parentNavigatorKey: rootNavigatorKey,
|
||||
builder: (_, __) => const SecureChatListScreen(),
|
||||
builder: (_, __) => const SecureChatFullScreen(),
|
||||
),
|
||||
StatefulShellRoute.indexedStack(
|
||||
builder: (context, state, navigationShell) => AuthGate(
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
|||
_handleHashtagSuggestions();
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_bodyFocusNode.requestFocus();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ class _FeedPersonalScreenState extends ConsumerState<FeedPersonalScreen> {
|
|||
}
|
||||
|
||||
void _openPostDetail(Post post) {
|
||||
Navigator.of(context).push(
|
||||
Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PostDetailScreen(post: post),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -5,13 +5,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||
import '../../providers/api_provider.dart';
|
||||
import '../../providers/feed_refresh_provider.dart';
|
||||
import '../../models/post.dart';
|
||||
import '../../theme/sojorn_feed_palette.dart';
|
||||
import '../../theme/theme_extensions.dart';
|
||||
import '../../theme/app_theme.dart';
|
||||
import '../../widgets/post/sojorn_swipeable_post.dart';
|
||||
import '../../widgets/post/sponsored_post_card.dart';
|
||||
import '../../services/ad_integration_service.dart';
|
||||
import '../post/post_detail_screen.dart';
|
||||
import '../compose/compose_screen.dart';
|
||||
import '../post/post_detail_screen.dart';
|
||||
import '../profile/viewable_profile_screen.dart';
|
||||
|
||||
/// sojorn feed - TikTok/Reels style immersive swipeable feed
|
||||
|
|
@ -120,7 +120,7 @@ class _FeedsojornScreenState extends ConsumerState<FeedsojornScreen> {
|
|||
}
|
||||
|
||||
void _openPostDetail(Post post) {
|
||||
Navigator.of(context).push(
|
||||
Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PostDetailScreen(post: post),
|
||||
),
|
||||
|
|
@ -405,7 +405,7 @@ class _SponsoredPostSlide extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = sojornFeedPalette.forId(post.id);
|
||||
final palette = Theme.of(context).extension<SojornExt>()!.feedPalettes.forId(post.id);
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
|
|||
],
|
||||
),
|
||||
floatingActionButton: Transform.translate(
|
||||
offset: const Offset(0, 8),
|
||||
offset: const Offset(0, 12),
|
||||
child: GestureDetector(
|
||||
onTap: () => setState(() => _isRadialMenuVisible = !_isRadialMenuVisible),
|
||||
child: Container(
|
||||
|
|
@ -115,48 +115,51 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
|
|||
),
|
||||
),
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
|
||||
bottomNavigationBar: BottomAppBar(
|
||||
notchMargin: 8.0,
|
||||
padding: EdgeInsets.zero,
|
||||
height: 58,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
shape: const CircularNotchedRectangle(),
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(16),
|
||||
topRight: Radius.circular(16),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_buildNavBarItem(
|
||||
icon: Icons.home_outlined,
|
||||
activeIcon: Icons.home,
|
||||
index: 0,
|
||||
label: 'Home',
|
||||
),
|
||||
_buildNavBarItem(
|
||||
icon: Icons.explore_outlined,
|
||||
activeIcon: Icons.explore,
|
||||
index: 1,
|
||||
label: 'Discover',
|
||||
),
|
||||
const SizedBox(width: 48),
|
||||
_buildNavBarItem(
|
||||
icon: Icons.play_circle_outline,
|
||||
activeIcon: Icons.play_circle,
|
||||
index: 2,
|
||||
label: 'Quips',
|
||||
),
|
||||
_buildNavBarItem(
|
||||
icon: Icons.person_outline,
|
||||
activeIcon: Icons.person,
|
||||
index: 3,
|
||||
label: 'Profile',
|
||||
),
|
||||
],
|
||||
bottomNavigationBar: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: BottomAppBar(
|
||||
notchMargin: 8.0,
|
||||
padding: EdgeInsets.zero,
|
||||
height: 58,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
shape: const CircularNotchedRectangle(),
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(16),
|
||||
topRight: Radius.circular(16),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_buildNavBarItem(
|
||||
icon: Icons.home_outlined,
|
||||
activeIcon: Icons.home,
|
||||
index: 0,
|
||||
label: 'Home',
|
||||
),
|
||||
_buildNavBarItem(
|
||||
icon: Icons.explore_outlined,
|
||||
activeIcon: Icons.explore,
|
||||
index: 1,
|
||||
label: 'Discover',
|
||||
),
|
||||
const SizedBox(width: 48),
|
||||
_buildNavBarItem(
|
||||
icon: Icons.play_circle_outline,
|
||||
activeIcon: Icons.play_circle,
|
||||
index: 2,
|
||||
label: 'Quips',
|
||||
),
|
||||
_buildNavBarItem(
|
||||
icon: Icons.person_outline,
|
||||
activeIcon: Icons.person,
|
||||
index: 3,
|
||||
label: 'Profile',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -198,12 +201,12 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
|
|||
icon: Icon(Icons.chat_bubble_outline, color: AppTheme.navyBlue),
|
||||
tooltip: 'Messages',
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const SecureChatFullScreen(),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const SecureChatFullScreen(),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ import '../../providers/api_provider.dart';
|
|||
import '../../theme/app_theme.dart';
|
||||
import '../../widgets/app_scaffold.dart';
|
||||
import '../../widgets/media/signed_media_image.dart';
|
||||
import '../post/post_detail_screen.dart';
|
||||
import '../profile/viewable_profile_screen.dart';
|
||||
import '../post/post_detail_screen.dart';
|
||||
|
||||
/// Notifications screen showing user activity
|
||||
class NotificationsScreen extends ConsumerStatefulWidget {
|
||||
|
|
@ -266,7 +266,7 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
|
|||
final post = await apiService.getPostById(notification.postId!);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).push(
|
||||
Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PostDetailScreen(post: post),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../../models/post.dart';
|
||||
import 'threaded_conversation_screen.dart';
|
||||
|
||||
/// Legacy alias for the Dynamic Block thread view.
|
||||
class FocusContextConversationScreen extends StatelessWidget {
|
||||
final String postId;
|
||||
final Post? initialPost;
|
||||
|
||||
const FocusContextConversationScreen({
|
||||
super.key,
|
||||
required this.postId,
|
||||
this.initialPost,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ThreadedConversationScreen(
|
||||
rootPostId: postId,
|
||||
rootPost: initialPost,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -475,7 +475,8 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen>
|
|||
final settings =
|
||||
_privacySettings ?? ProfilePrivacySettings.defaults(profile.id);
|
||||
|
||||
final result = await Navigator.of(context).push<ProfileSettingsResult>(
|
||||
final result = await Navigator.of(context, rootNavigator: true)
|
||||
.push<ProfileSettingsResult>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ProfileSettingsScreen(
|
||||
profile: profile,
|
||||
|
|
@ -733,7 +734,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen>
|
|||
}
|
||||
|
||||
void _openPostDetail(Post post) {
|
||||
Navigator.of(context).push(
|
||||
Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PostDetailScreen(post: post),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -156,7 +156,9 @@ class _ProfileSettingsScreenState extends ConsumerState<ProfileSettingsScreen> {
|
|||
setState(
|
||||
() => _errorMessage = e.toString().replaceAll('Exception: ', ''));
|
||||
} finally {
|
||||
if (mounted) setState(() => _isSaving = false);
|
||||
if (mounted) {
|
||||
setState(() => _isSaving = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import 'dart:async';
|
|||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../models/post.dart';
|
||||
import '../../models/profile.dart';
|
||||
import '../../models/profile_privacy_settings.dart';
|
||||
|
|
@ -15,12 +14,12 @@ import '../../utils/url_launcher_helper.dart';
|
|||
import '../../widgets/post_item.dart';
|
||||
import '../../widgets/media/signed_media_image.dart';
|
||||
import '../compose/compose_screen.dart';
|
||||
import '../post/post_detail_screen.dart';
|
||||
import '../secure_chat/secure_chat_modal_sheet.dart';
|
||||
import '../secure_chat/secure_chat_screen.dart';
|
||||
import '../../services/auth_service.dart';
|
||||
import '../../services/secure_chat_service.dart';
|
||||
import '../post/post_detail_screen.dart';
|
||||
import 'profile_settings_screen.dart';
|
||||
import '../../routes/app_routes.dart';
|
||||
import 'profile_screen.dart';
|
||||
|
||||
/// Screen for viewing another user's profile
|
||||
class ViewableProfileScreen extends ConsumerStatefulWidget {
|
||||
|
|
@ -290,7 +289,7 @@ class _ViewableProfileScreenState extends ConsumerState<ViewableProfileScreen>
|
|||
}
|
||||
|
||||
void _openPostDetail(Post post) {
|
||||
Navigator.of(context).push(
|
||||
Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PostDetailScreen(post: post),
|
||||
),
|
||||
|
|
@ -327,11 +326,10 @@ class _ViewableProfileScreenState extends ConsumerState<ViewableProfileScreen>
|
|||
return;
|
||||
}
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => SecureChatModal(conversation: conversation),
|
||||
Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => SecureChatScreen(conversation: conversation),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
if (!mounted) return;
|
||||
|
|
@ -351,7 +349,8 @@ class _ViewableProfileScreenState extends ConsumerState<ViewableProfileScreen>
|
|||
final settings =
|
||||
_privacySettings ?? ProfilePrivacySettings.defaults(profile.id);
|
||||
|
||||
final result = await Navigator.of(context).push<ProfileSettingsResult>(
|
||||
final result = await Navigator.of(context, rootNavigator: true)
|
||||
.push<ProfileSettingsResult>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ProfileSettingsScreen(
|
||||
profile: profile,
|
||||
|
|
@ -532,7 +531,11 @@ class _ViewableProfileScreenState extends ConsumerState<ViewableProfileScreen>
|
|||
if (navigator.canPop()) {
|
||||
navigator.pop();
|
||||
} else {
|
||||
context.go(AppRoutes.homeAlias);
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const ProfileScreen(),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -407,7 +407,7 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
|
|||
try {
|
||||
final post = await api.getPostById(quip.id);
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).push(
|
||||
Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PostDetailScreen(post: post),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -203,7 +203,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||
}
|
||||
|
||||
void _openPostDetail(Post post) {
|
||||
Navigator.of(context).push(
|
||||
Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PostDetailScreen(post: post),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,653 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:timeago/timeago.dart' as timeago;
|
||||
import '../../models/secure_chat.dart';
|
||||
import '../../services/secure_chat_service.dart';
|
||||
import '../../theme/app_theme.dart';
|
||||
import '../../widgets/media/signed_media_image.dart';
|
||||
import 'secure_chat_modal_sheet.dart';
|
||||
import 'new_conversation_sheet.dart';
|
||||
|
||||
/// List of secure E2EE conversations
|
||||
/// Unified chat list screen with AppTheme styling
|
||||
class SecureChatListScreen extends StatefulWidget {
|
||||
const SecureChatListScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SecureChatListScreen> createState() => _SecureChatListScreenState();
|
||||
}
|
||||
|
||||
class _SecureChatListScreenState extends State<SecureChatListScreen> {
|
||||
final SecureChatService _chatService = SecureChatService();
|
||||
|
||||
List<SecureConversation> _conversations = [];
|
||||
bool _isLoading = true;
|
||||
bool _isInitializing = false;
|
||||
String? _error;
|
||||
|
||||
late Stream<List<SecureConversation>> _conversationStream;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Create LIVE stream that automatically updates
|
||||
_conversationStream = Stream.periodic(const Duration(seconds: 2))
|
||||
.asyncMap((_) => _chatService.getConversations())
|
||||
.asBroadcastStream();
|
||||
|
||||
// Also trigger immediate reload on conversation changes
|
||||
_chatService.conversationListChanges.listen((_) {
|
||||
if (mounted) {
|
||||
print('[UI] Conversation list changed - stream will update');
|
||||
setState(() {}); // Force rebuild to get latest from stream
|
||||
}
|
||||
});
|
||||
|
||||
_initialize();
|
||||
}
|
||||
|
||||
Future<void> _initialize() async {
|
||||
setState(() => _isInitializing = true);
|
||||
|
||||
try {
|
||||
// Check if E2EE is ready
|
||||
final isReady = await _chatService.isReady();
|
||||
if (!isReady) {
|
||||
await _chatService.initialize();
|
||||
}
|
||||
await _loadConversations();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_error = 'Failed to initialize secure messaging';
|
||||
_isInitializing = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadConversations() async {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = true);
|
||||
}
|
||||
|
||||
try {
|
||||
final conversations = await _chatService.getConversations();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_conversations = conversations;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
print('[ChatList] Error loading conversations: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _uploadAllKeys() async {
|
||||
try {
|
||||
print('[ChatList] Manual key upload requested');
|
||||
await _chatService.uploadKeysManually();
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Keys uploaded to backend!'),
|
||||
backgroundColor: Colors.blue,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Error uploading keys: $e'),
|
||||
backgroundColor: AppTheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _startNewConversation() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => NewConversationSheet(
|
||||
onConversationStarted: (conversation) {
|
||||
Navigator.pop(context);
|
||||
_openConversation(conversation);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openConversation(SecureConversation conversation) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => SecureChatModal(conversation: conversation),
|
||||
).then((_) => _loadConversations());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.scaffoldBg,
|
||||
appBar: AppBar(
|
||||
backgroundColor: AppTheme.scaffoldBg,
|
||||
elevation: 0,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: Icon(Icons.arrow_back, color: AppTheme.navyBlue),
|
||||
),
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Messages',
|
||||
style: GoogleFonts.literata(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.navyBlue,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'End-to-end encrypted',
|
||||
style: GoogleFonts.inter(
|
||||
color: AppTheme.textDisabled,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: TextButton(
|
||||
onPressed: _uploadAllKeys,
|
||||
child: Text(
|
||||
'🔑 UPLOAD KEYS',
|
||||
style: GoogleFonts.inter(
|
||||
color: Colors.blue,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _loadConversations,
|
||||
icon: Icon(Icons.refresh, color: AppTheme.navyBlue),
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _startNewConversation,
|
||||
backgroundColor: AppTheme.brightNavy,
|
||||
child: const Icon(Icons.edit, color: Colors.white),
|
||||
),
|
||||
body: _buildBody(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
if (_isInitializing) {
|
||||
return _buildInitializingState();
|
||||
}
|
||||
|
||||
// LIVE StreamBuilder - constantly updating
|
||||
return StreamBuilder<List<SecureConversation>>(
|
||||
stream: _conversationStream,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) {
|
||||
return _buildErrorState();
|
||||
}
|
||||
|
||||
final conversations = snapshot.data ?? [];
|
||||
|
||||
if (snapshot.connectionState == ConnectionState.waiting && conversations.isEmpty) {
|
||||
return Center(
|
||||
child: CircularProgressIndicator(color: AppTheme.ksuPurple),
|
||||
);
|
||||
}
|
||||
|
||||
if (conversations.isEmpty) {
|
||||
return _buildEmptyState();
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
setState(() {}); // Trigger stream rebuild
|
||||
},
|
||||
child: _buildConversationList(conversations),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildConversationList(List<SecureConversation> conversations) {
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: conversations.length,
|
||||
separatorBuilder: (_, __) => Divider(
|
||||
height: 1,
|
||||
indent: 72,
|
||||
color: AppTheme.navyBlue.withValues(alpha: 0.1),
|
||||
),
|
||||
itemBuilder: (context, index) =>
|
||||
_buildConversationTile(conversations[index]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInitializingState() {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.queenPink.withValues(alpha: 0.3),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.key,
|
||||
size: 40,
|
||||
color: AppTheme.brightNavy,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Setting up encryption...',
|
||||
style: GoogleFonts.literata(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.navyBlue,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Generating secure keys',
|
||||
style: GoogleFonts.inter(
|
||||
color: AppTheme.textDisabled,
|
||||
fontSize: 14,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
CircularProgressIndicator(color: AppTheme.brightNavy),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorState() {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: AppTheme.error,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_error ?? 'An error occurred',
|
||||
style: GoogleFonts.literata(
|
||||
color: AppTheme.navyBlue,
|
||||
fontSize: 16,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _initialize,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: Text('Retry', style: GoogleFonts.inter()),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.brightNavy,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.queenPink.withValues(alpha: 0.3),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.chat_bubble_outline,
|
||||
size: 40,
|
||||
color: AppTheme.brightNavy,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'No messages yet',
|
||||
style: GoogleFonts.literata(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.navyBlue,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Start a conversation with someone you mutually follow',
|
||||
style: GoogleFonts.inter(
|
||||
color: AppTheme.textDisabled,
|
||||
fontSize: 14,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Long press on conversations to delete',
|
||||
style: GoogleFonts.inter(
|
||||
color: AppTheme.textDisabled,
|
||||
fontSize: 12,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _startNewConversation,
|
||||
icon: const Icon(Icons.edit),
|
||||
label: Text('New Message', style: GoogleFonts.inter(fontWeight: FontWeight.w600)),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.brightNavy,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildConversationTile(SecureConversation conversation) {
|
||||
final hasUnread = (conversation.unreadCount ?? 0) > 0;
|
||||
final displayName = conversation.otherUserDisplayName ??
|
||||
'@${conversation.otherUserHandle ?? 'Unknown'}';
|
||||
|
||||
return GestureDetector(
|
||||
onLongPress: () => _showConversationMenu(conversation),
|
||||
child: InkWell(
|
||||
onTap: () => _openConversation(conversation),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Avatar
|
||||
CircleAvatar(
|
||||
radius: 24,
|
||||
backgroundColor: AppTheme.queenPink.withValues(alpha: 0.3),
|
||||
child: conversation.otherUserAvatarUrl != null
|
||||
? ClipOval(
|
||||
child: SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: SignedMediaImage(
|
||||
url: conversation.otherUserAvatarUrl!,
|
||||
width: 48,
|
||||
height: 48,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
displayName.isNotEmpty
|
||||
? displayName[0].toUpperCase()
|
||||
: '?',
|
||||
style: GoogleFonts.inter(
|
||||
color: AppTheme.navyBlue,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Name and time
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
displayName,
|
||||
style: GoogleFonts.literata(
|
||||
fontWeight: hasUnread ? FontWeight.w700 : FontWeight.w600,
|
||||
color: AppTheme.navyBlue,
|
||||
fontSize: 15,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.lock,
|
||||
size: 12,
|
||||
color: AppTheme.brightNavy.withValues(alpha: 0.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
timeago.format(conversation.lastMessageAt),
|
||||
style: GoogleFonts.inter(
|
||||
color: AppTheme.textDisabled,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (hasUnread)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.brightNavy,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
'${conversation.unreadCount}',
|
||||
style: GoogleFonts.inter(
|
||||
color: Colors.white,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
color: AppTheme.navyBlue.withValues(alpha: 0.3),
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showConversationMenu(SecureConversation conversation) {
|
||||
final displayName = conversation.otherUserDisplayName ??
|
||||
'@${conversation.otherUserHandle ?? 'Unknown'}';
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: AppTheme.cardSurface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.navyBlue.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Text(
|
||||
'Chat with $displayName',
|
||||
style: GoogleFonts.literata(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.navyBlue,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
leading: Icon(Icons.delete, color: AppTheme.error),
|
||||
title: Text(
|
||||
'Delete Conversation',
|
||||
style: GoogleFonts.inter(color: AppTheme.error),
|
||||
),
|
||||
subtitle: Text(
|
||||
'Remove all messages permanently',
|
||||
style: GoogleFonts.inter(
|
||||
color: AppTheme.textDisabled,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_deleteConversation(conversation);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _deleteConversation(SecureConversation conversation) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: AppTheme.cardSurface,
|
||||
title: Text(
|
||||
'Delete Conversation',
|
||||
style: GoogleFonts.literata(
|
||||
color: AppTheme.navyBlue,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
content: Text(
|
||||
'This will delete all messages in this conversation. This action cannot be undone.',
|
||||
style: GoogleFonts.inter(color: AppTheme.navyText),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(
|
||||
'Cancel',
|
||||
style: GoogleFonts.inter(color: AppTheme.egyptianBlue),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(
|
||||
'Delete',
|
||||
style: GoogleFonts.inter(color: AppTheme.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
try {
|
||||
final result = await _chatService.deleteConversation(
|
||||
conversation.id,
|
||||
fullDelete: true,
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
_loadConversations();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Conversation deleted',
|
||||
style: GoogleFonts.inter(),
|
||||
),
|
||||
backgroundColor: AppTheme.brightNavy,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(result.error ?? 'Failed to delete chat'),
|
||||
backgroundColor: AppTheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Error: $e'),
|
||||
backgroundColor: AppTheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,794 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:timeago/timeago.dart' as timeago;
|
||||
import '../../models/secure_chat.dart';
|
||||
import '../../services/secure_chat_service.dart';
|
||||
import '../../theme/app_theme.dart';
|
||||
import '../../widgets/media/signed_media_image.dart';
|
||||
import 'secure_chat_screen.dart';
|
||||
import 'new_conversation_sheet.dart';
|
||||
|
||||
/// Modal sheet for secure chat list
|
||||
/// Displays conversations in a modal bottom sheet that stays within shell context
|
||||
class SecureChatModalSheet extends StatefulWidget {
|
||||
const SecureChatModalSheet({super.key});
|
||||
|
||||
@override
|
||||
State<SecureChatModalSheet> createState() => _SecureChatModalSheetState();
|
||||
}
|
||||
|
||||
class _SecureChatModalSheetState extends State<SecureChatModalSheet> {
|
||||
final SecureChatService _chatService = SecureChatService();
|
||||
|
||||
List<SecureConversation> _conversations = [];
|
||||
bool _isLoading = true;
|
||||
bool _isInitializing = false;
|
||||
String? _error;
|
||||
|
||||
late Stream<List<SecureConversation>> _conversationStream;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Create LIVE stream that automatically updates every 2 seconds
|
||||
_conversationStream = Stream.periodic(const Duration(seconds: 2))
|
||||
.asyncMap((_) => _chatService.getConversations())
|
||||
.asBroadcastStream();
|
||||
|
||||
// Also trigger immediate reload on conversation changes
|
||||
_chatService.conversationListChanges.listen((_) {
|
||||
if (mounted) {
|
||||
print('[UI] Conversation list changed in modal - stream will update');
|
||||
setState(() {}); // Force rebuild to get latest from stream
|
||||
}
|
||||
});
|
||||
|
||||
_initialize();
|
||||
}
|
||||
|
||||
Future<void> _initialize() async {
|
||||
setState(() => _isInitializing = true);
|
||||
|
||||
try {
|
||||
// Check if E2EE is ready
|
||||
final isReady = await _chatService.isReady();
|
||||
if (!isReady) {
|
||||
await _chatService.initialize();
|
||||
}
|
||||
await _loadConversations();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_error = 'Failed to initialize secure messaging';
|
||||
_isInitializing = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadConversations() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final conversations = await _chatService.getConversations();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_conversations = conversations;
|
||||
_isLoading = false;
|
||||
_isInitializing = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_error = 'Failed to load conversations';
|
||||
_isLoading = false;
|
||||
_isInitializing = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _startNewConversation() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => NewConversationSheet(
|
||||
onConversationStarted: (conversation) {
|
||||
Navigator.pop(context);
|
||||
_openConversation(conversation);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openConversation(SecureConversation conversation) {
|
||||
// Close the chat list modal first
|
||||
Navigator.of(context).pop();
|
||||
|
||||
// Open individual chat as a modal sheet
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => SecureChatModal(conversation: conversation),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DraggableScrollableSheet(
|
||||
expand: false,
|
||||
initialChildSize: 0.8,
|
||||
minChildSize: 0.5,
|
||||
maxChildSize: 0.95,
|
||||
builder: (context, scrollController) => Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.scaffoldBg,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: AppTheme.navyBlue.withValues(alpha: 0.1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'Messages',
|
||||
style: GoogleFonts.literata(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.navyBlue,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'End-to-end encrypted',
|
||||
style: GoogleFonts.inter(
|
||||
color: AppTheme.textDisabled,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: _loadConversations,
|
||||
icon: Icon(Icons.refresh, color: AppTheme.navyBlue),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: Icon(Icons.close, color: AppTheme.navyBlue),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// FAB positioned at bottom right
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
_buildBody(scrollController),
|
||||
Positioned(
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
child: FloatingActionButton(
|
||||
onPressed: _startNewConversation,
|
||||
backgroundColor: AppTheme.brightNavy,
|
||||
child: const Icon(Icons.edit, color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(ScrollController scrollController) {
|
||||
if (_isInitializing) {
|
||||
return _buildInitializingState();
|
||||
}
|
||||
|
||||
// LIVE StreamBuilder - constantly updating
|
||||
return StreamBuilder<List<SecureConversation>>(
|
||||
stream: _conversationStream,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) {
|
||||
return _buildErrorState();
|
||||
}
|
||||
|
||||
final conversations = snapshot.data ?? [];
|
||||
|
||||
if (snapshot.connectionState == ConnectionState.waiting && conversations.isEmpty) {
|
||||
return Center(
|
||||
child: CircularProgressIndicator(color: AppTheme.ksuPurple),
|
||||
);
|
||||
}
|
||||
|
||||
if (conversations.isEmpty) {
|
||||
return _buildEmptyState();
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: conversations.length,
|
||||
separatorBuilder: (_, __) => Divider(
|
||||
height: 1,
|
||||
indent: 72,
|
||||
color: AppTheme.navyBlue.withValues(alpha: 0.1),
|
||||
),
|
||||
itemBuilder: (context, index) =>
|
||||
_buildConversationTile(conversations[index]),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInitializingState() {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.queenPink.withValues(alpha: 0.3),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.key,
|
||||
size: 40,
|
||||
color: AppTheme.brightNavy,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Setting up encryption...',
|
||||
style: GoogleFonts.literata(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.navyBlue,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Generating secure keys',
|
||||
style: GoogleFonts.inter(
|
||||
color: AppTheme.textDisabled,
|
||||
fontSize: 14,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
CircularProgressIndicator(color: AppTheme.brightNavy),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorState() {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: AppTheme.error,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_error ?? 'An error occurred',
|
||||
style: GoogleFonts.literata(
|
||||
color: AppTheme.navyBlue,
|
||||
fontSize: 16,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _initialize,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: Text('Retry', style: GoogleFonts.inter()),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.brightNavy,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.queenPink.withValues(alpha: 0.3),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.chat_bubble_outline,
|
||||
size: 40,
|
||||
color: AppTheme.brightNavy,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'No messages yet',
|
||||
style: GoogleFonts.literata(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.navyBlue,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Start a conversation with someone you mutually follow',
|
||||
style: GoogleFonts.inter(
|
||||
color: AppTheme.textDisabled,
|
||||
fontSize: 14,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Long press on conversations to delete',
|
||||
style: GoogleFonts.inter(
|
||||
color: AppTheme.textDisabled,
|
||||
fontSize: 12,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _startNewConversation,
|
||||
icon: const Icon(Icons.edit),
|
||||
label: Text('New Message', style: GoogleFonts.inter(fontWeight: FontWeight.w600)),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.brightNavy,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildConversationTile(SecureConversation conversation) {
|
||||
final hasUnread = (conversation.unreadCount ?? 0) > 0;
|
||||
final displayName = conversation.otherUserDisplayName ??
|
||||
'@${conversation.otherUserHandle ?? 'Unknown'}';
|
||||
|
||||
return GestureDetector(
|
||||
onLongPress: () => _showConversationMenu(conversation),
|
||||
child: InkWell(
|
||||
onTap: () => _openConversation(conversation),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Avatar
|
||||
CircleAvatar(
|
||||
radius: 24,
|
||||
backgroundColor: AppTheme.queenPink.withValues(alpha: 0.3),
|
||||
child: conversation.otherUserAvatarUrl != null
|
||||
? ClipOval(
|
||||
child: SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: SignedMediaImage(
|
||||
url: conversation.otherUserAvatarUrl!,
|
||||
width: 48,
|
||||
height: 48,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
displayName.isNotEmpty
|
||||
? displayName[0].toUpperCase()
|
||||
: '?',
|
||||
style: GoogleFonts.inter(
|
||||
color: AppTheme.navyBlue,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Name and time
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
displayName,
|
||||
style: GoogleFonts.literata(
|
||||
fontWeight: hasUnread ? FontWeight.w700 : FontWeight.w600,
|
||||
color: AppTheme.navyBlue,
|
||||
fontSize: 15,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.lock,
|
||||
size: 12,
|
||||
color: AppTheme.brightNavy.withValues(alpha: 0.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
timeago.format(conversation.lastMessageAt),
|
||||
style: GoogleFonts.inter(
|
||||
color: AppTheme.textDisabled,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (hasUnread)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.brightNavy,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
'${conversation.unreadCount}',
|
||||
style: GoogleFonts.inter(
|
||||
color: Colors.white,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
color: AppTheme.navyBlue.withValues(alpha: 0.3),
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showConversationMenu(SecureConversation conversation) {
|
||||
final displayName = conversation.otherUserDisplayName ??
|
||||
'@${conversation.otherUserHandle ?? 'Unknown'}';
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: AppTheme.cardSurface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.navyBlue.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Text(
|
||||
'Chat with $displayName',
|
||||
style: GoogleFonts.literata(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.navyBlue,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
leading: Icon(Icons.delete, color: AppTheme.error),
|
||||
title: Text(
|
||||
'Delete Conversation',
|
||||
style: GoogleFonts.inter(color: AppTheme.error),
|
||||
),
|
||||
subtitle: Text(
|
||||
'Remove all messages permanently',
|
||||
style: GoogleFonts.inter(
|
||||
color: AppTheme.textDisabled,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_deleteConversation(conversation);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _deleteConversation(SecureConversation conversation) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: AppTheme.cardSurface,
|
||||
title: Text(
|
||||
'Delete Conversation',
|
||||
style: GoogleFonts.literata(
|
||||
color: AppTheme.navyBlue,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
content: Text(
|
||||
'This will delete all messages in this conversation. This action cannot be undone.',
|
||||
style: GoogleFonts.inter(color: AppTheme.navyText),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(
|
||||
'Cancel',
|
||||
style: GoogleFonts.inter(color: AppTheme.egyptianBlue),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(
|
||||
'Delete',
|
||||
style: GoogleFonts.inter(color: AppTheme.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
try {
|
||||
final result = await _chatService.deleteConversation(
|
||||
conversation.id,
|
||||
fullDelete: true,
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
_loadConversations();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Conversation deleted',
|
||||
style: GoogleFonts.inter(),
|
||||
),
|
||||
backgroundColor: AppTheme.brightNavy,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(result.error ?? 'Failed to delete chat'),
|
||||
backgroundColor: AppTheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Error: $e'),
|
||||
backgroundColor: AppTheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Modal sheet for individual secure chat conversation
|
||||
class SecureChatModal extends StatefulWidget {
|
||||
final SecureConversation conversation;
|
||||
|
||||
const SecureChatModal({super.key, required this.conversation});
|
||||
|
||||
@override
|
||||
State<SecureChatModal> createState() => _SecureChatModalState();
|
||||
}
|
||||
|
||||
class _SecureChatModalState extends State<SecureChatModal> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DraggableScrollableSheet(
|
||||
expand: false,
|
||||
initialChildSize: 0.9,
|
||||
minChildSize: 0.7,
|
||||
maxChildSize: 0.95,
|
||||
builder: (context, scrollController) => Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.scaffoldBg,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Header with conversation info
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: AppTheme.navyBlue.withValues(alpha: 0.1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 20,
|
||||
backgroundColor: AppTheme.queenPink.withValues(alpha: 0.3),
|
||||
child: widget.conversation.otherUserAvatarUrl != null
|
||||
? ClipOval(
|
||||
child: SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: SignedMediaImage(
|
||||
url: widget.conversation.otherUserAvatarUrl!,
|
||||
width: 40,
|
||||
height: 40,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
(widget.conversation.otherUserDisplayName ??
|
||||
'@${widget.conversation.otherUserHandle ?? 'Unknown'}')
|
||||
.isNotEmpty
|
||||
? (widget.conversation.otherUserDisplayName ??
|
||||
'@${widget.conversation.otherUserHandle ?? 'Unknown'}')[0]
|
||||
.toUpperCase()
|
||||
: '?',
|
||||
style: GoogleFonts.inter(
|
||||
color: AppTheme.navyBlue,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.conversation.otherUserDisplayName ??
|
||||
'@${widget.conversation.otherUserHandle ?? 'Unknown'}',
|
||||
style: GoogleFonts.literata(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.navyBlue,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.lock,
|
||||
size: 12,
|
||||
color: AppTheme.brightNavy.withValues(alpha: 0.5),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'End-to-end encrypted',
|
||||
style: GoogleFonts.inter(
|
||||
color: AppTheme.textDisabled,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: TextButton(
|
||||
onPressed: () {
|
||||
final chatService = SecureChatService();
|
||||
chatService.uploadKeysManually();
|
||||
},
|
||||
child: Text(
|
||||
'🔑',
|
||||
style: GoogleFonts.inter(
|
||||
color: Colors.blue,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: Icon(Icons.close, color: AppTheme.navyBlue),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Chat content
|
||||
Expanded(
|
||||
child: SecureChatScreen(
|
||||
conversation: widget.conversation,
|
||||
isModal: true,
|
||||
scrollController: scrollController,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -303,6 +303,7 @@ class _SecureChatScreenState extends State<SecureChatScreen>
|
|||
}
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_markAsRead();
|
||||
_refreshActiveHeader(items);
|
||||
_updateStickyDateLabel();
|
||||
|
|
@ -549,6 +550,7 @@ class _SecureChatScreenState extends State<SecureChatScreen>
|
|||
|
||||
void _updateStickyDateLabel() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final listContext = _listViewportKey.currentContext;
|
||||
if (listContext == null) return;
|
||||
final listBox = listContext.findRenderObject() as RenderBox?;
|
||||
|
|
|
|||
|
|
@ -460,6 +460,16 @@ class ApiService {
|
|||
return posts.map((p) => Post.fromJson(p)).toList();
|
||||
}
|
||||
|
||||
/// Get Focus-Context data for the new interactive block system
|
||||
/// Returns: Target Post, Direct Parent (if any), and Direct Children (1st layer only)
|
||||
Future<FocusContext> getPostFocusContext(String postId) async {
|
||||
final data = await _callGoApi(
|
||||
'/posts/$postId/focus-context',
|
||||
method: 'GET',
|
||||
);
|
||||
return FocusContext.fromJson(data);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Publishing - Unified Post/Beacon Flow
|
||||
// =========================================================================
|
||||
|
|
@ -837,6 +847,18 @@ class ApiService {
|
|||
return '${ApiConfig.baseUrl}/media/signed?path=$path'; // Placeholder
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> toggleReaction(String postId, String emoji) async {
|
||||
final data = await callGoApi(
|
||||
'/posts/$postId/reactions/toggle',
|
||||
method: 'POST',
|
||||
body: {'emoji': emoji},
|
||||
);
|
||||
if (data is Map<String, dynamic>) {
|
||||
return data;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
Future<SearchResults> search(String query) async {
|
||||
try {
|
||||
final data = await callGoApi(
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import '../screens/post/threaded_conversation_screen.dart';
|
|||
class FeedNavigationService {
|
||||
static void openQuipsFeed(BuildContext context, Post post) {
|
||||
// Navigate to Quips feed with the specific video
|
||||
Navigator.of(context).push(
|
||||
Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => QuipsFeedScreen(
|
||||
initialPostId: post.id,
|
||||
|
|
@ -18,7 +18,7 @@ class FeedNavigationService {
|
|||
|
||||
static void openThreadedConversation(BuildContext context, String postId) {
|
||||
// Navigate to threaded conversation for regular posts
|
||||
Navigator.of(context).push(
|
||||
Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ThreadedConversationScreen(
|
||||
rootPostId: postId,
|
||||
|
|
|
|||
|
|
@ -146,19 +146,29 @@ class ImageUploadService {
|
|||
}
|
||||
|
||||
File sanitizedFile;
|
||||
bool useRawUpload = false;
|
||||
try {
|
||||
sanitizedFile = await MediaSanitizer.sanitizeImage(imageFile);
|
||||
} catch (e) {
|
||||
throw UploadException('Image sanitization failed: $e');
|
||||
final message = e.toString();
|
||||
if (message.contains('Unsupported operation') || message.contains('_Namespace')) {
|
||||
// Fallback: upload original bytes without processing for unsupported formats.
|
||||
useRawUpload = true;
|
||||
sanitizedFile = imageFile;
|
||||
} else {
|
||||
throw UploadException('Image sanitization failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
final fileName = sanitizedFile.path.split('/').last;
|
||||
const contentType = 'image/jpeg';
|
||||
final contentType = useRawUpload ? _contentTypeForFileName(fileName) : 'image/jpeg';
|
||||
|
||||
// 2. Process image with filter if provided
|
||||
Uint8List fileBytes;
|
||||
|
||||
if (filter != null && filter.id != 'none') {
|
||||
if (useRawUpload) {
|
||||
fileBytes = await sanitizedFile.readAsBytes();
|
||||
} else if (filter != null && filter.id != 'none') {
|
||||
onProgress?.call(0.1);
|
||||
final processed = await _processImage(sanitizedFile, filter, maxWidth, maxHeight, quality);
|
||||
fileBytes = processed.bytes;
|
||||
|
|
@ -506,6 +516,28 @@ class ImageUploadService {
|
|||
|
||||
return url;
|
||||
}
|
||||
|
||||
String _contentTypeForFileName(String fileName) {
|
||||
final extension = fileName.split('.').last.toLowerCase();
|
||||
switch (extension) {
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
return 'image/jpeg';
|
||||
case 'png':
|
||||
return 'image/png';
|
||||
case 'gif':
|
||||
return 'image/gif';
|
||||
case 'webp':
|
||||
return 'image/webp';
|
||||
case 'svg':
|
||||
return 'image/svg+xml';
|
||||
case 'heic':
|
||||
case 'heif':
|
||||
return 'image/heic';
|
||||
default:
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ImageValidationResult {
|
||||
|
|
@ -550,4 +582,4 @@ class UploadException implements Exception {
|
|||
UploadException(this.message);
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -217,6 +217,12 @@ class SimpleE2EEService {
|
|||
if (await _testKeyCompatibility()) {
|
||||
// Check if keys exist on backend, upload if not
|
||||
if (await _checkKeysExistOnBackend()) {
|
||||
final backendValid = await _validateBackendKeyBundle(userId);
|
||||
if (!backendValid) {
|
||||
print('[E2EE] Backend key bundle failed signature verification - reuploading');
|
||||
await _uploadExistingKeys();
|
||||
return;
|
||||
}
|
||||
print('[E2EE] Keys exist on backend - ready');
|
||||
return;
|
||||
} else {
|
||||
|
|
@ -296,6 +302,50 @@ class SimpleE2EEService {
|
|||
return false;
|
||||
}
|
||||
|
||||
Future<bool> _validateBackendKeyBundle(String userId) async {
|
||||
try {
|
||||
final bundle = await _api.getKeyBundle(userId);
|
||||
|
||||
String? ikField = bundle['identity_key_public'];
|
||||
if (ikField == null && bundle['identity_key'] is Map) {
|
||||
ikField = bundle['identity_key']['public_key'];
|
||||
} else if (ikField == null) {
|
||||
ikField = bundle['identity_key'];
|
||||
}
|
||||
|
||||
String? spkField = bundle['signed_prekey_public'];
|
||||
String? spkSignature = bundle['signed_prekey_signature'];
|
||||
if (spkField == null && bundle['signed_prekey'] is Map) {
|
||||
spkField = bundle['signed_prekey']['public_key'];
|
||||
spkSignature = bundle['signed_prekey']['signature'];
|
||||
} else if (spkField == null) {
|
||||
spkField = bundle['signed_prekey'];
|
||||
}
|
||||
|
||||
if (ikField == null || ikField.isEmpty) return false;
|
||||
if (spkField == null || spkField.isEmpty) return false;
|
||||
if (spkSignature == null || spkSignature.isEmpty) return false;
|
||||
|
||||
final ikParts = ikField.split(':');
|
||||
if (ikParts.length != 2) return false;
|
||||
|
||||
final skBytes = base64Decode(ikParts[0]);
|
||||
final spkBytes = base64Decode(spkField);
|
||||
final sigBytes = base64Decode(spkSignature);
|
||||
|
||||
final theirSk = SimplePublicKey(skBytes, type: KeyPairType.ed25519);
|
||||
final verified = await _signingAlgo.verify(
|
||||
spkBytes,
|
||||
signature: Signature(sigBytes, publicKey: theirSk),
|
||||
);
|
||||
|
||||
return verified;
|
||||
} catch (e) {
|
||||
print('[E2EE] Backend key bundle validation failed: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Smart key recovery that preserves messages when possible
|
||||
Future<void> initiateKeyRecovery(String userId) async {
|
||||
print('[E2EE] Starting smart key recovery...');
|
||||
|
|
@ -655,6 +705,11 @@ class SimpleE2EEService {
|
|||
await _api.callGoApi('/keys/otk/$keyId', method: 'DELETE');
|
||||
print('[E2EE] Deleted used OTK #$keyId from server');
|
||||
} catch (e) {
|
||||
final message = e.toString();
|
||||
if (message.contains('route not found') || message.contains('404')) {
|
||||
print('[E2EE] OTK delete endpoint missing on backend; skipping cleanup for #$keyId');
|
||||
return;
|
||||
}
|
||||
print('[E2EE] Error deleting OTK #$keyId: $e');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,192 +2,100 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter/services.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
import 'theme_extensions.dart';
|
||||
import 'tokens.dart';
|
||||
|
||||
enum AppThemeType {
|
||||
basic,
|
||||
pop,
|
||||
}
|
||||
|
||||
class AppTheme {
|
||||
// ============================================================================
|
||||
// BASIC THEME PALETTE (Current/Original Theme)
|
||||
// ============================================================================
|
||||
|
||||
// ANCHORS (The 60%)
|
||||
static const Color basicNavyBlue = Color(0xFF000383);
|
||||
static const Color basicNavyText = Color(0xFF000383);
|
||||
|
||||
// FLOW & STRUCTURE (The 30%)
|
||||
static const Color basicEgyptianBlue = Color(0xFF0E38AE);
|
||||
static const Color basicBrightNavy = Color(0xFF1974D1);
|
||||
|
||||
// ACCENTS (The 10%)
|
||||
static const Color basicRoyalPurple = Color(0xFF7751A8);
|
||||
static const Color basicKsuPurple = Color(0xFF512889);
|
||||
|
||||
// NEUTRALS & BACKGROUNDS
|
||||
static const Color basicQueenPink = Color(0xFFE5C0DD);
|
||||
static const Color basicQueenPinkLight = Color(0xFFF9F2F7);
|
||||
static const Color basicWhite = Color(0xFFFFFFFF);
|
||||
|
||||
// ============================================================================
|
||||
// POP THEME PALETTE - "Awakening" - High Contrast & Energy
|
||||
// ============================================================================
|
||||
|
||||
// 60% - The Anchors (Deep & Grounded)
|
||||
static const Color popNavyBlue = Color(0xFF000383);
|
||||
static const Color popNavyText = Color(0xFF0D1050);
|
||||
|
||||
// 30% - Structure & Flow (Bright & Defined)
|
||||
static const Color popEgyptianBlue = Color(0xFF0E38AE);
|
||||
static const Color popBrightNavy = Color(0xFF1974D1);
|
||||
|
||||
// 10% - The Pop (Vibrant Accents)
|
||||
static const Color popRoyalPurple = Color(0xFF7751A8);
|
||||
static const Color popKsuPurple = Color(0xFF512889);
|
||||
|
||||
// Backgrounds - The "Clean" Space
|
||||
static const Color popScaffoldBg = Color(0xFFF9F6F9);
|
||||
static const Color popCardSurface = Colors.white;
|
||||
|
||||
// Interaction
|
||||
static const Color popHighlight = Color(0xFFE5C0DD);
|
||||
|
||||
// ============================================================================
|
||||
// CURRENT THEME COLORS (Dynamic based on selected theme)
|
||||
// ============================================================================
|
||||
|
||||
static AppThemeType _currentThemeType = AppThemeType.basic;
|
||||
|
||||
static void setThemeType(AppThemeType type) {
|
||||
_currentThemeType = type;
|
||||
}
|
||||
|
||||
static bool get isPop => _currentThemeType == AppThemeType.pop;
|
||||
static const Map<AppThemeType, SojornExt> _extensions = {
|
||||
AppThemeType.basic: SojornExt.basic,
|
||||
AppThemeType.pop: SojornExt.pop,
|
||||
};
|
||||
|
||||
// Dynamic color getters
|
||||
static Color get navyBlue => isPop ? popNavyBlue : basicNavyBlue;
|
||||
static Color get navyText => isPop ? popNavyText : basicNavyText;
|
||||
static Color get egyptianBlue => isPop ? popEgyptianBlue : basicEgyptianBlue;
|
||||
static Color get brightNavy => isPop ? popBrightNavy : basicBrightNavy;
|
||||
static Color get royalPurple => isPop ? popRoyalPurple : basicRoyalPurple;
|
||||
static Color get ksuPurple => isPop ? popKsuPurple : basicKsuPurple;
|
||||
static Color get queenPink => isPop ? popHighlight : basicQueenPink;
|
||||
static Color get queenPinkLight => scaffoldBg; // Alias for backward compatibility
|
||||
static Color get scaffoldBg => isPop ? popScaffoldBg : basicQueenPinkLight;
|
||||
static Color get cardSurface => isPop ? popCardSurface : basicWhite;
|
||||
static const Color white = basicWhite;
|
||||
static SojornExt get ext => _extensions[_currentThemeType]!;
|
||||
static SojornBrandColors get _brand => ext.brandColors;
|
||||
static SojornFlowLines get _lines => ext.flowLines;
|
||||
|
||||
// SEMANTIC
|
||||
static const Color error = Color(0xFFD32F2F);
|
||||
static const Color success = basicKsuPurple;
|
||||
static const Color warning = Color(0xFFFBC02D);
|
||||
static const Color info = Color(0xFF2196F3);
|
||||
// Backward compatible color getters.
|
||||
static Color get navyBlue => _brand.navyBlue;
|
||||
static Color get navyText => _brand.navyText;
|
||||
static Color get egyptianBlue => _brand.egyptianBlue;
|
||||
static Color get brightNavy => _brand.brightNavy;
|
||||
static Color get royalPurple => _brand.royalPurple;
|
||||
static Color get ksuPurple => _brand.ksuPurple;
|
||||
static Color get queenPink => _brand.queenPink;
|
||||
static Color get queenPinkLight => _brand.queenPinkLight;
|
||||
static Color get scaffoldBg => _brand.scaffoldBg;
|
||||
static Color get cardSurface => _brand.cardSurface;
|
||||
static const Color white = SojornColors.basicWhite;
|
||||
|
||||
// Semantic
|
||||
static const Color error = SojornColors.error;
|
||||
static Color get success => ksuPurple;
|
||||
static const Color warning = SojornColors.warning;
|
||||
static const Color info = SojornColors.info;
|
||||
|
||||
// Trust Tiers
|
||||
static Color get tierEstablished => egyptianBlue;
|
||||
static Color get tierTrusted => royalPurple;
|
||||
static const Color tierNew = Colors.grey;
|
||||
static Color get tierEstablished => ext.trustTierColors.established;
|
||||
static Color get tierTrusted => ext.trustTierColors.trusted;
|
||||
static Color get tierNew => ext.trustTierColors.fresh;
|
||||
|
||||
// ============================================================================
|
||||
// DIMENSIONS
|
||||
// ============================================================================
|
||||
// Dimensions
|
||||
static const double spacingSm = SojornSpacing.sm;
|
||||
static const double spacingMd = SojornSpacing.md;
|
||||
static const double spacingLg = SojornSpacing.lg;
|
||||
static const double spacingXs = SojornSpacing.xs;
|
||||
static const double spacing2xs = SojornSpacing.xxs;
|
||||
|
||||
static const double spacingSm = 12.0;
|
||||
static const double spacingMd = 16.0;
|
||||
static const double spacingLg = 24.0;
|
||||
static const double spacingXs = 4.0;
|
||||
static const double spacing2xs = 2.0;
|
||||
static const double radiusSm = SojornRadii.sm;
|
||||
static const double radiusXs = SojornRadii.xs;
|
||||
static const double radiusMd = SojornRadii.md;
|
||||
static const double radiusMdValue = SojornRadii.md;
|
||||
static const double radiusFull = SojornRadii.full;
|
||||
|
||||
// Radii
|
||||
static const double radiusSm = 4.0;
|
||||
static const double radiusXs = 2.0;
|
||||
static const double radiusMd = 8.0;
|
||||
static const double radiusMdValue = 8.0;
|
||||
static const double radiusFull = 36.0;
|
||||
|
||||
// Text Colors - COLOR HIERARCHY: Content neutral, UI branded
|
||||
static Color get textPrimary => navyText; // Names, handles, UI
|
||||
static Color get textSecondary => navyText; // Names, handles, UI
|
||||
static Color get textTertiary => navyText; // Names, handles, UI
|
||||
static const Color textDisabled = Colors.grey;
|
||||
static const Color textOnAccent = white;
|
||||
// Text Colors - COLOR HIERARCHY: Content neutral, UI branded.
|
||||
static Color get textPrimary => navyText;
|
||||
static Color get textSecondary => navyText;
|
||||
static Color get textTertiary => navyText;
|
||||
static const Color textDisabled = SojornColors.textDisabled;
|
||||
static const Color textOnAccent = SojornColors.textOnAccent;
|
||||
static Color get border => egyptianBlue;
|
||||
|
||||
// Post Content - Neutral for contrast with purple UI
|
||||
static const Color postContent = Color(0xFF1A1A1A);
|
||||
static const Color postContentLight = Color(0xFF4A4A4A);
|
||||
|
||||
// STRICT SEPARATION
|
||||
static const double borderWidth = 1.5;
|
||||
static const double dividerThickness = 2.0;
|
||||
static const double flowLineWidth = 3.0;
|
||||
// Post Content - Neutral for contrast with purple UI.
|
||||
static const Color postContent = SojornColors.postContent;
|
||||
static const Color postContentLight = SojornColors.postContentLight;
|
||||
|
||||
// Lines
|
||||
static const double borderWidth = SojornLines.border;
|
||||
static const double dividerThickness = SojornLines.dividerStrong;
|
||||
static const double flowLineWidth = SojornLines.flow;
|
||||
|
||||
// Post Specific Spacing
|
||||
static const double spacingPostShort = 16.0;
|
||||
static const double spacingPostMedium = 24.0;
|
||||
static const double spacingPostLong = 32.0;
|
||||
static const double spacingPostShort = SojornSpacing.postShort;
|
||||
static const double spacingPostMedium = SojornSpacing.postMedium;
|
||||
static const double spacingPostLong = SojornSpacing.postLong;
|
||||
|
||||
// ============================================================================
|
||||
// TYPOGRAPHY (Literata + Navy Blue) - STRICT FLAT DESIGN
|
||||
// ============================================================================
|
||||
|
||||
static TextTheme get textTheme => GoogleFonts.literataTextTheme().copyWith(
|
||||
// Hero Body Text - Main post content (NEUTRAL for contrast with purple UI)
|
||||
bodyLarge: GoogleFonts.literata(
|
||||
fontSize: 17,
|
||||
height: 1.5,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: postContent,
|
||||
),
|
||||
// Standard Body - Secondary content (NEUTRAL)
|
||||
bodyMedium: GoogleFonts.literata(
|
||||
fontSize: 16,
|
||||
height: 1.5,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: postContentLight,
|
||||
),
|
||||
// Metadata / UI Elements (Sans-serif)
|
||||
labelSmall: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: egyptianBlue,
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
labelMedium: GoogleFonts.inter(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: brightNavy,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
// Author Name - Visual anchor, ExtraBold Navy Blue
|
||||
labelLarge: GoogleFonts.inter(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: navyBlue,
|
||||
),
|
||||
// Headlines
|
||||
headlineSmall: GoogleFonts.literata(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: navyBlue,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
headlineMedium: GoogleFonts.literata(
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: navyBlue,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
);
|
||||
// Typography
|
||||
static TextTheme get textTheme => _buildTextTheme(_brand);
|
||||
|
||||
// Backward Compat Getters
|
||||
static TextStyle get postBody => textTheme.bodyLarge!;
|
||||
static TextStyle get postBodyShort => textTheme.bodyLarge!.copyWith(fontSize: 22);
|
||||
static TextStyle get postBodyLong => textTheme.bodyLarge!.copyWith(fontSize: 18);
|
||||
static TextStyle get postBodyReflective => textTheme.bodyLarge!.copyWith(
|
||||
fontStyle: FontStyle.italic,
|
||||
color: ksuPurple
|
||||
);
|
||||
fontStyle: FontStyle.italic,
|
||||
color: ksuPurple,
|
||||
);
|
||||
|
||||
// Text Style Getters
|
||||
static TextStyle get bodyMedium => textTheme.bodyMedium!;
|
||||
|
|
@ -197,118 +105,211 @@ class AppTheme {
|
|||
static TextStyle get labelMedium => textTheme.labelMedium!;
|
||||
static TextStyle get labelSmall => textTheme.labelSmall!;
|
||||
|
||||
// ============================================================================
|
||||
// THEME DATA
|
||||
// ============================================================================
|
||||
// Theme Data
|
||||
static ThemeData get lightTheme => themeFor(_currentThemeType);
|
||||
|
||||
static ThemeData themeFor(AppThemeType type) {
|
||||
final ext = _extensions[type]!;
|
||||
return _buildTheme(ext);
|
||||
}
|
||||
|
||||
static ThemeData _buildTheme(SojornExt ext) {
|
||||
final brand = ext.brandColors;
|
||||
final lines = ext.flowLines;
|
||||
final textTheme = _buildTextTheme(brand);
|
||||
|
||||
static ThemeData get lightTheme {
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.light,
|
||||
scaffoldBackgroundColor: scaffoldBg,
|
||||
primaryColor: navyBlue,
|
||||
|
||||
// Color Scheme
|
||||
colorScheme: ColorScheme.light(
|
||||
primary: navyBlue,
|
||||
secondary: brightNavy,
|
||||
tertiary: royalPurple,
|
||||
surface: cardSurface,
|
||||
onSurface: navyText,
|
||||
error: error,
|
||||
),
|
||||
|
||||
// Text Theme
|
||||
scaffoldBackgroundColor: brand.scaffoldBg,
|
||||
primaryColor: brand.navyBlue,
|
||||
colorScheme: _buildColorScheme(brand),
|
||||
textTheme: textTheme,
|
||||
fontFamily: GoogleFonts.literata().fontFamily,
|
||||
appBarTheme: _buildAppBarTheme(brand, textTheme, lines),
|
||||
cardTheme: _buildCardTheme(brand, lines),
|
||||
elevatedButtonTheme: _buildElevatedButtonTheme(brand),
|
||||
textButtonTheme: _buildTextButtonTheme(brand),
|
||||
bottomNavigationBarTheme: _buildBottomNavTheme(brand, ext.options),
|
||||
floatingActionButtonTheme: _buildFabTheme(brand, ext.options),
|
||||
dividerTheme: _buildDividerTheme(brand, lines),
|
||||
inputDecorationTheme: _buildInputTheme(brand, lines),
|
||||
extensions: <ThemeExtension<dynamic>>[ext],
|
||||
);
|
||||
}
|
||||
|
||||
// AppBar (High Contrast)
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: cardSurface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
centerTitle: false,
|
||||
iconTheme: IconThemeData(color: navyBlue),
|
||||
titleTextStyle: textTheme.headlineSmall,
|
||||
systemOverlayStyle: SystemUiOverlayStyle.dark,
|
||||
shape: Border(
|
||||
bottom: BorderSide(color: egyptianBlue, width: isPop ? 2 : borderWidth),
|
||||
),
|
||||
static TextTheme _buildTextTheme(SojornBrandColors brand) {
|
||||
return GoogleFonts.literataTextTheme().copyWith(
|
||||
bodyLarge: GoogleFonts.literata(
|
||||
fontSize: 17,
|
||||
height: 1.5,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: SojornColors.postContent,
|
||||
),
|
||||
bodyMedium: GoogleFonts.literata(
|
||||
fontSize: 16,
|
||||
height: 1.5,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: SojornColors.postContentLight,
|
||||
),
|
||||
labelSmall: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: brand.egyptianBlue,
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
labelMedium: GoogleFonts.inter(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: brand.brightNavy,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
labelLarge: GoogleFonts.inter(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: brand.navyBlue,
|
||||
),
|
||||
headlineSmall: GoogleFonts.literata(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: brand.navyBlue,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
headlineMedium: GoogleFonts.literata(
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: brand.navyBlue,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Card Theme (Defined Edges)
|
||||
cardTheme: CardThemeData(
|
||||
color: cardSurface,
|
||||
static ColorScheme _buildColorScheme(SojornBrandColors brand) {
|
||||
return ColorScheme.light(
|
||||
primary: brand.navyBlue,
|
||||
secondary: brand.brightNavy,
|
||||
tertiary: brand.royalPurple,
|
||||
surface: brand.cardSurface,
|
||||
onSurface: brand.navyText,
|
||||
error: SojornColors.error,
|
||||
);
|
||||
}
|
||||
|
||||
static AppBarTheme _buildAppBarTheme(
|
||||
SojornBrandColors brand,
|
||||
TextTheme textTheme,
|
||||
SojornFlowLines lines,
|
||||
) {
|
||||
return AppBarTheme(
|
||||
backgroundColor: brand.cardSurface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
centerTitle: false,
|
||||
iconTheme: IconThemeData(color: brand.navyBlue),
|
||||
titleTextStyle: textTheme.headlineSmall,
|
||||
systemOverlayStyle: SystemUiOverlayStyle.dark,
|
||||
shape: Border(
|
||||
bottom: BorderSide(color: brand.egyptianBlue, width: lines.appBarBorder),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static CardThemeData _buildCardTheme(SojornBrandColors brand, SojornFlowLines lines) {
|
||||
return CardThemeData(
|
||||
color: brand.cardSurface,
|
||||
elevation: 0,
|
||||
margin: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(SojornRadii.lg),
|
||||
side: BorderSide(color: brand.egyptianBlue, width: lines.cardBorder),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static ElevatedButtonThemeData _buildElevatedButtonTheme(SojornBrandColors brand) {
|
||||
return ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: brand.brightNavy,
|
||||
foregroundColor: SojornColors.textOnAccent,
|
||||
elevation: 0,
|
||||
margin: EdgeInsets.zero,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: SojornSpacing.lg,
|
||||
vertical: SojornSpacing.md,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(color: egyptianBlue, width: 1),
|
||||
borderRadius: BorderRadius.circular(SojornRadii.md),
|
||||
),
|
||||
textStyle: GoogleFonts.inter(fontWeight: FontWeight.bold),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Buttons
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: brightNavy,
|
||||
foregroundColor: white,
|
||||
elevation: 0,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
textStyle: GoogleFonts.inter(fontWeight: FontWeight.bold),
|
||||
),
|
||||
static TextButtonThemeData _buildTextButtonTheme(SojornBrandColors brand) {
|
||||
return TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: brand.egyptianBlue,
|
||||
textStyle: GoogleFonts.inter(fontWeight: FontWeight.w600),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: egyptianBlue,
|
||||
textStyle: GoogleFonts.inter(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
static BottomNavigationBarThemeData _buildBottomNavTheme(
|
||||
SojornBrandColors brand,
|
||||
SojornThemeOptions options,
|
||||
) {
|
||||
return BottomNavigationBarThemeData(
|
||||
backgroundColor: brand.cardSurface,
|
||||
selectedItemColor: brand.royalPurple,
|
||||
unselectedItemColor: SojornColors.bottomNavUnselected,
|
||||
type: BottomNavigationBarType.fixed,
|
||||
showSelectedLabels: options.showBottomNavLabels,
|
||||
showUnselectedLabels: options.showBottomNavLabels,
|
||||
elevation: options.bottomNavElevation,
|
||||
);
|
||||
}
|
||||
|
||||
// Bottom Nav
|
||||
bottomNavigationBarTheme: BottomNavigationBarThemeData(
|
||||
backgroundColor: cardSurface,
|
||||
selectedItemColor: royalPurple,
|
||||
unselectedItemColor: const Color(0xFF9EA3B0),
|
||||
type: BottomNavigationBarType.fixed,
|
||||
showSelectedLabels: !isPop,
|
||||
showUnselectedLabels: !isPop,
|
||||
elevation: isPop ? 10 : 0,
|
||||
),
|
||||
static FloatingActionButtonThemeData _buildFabTheme(
|
||||
SojornBrandColors brand,
|
||||
SojornThemeOptions options,
|
||||
) {
|
||||
return FloatingActionButtonThemeData(
|
||||
backgroundColor: brand.brightNavy,
|
||||
foregroundColor: SojornColors.textOnAccent,
|
||||
elevation: options.fabElevation,
|
||||
shape: options.fabShape,
|
||||
);
|
||||
}
|
||||
|
||||
// Floating Action Button
|
||||
floatingActionButtonTheme: FloatingActionButtonThemeData(
|
||||
backgroundColor: brightNavy,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: isPop ? 4 : 6,
|
||||
shape: isPop ? const CircleBorder() : null,
|
||||
),
|
||||
static DividerThemeData _buildDividerTheme(
|
||||
SojornBrandColors brand,
|
||||
SojornFlowLines lines,
|
||||
) {
|
||||
return DividerThemeData(
|
||||
color: brand.queenPink,
|
||||
thickness: lines.divider,
|
||||
space: SojornSpacing.lg,
|
||||
);
|
||||
}
|
||||
|
||||
// Divider (Hard & Visible)
|
||||
dividerTheme: DividerThemeData(
|
||||
color: queenPink,
|
||||
thickness: 1,
|
||||
space: 24,
|
||||
static InputDecorationTheme _buildInputTheme(
|
||||
SojornBrandColors brand,
|
||||
SojornFlowLines lines,
|
||||
) {
|
||||
return InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: brand.cardSurface,
|
||||
contentPadding: const EdgeInsets.all(SojornSpacing.md),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(SojornRadii.md),
|
||||
borderSide: BorderSide(color: brand.egyptianBlue, width: lines.inputBorder),
|
||||
),
|
||||
|
||||
// Input Fields
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: cardSurface,
|
||||
contentPadding: const EdgeInsets.all(16),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(color: egyptianBlue, width: 1),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(color: egyptianBlue, width: 1),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(color: royalPurple, width: 2),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(SojornRadii.md),
|
||||
borderSide: BorderSide(color: brand.egyptianBlue, width: lines.inputBorder),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(SojornRadii.md),
|
||||
borderSide: BorderSide(color: brand.royalPurple, width: lines.inputFocusBorder),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'theme_extensions.dart';
|
||||
|
||||
@Deprecated('Use Theme.of(context).extension<SojornExt>()!.feedPalettes instead.')
|
||||
class sojornFeedPalette {
|
||||
final Color backgroundTop;
|
||||
final Color backgroundBottom;
|
||||
|
|
@ -17,51 +20,30 @@ class sojornFeedPalette {
|
|||
required this.accentColor,
|
||||
});
|
||||
|
||||
static const List<sojornFeedPalette> presets = [
|
||||
sojornFeedPalette(
|
||||
backgroundTop: Color(0xFF0B1023),
|
||||
backgroundBottom: Color(0xFF1B2340),
|
||||
panelColor: Color(0xFF0E1328),
|
||||
textColor: Color(0xFFF8FAFF),
|
||||
subTextColor: Color(0xFFB9C3E6),
|
||||
accentColor: Color(0xFF70A7FF),
|
||||
static const SojornFeedPalettes _palettes = SojornFeedPalettes.defaultPresets;
|
||||
|
||||
static final List<sojornFeedPalette> presets = List<sojornFeedPalette>.unmodifiable(
|
||||
_palettes.presets.map(
|
||||
(palette) => sojornFeedPalette(
|
||||
backgroundTop: palette.backgroundTop,
|
||||
backgroundBottom: palette.backgroundBottom,
|
||||
panelColor: palette.panelColor,
|
||||
textColor: palette.textColor,
|
||||
subTextColor: palette.subTextColor,
|
||||
accentColor: palette.accentColor,
|
||||
),
|
||||
),
|
||||
sojornFeedPalette(
|
||||
backgroundTop: Color(0xFF0E1A16),
|
||||
backgroundBottom: Color(0xFF1E3A2D),
|
||||
panelColor: Color(0xFF12261F),
|
||||
textColor: Color(0xFFF5FFF8),
|
||||
subTextColor: Color(0xFFB7D7C6),
|
||||
accentColor: Color(0xFF5FD7A1),
|
||||
),
|
||||
sojornFeedPalette(
|
||||
backgroundTop: Color(0xFF1B0E16),
|
||||
backgroundBottom: Color(0xFF3A1B2B),
|
||||
panelColor: Color(0xFF24101A),
|
||||
textColor: Color(0xFFFFF6F9),
|
||||
subTextColor: Color(0xFFE0B9C6),
|
||||
accentColor: Color(0xFFF28FB3),
|
||||
),
|
||||
sojornFeedPalette(
|
||||
backgroundTop: Color(0xFF0B1720),
|
||||
backgroundBottom: Color(0xFF193547),
|
||||
panelColor: Color(0xFF10212D),
|
||||
textColor: Color(0xFFEFF7FF),
|
||||
subTextColor: Color(0xFFAFC6D9),
|
||||
accentColor: Color(0xFF6FD3FF),
|
||||
),
|
||||
sojornFeedPalette(
|
||||
backgroundTop: Color(0xFF201A12),
|
||||
backgroundBottom: Color(0xFF3A2C1B),
|
||||
panelColor: Color(0xFF221B13),
|
||||
textColor: Color(0xFFFFF7ED),
|
||||
subTextColor: Color(0xFFD9C8AE),
|
||||
accentColor: Color(0xFFFFC074),
|
||||
),
|
||||
];
|
||||
);
|
||||
|
||||
static sojornFeedPalette forId(String id) {
|
||||
final index = id.hashCode.abs() % presets.length;
|
||||
return presets[index];
|
||||
final palette = _palettes.forId(id);
|
||||
return sojornFeedPalette(
|
||||
backgroundTop: palette.backgroundTop,
|
||||
backgroundBottom: palette.backgroundBottom,
|
||||
panelColor: palette.panelColor,
|
||||
textColor: palette.textColor,
|
||||
subTextColor: palette.subTextColor,
|
||||
accentColor: palette.accentColor,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
341
sojorn_app/lib/theme/theme_extensions.dart
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
import 'dart:ui' show lerpDouble;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'tokens.dart';
|
||||
|
||||
@immutable
|
||||
class SojornBrandColors {
|
||||
final Color navyBlue;
|
||||
final Color navyText;
|
||||
final Color egyptianBlue;
|
||||
final Color brightNavy;
|
||||
final Color royalPurple;
|
||||
final Color ksuPurple;
|
||||
final Color queenPink;
|
||||
final Color queenPinkLight;
|
||||
final Color scaffoldBg;
|
||||
final Color cardSurface;
|
||||
|
||||
const SojornBrandColors({
|
||||
required this.navyBlue,
|
||||
required this.navyText,
|
||||
required this.egyptianBlue,
|
||||
required this.brightNavy,
|
||||
required this.royalPurple,
|
||||
required this.ksuPurple,
|
||||
required this.queenPink,
|
||||
required this.queenPinkLight,
|
||||
required this.scaffoldBg,
|
||||
required this.cardSurface,
|
||||
});
|
||||
|
||||
SojornBrandColors lerp(SojornBrandColors other, double t) {
|
||||
return SojornBrandColors(
|
||||
navyBlue: Color.lerp(navyBlue, other.navyBlue, t)!,
|
||||
navyText: Color.lerp(navyText, other.navyText, t)!,
|
||||
egyptianBlue: Color.lerp(egyptianBlue, other.egyptianBlue, t)!,
|
||||
brightNavy: Color.lerp(brightNavy, other.brightNavy, t)!,
|
||||
royalPurple: Color.lerp(royalPurple, other.royalPurple, t)!,
|
||||
ksuPurple: Color.lerp(ksuPurple, other.ksuPurple, t)!,
|
||||
queenPink: Color.lerp(queenPink, other.queenPink, t)!,
|
||||
queenPinkLight: Color.lerp(queenPinkLight, other.queenPinkLight, t)!,
|
||||
scaffoldBg: Color.lerp(scaffoldBg, other.scaffoldBg, t)!,
|
||||
cardSurface: Color.lerp(cardSurface, other.cardSurface, t)!,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class SojornTrustTierColors {
|
||||
final Color established;
|
||||
final Color trusted;
|
||||
final Color fresh;
|
||||
|
||||
const SojornTrustTierColors({
|
||||
required this.established,
|
||||
required this.trusted,
|
||||
required this.fresh,
|
||||
});
|
||||
|
||||
SojornTrustTierColors lerp(SojornTrustTierColors other, double t) {
|
||||
return SojornTrustTierColors(
|
||||
established: Color.lerp(established, other.established, t)!,
|
||||
trusted: Color.lerp(trusted, other.trusted, t)!,
|
||||
fresh: Color.lerp(fresh, other.fresh, t)!,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class SojornFlowLines {
|
||||
final double appBarBorder;
|
||||
final double cardBorder;
|
||||
final double inputBorder;
|
||||
final double inputFocusBorder;
|
||||
final double divider;
|
||||
final double flow;
|
||||
|
||||
const SojornFlowLines({
|
||||
required this.appBarBorder,
|
||||
required this.cardBorder,
|
||||
required this.inputBorder,
|
||||
required this.inputFocusBorder,
|
||||
required this.divider,
|
||||
required this.flow,
|
||||
});
|
||||
|
||||
SojornFlowLines lerp(SojornFlowLines other, double t) {
|
||||
return SojornFlowLines(
|
||||
appBarBorder: lerpDouble(appBarBorder, other.appBarBorder, t)!,
|
||||
cardBorder: lerpDouble(cardBorder, other.cardBorder, t)!,
|
||||
inputBorder: lerpDouble(inputBorder, other.inputBorder, t)!,
|
||||
inputFocusBorder: lerpDouble(inputFocusBorder, other.inputFocusBorder, t)!,
|
||||
divider: lerpDouble(divider, other.divider, t)!,
|
||||
flow: lerpDouble(flow, other.flow, t)!,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class SojornThemeOptions {
|
||||
final bool showBottomNavLabels;
|
||||
final double bottomNavElevation;
|
||||
final double fabElevation;
|
||||
final ShapeBorder? fabShape;
|
||||
|
||||
const SojornThemeOptions({
|
||||
required this.showBottomNavLabels,
|
||||
required this.bottomNavElevation,
|
||||
required this.fabElevation,
|
||||
required this.fabShape,
|
||||
});
|
||||
|
||||
SojornThemeOptions lerp(SojornThemeOptions other, double t) {
|
||||
return SojornThemeOptions(
|
||||
showBottomNavLabels: t < 0.5 ? showBottomNavLabels : other.showBottomNavLabels,
|
||||
bottomNavElevation: lerpDouble(bottomNavElevation, other.bottomNavElevation, t)!,
|
||||
fabElevation: lerpDouble(fabElevation, other.fabElevation, t)!,
|
||||
fabShape: ShapeBorder.lerp(fabShape, other.fabShape, t),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class SojornFeedPalette {
|
||||
final Color backgroundTop;
|
||||
final Color backgroundBottom;
|
||||
final Color panelColor;
|
||||
final Color textColor;
|
||||
final Color subTextColor;
|
||||
final Color accentColor;
|
||||
|
||||
const SojornFeedPalette({
|
||||
required this.backgroundTop,
|
||||
required this.backgroundBottom,
|
||||
required this.panelColor,
|
||||
required this.textColor,
|
||||
required this.subTextColor,
|
||||
required this.accentColor,
|
||||
});
|
||||
|
||||
SojornFeedPalette lerp(SojornFeedPalette other, double t) {
|
||||
return SojornFeedPalette(
|
||||
backgroundTop: Color.lerp(backgroundTop, other.backgroundTop, t)!,
|
||||
backgroundBottom: Color.lerp(backgroundBottom, other.backgroundBottom, t)!,
|
||||
panelColor: Color.lerp(panelColor, other.panelColor, t)!,
|
||||
textColor: Color.lerp(textColor, other.textColor, t)!,
|
||||
subTextColor: Color.lerp(subTextColor, other.subTextColor, t)!,
|
||||
accentColor: Color.lerp(accentColor, other.accentColor, t)!,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class SojornFeedPalettes {
|
||||
final List<SojornFeedPalette> presets;
|
||||
|
||||
const SojornFeedPalettes({required this.presets});
|
||||
|
||||
static const SojornFeedPalettes defaultPresets = SojornFeedPalettes(
|
||||
presets: [
|
||||
SojornFeedPalette(
|
||||
backgroundTop: SojornColors.feedNavyTop,
|
||||
backgroundBottom: SojornColors.feedNavyBottom,
|
||||
panelColor: SojornColors.feedNavyPanel,
|
||||
textColor: SojornColors.feedNavyText,
|
||||
subTextColor: SojornColors.feedNavySubText,
|
||||
accentColor: SojornColors.feedNavyAccent,
|
||||
),
|
||||
SojornFeedPalette(
|
||||
backgroundTop: SojornColors.feedForestTop,
|
||||
backgroundBottom: SojornColors.feedForestBottom,
|
||||
panelColor: SojornColors.feedForestPanel,
|
||||
textColor: SojornColors.feedForestText,
|
||||
subTextColor: SojornColors.feedForestSubText,
|
||||
accentColor: SojornColors.feedForestAccent,
|
||||
),
|
||||
SojornFeedPalette(
|
||||
backgroundTop: SojornColors.feedRoseTop,
|
||||
backgroundBottom: SojornColors.feedRoseBottom,
|
||||
panelColor: SojornColors.feedRosePanel,
|
||||
textColor: SojornColors.feedRoseText,
|
||||
subTextColor: SojornColors.feedRoseSubText,
|
||||
accentColor: SojornColors.feedRoseAccent,
|
||||
),
|
||||
SojornFeedPalette(
|
||||
backgroundTop: SojornColors.feedSkyTop,
|
||||
backgroundBottom: SojornColors.feedSkyBottom,
|
||||
panelColor: SojornColors.feedSkyPanel,
|
||||
textColor: SojornColors.feedSkyText,
|
||||
subTextColor: SojornColors.feedSkySubText,
|
||||
accentColor: SojornColors.feedSkyAccent,
|
||||
),
|
||||
SojornFeedPalette(
|
||||
backgroundTop: SojornColors.feedAmberTop,
|
||||
backgroundBottom: SojornColors.feedAmberBottom,
|
||||
panelColor: SojornColors.feedAmberPanel,
|
||||
textColor: SojornColors.feedAmberText,
|
||||
subTextColor: SojornColors.feedAmberSubText,
|
||||
accentColor: SojornColors.feedAmberAccent,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
SojornFeedPalette forId(String id) {
|
||||
final index = id.hashCode.abs() % presets.length;
|
||||
return presets[index];
|
||||
}
|
||||
|
||||
SojornFeedPalettes lerp(SojornFeedPalettes other, double t) {
|
||||
if (presets.length != other.presets.length) {
|
||||
return t < 0.5 ? this : other;
|
||||
}
|
||||
|
||||
return SojornFeedPalettes(
|
||||
presets: List<SojornFeedPalette>.generate(
|
||||
presets.length,
|
||||
(index) => presets[index].lerp(other.presets[index], t),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class SojornExt extends ThemeExtension<SojornExt> {
|
||||
final SojornBrandColors brandColors;
|
||||
final SojornTrustTierColors trustTierColors;
|
||||
final SojornFlowLines flowLines;
|
||||
final SojornFeedPalettes feedPalettes;
|
||||
final SojornThemeOptions options;
|
||||
|
||||
const SojornExt({
|
||||
required this.brandColors,
|
||||
required this.trustTierColors,
|
||||
required this.flowLines,
|
||||
required this.feedPalettes,
|
||||
required this.options,
|
||||
});
|
||||
|
||||
static const SojornExt basic = SojornExt(
|
||||
brandColors: SojornBrandColors(
|
||||
navyBlue: SojornColors.basicNavyBlue,
|
||||
navyText: SojornColors.basicNavyText,
|
||||
egyptianBlue: SojornColors.basicEgyptianBlue,
|
||||
brightNavy: SojornColors.basicBrightNavy,
|
||||
royalPurple: SojornColors.basicRoyalPurple,
|
||||
ksuPurple: SojornColors.basicKsuPurple,
|
||||
queenPink: SojornColors.basicQueenPink,
|
||||
queenPinkLight: SojornColors.basicQueenPinkLight,
|
||||
scaffoldBg: SojornColors.basicQueenPinkLight,
|
||||
cardSurface: SojornColors.basicWhite,
|
||||
),
|
||||
trustTierColors: SojornTrustTierColors(
|
||||
established: SojornColors.basicEgyptianBlue,
|
||||
trusted: SojornColors.basicRoyalPurple,
|
||||
fresh: SojornColors.tierNew,
|
||||
),
|
||||
flowLines: SojornFlowLines(
|
||||
appBarBorder: SojornLines.border,
|
||||
cardBorder: SojornLines.borderThin,
|
||||
inputBorder: SojornLines.borderThin,
|
||||
inputFocusBorder: SojornLines.borderStrong,
|
||||
divider: SojornLines.divider,
|
||||
flow: SojornLines.flow,
|
||||
),
|
||||
feedPalettes: SojornFeedPalettes.defaultPresets,
|
||||
options: SojornThemeOptions(
|
||||
showBottomNavLabels: true,
|
||||
bottomNavElevation: 0,
|
||||
fabElevation: 6,
|
||||
fabShape: null,
|
||||
),
|
||||
);
|
||||
|
||||
static const SojornExt pop = SojornExt(
|
||||
brandColors: SojornBrandColors(
|
||||
navyBlue: SojornColors.popNavyBlue,
|
||||
navyText: SojornColors.popNavyText,
|
||||
egyptianBlue: SojornColors.popEgyptianBlue,
|
||||
brightNavy: SojornColors.popBrightNavy,
|
||||
royalPurple: SojornColors.popRoyalPurple,
|
||||
ksuPurple: SojornColors.popKsuPurple,
|
||||
queenPink: SojornColors.popHighlight,
|
||||
queenPinkLight: SojornColors.popScaffoldBg,
|
||||
scaffoldBg: SojornColors.popScaffoldBg,
|
||||
cardSurface: SojornColors.popCardSurface,
|
||||
),
|
||||
trustTierColors: SojornTrustTierColors(
|
||||
established: SojornColors.popEgyptianBlue,
|
||||
trusted: SojornColors.popRoyalPurple,
|
||||
fresh: SojornColors.tierNew,
|
||||
),
|
||||
flowLines: SojornFlowLines(
|
||||
appBarBorder: SojornLines.borderStrong,
|
||||
cardBorder: SojornLines.borderThin,
|
||||
inputBorder: SojornLines.borderThin,
|
||||
inputFocusBorder: SojornLines.borderStrong,
|
||||
divider: SojornLines.divider,
|
||||
flow: SojornLines.flow,
|
||||
),
|
||||
feedPalettes: SojornFeedPalettes.defaultPresets,
|
||||
options: SojornThemeOptions(
|
||||
showBottomNavLabels: false,
|
||||
bottomNavElevation: 10,
|
||||
fabElevation: 4,
|
||||
fabShape: const CircleBorder(),
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
SojornExt copyWith({
|
||||
SojornBrandColors? brandColors,
|
||||
SojornTrustTierColors? trustTierColors,
|
||||
SojornFlowLines? flowLines,
|
||||
SojornFeedPalettes? feedPalettes,
|
||||
SojornThemeOptions? options,
|
||||
}) {
|
||||
return SojornExt(
|
||||
brandColors: brandColors ?? this.brandColors,
|
||||
trustTierColors: trustTierColors ?? this.trustTierColors,
|
||||
flowLines: flowLines ?? this.flowLines,
|
||||
feedPalettes: feedPalettes ?? this.feedPalettes,
|
||||
options: options ?? this.options,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
SojornExt lerp(ThemeExtension<SojornExt>? other, double t) {
|
||||
if (other is! SojornExt) {
|
||||
return this;
|
||||
}
|
||||
|
||||
return SojornExt(
|
||||
brandColors: brandColors.lerp(other.brandColors, t),
|
||||
trustTierColors: trustTierColors.lerp(other.trustTierColors, t),
|
||||
flowLines: flowLines.lerp(other.flowLines, t),
|
||||
feedPalettes: feedPalettes.lerp(other.feedPalettes, t),
|
||||
options: options.lerp(other.options, t),
|
||||
);
|
||||
}
|
||||
}
|
||||
111
sojorn_app/lib/theme/tokens.dart
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class SojornColors {
|
||||
const SojornColors._();
|
||||
|
||||
// Basic theme palette.
|
||||
static const Color basicNavyBlue = Color(0xFF000383);
|
||||
static const Color basicNavyText = Color(0xFF000383);
|
||||
static const Color basicEgyptianBlue = Color(0xFF0E38AE);
|
||||
static const Color basicBrightNavy = Color(0xFF1974D1);
|
||||
static const Color basicRoyalPurple = Color(0xFF7751A8);
|
||||
static const Color basicKsuPurple = Color(0xFF512889);
|
||||
static const Color basicQueenPink = Color(0xFFE5C0DD);
|
||||
static const Color basicQueenPinkLight = Color(0xFFF9F2F7);
|
||||
static const Color basicWhite = Color(0xFFFFFFFF);
|
||||
|
||||
// Pop theme palette.
|
||||
static const Color popNavyBlue = Color(0xFF000383);
|
||||
static const Color popNavyText = Color(0xFF0D1050);
|
||||
static const Color popEgyptianBlue = Color(0xFF0E38AE);
|
||||
static const Color popBrightNavy = Color(0xFF1974D1);
|
||||
static const Color popRoyalPurple = Color(0xFF7751A8);
|
||||
static const Color popKsuPurple = Color(0xFF512889);
|
||||
static const Color popScaffoldBg = Color(0xFFF9F6F9);
|
||||
static const Color popCardSurface = Color(0xFFFFFFFF);
|
||||
static const Color popHighlight = Color(0xFFE5C0DD);
|
||||
|
||||
// Semantic colors.
|
||||
static const Color error = Color(0xFFD32F2F);
|
||||
static const Color warning = Color(0xFFFBC02D);
|
||||
static const Color info = Color(0xFF2196F3);
|
||||
static const Color textDisabled = Color(0xFF9E9E9E);
|
||||
static const Color textOnAccent = Color(0xFFFFFFFF);
|
||||
|
||||
static const Color postContent = Color(0xFF1A1A1A);
|
||||
static const Color postContentLight = Color(0xFF4A4A4A);
|
||||
static const Color bottomNavUnselected = Color(0xFF9EA3B0);
|
||||
static const Color tierNew = Color(0xFF9E9E9E);
|
||||
|
||||
// Feed palettes.
|
||||
static const Color feedNavyTop = Color(0xFF0B1023);
|
||||
static const Color feedNavyBottom = Color(0xFF1B2340);
|
||||
static const Color feedNavyPanel = Color(0xFF0E1328);
|
||||
static const Color feedNavyText = Color(0xFFF8FAFF);
|
||||
static const Color feedNavySubText = Color(0xFFB9C3E6);
|
||||
static const Color feedNavyAccent = Color(0xFF70A7FF);
|
||||
|
||||
static const Color feedForestTop = Color(0xFF0E1A16);
|
||||
static const Color feedForestBottom = Color(0xFF1E3A2D);
|
||||
static const Color feedForestPanel = Color(0xFF12261F);
|
||||
static const Color feedForestText = Color(0xFFF5FFF8);
|
||||
static const Color feedForestSubText = Color(0xFFB7D7C6);
|
||||
static const Color feedForestAccent = Color(0xFF5FD7A1);
|
||||
|
||||
static const Color feedRoseTop = Color(0xFF1B0E16);
|
||||
static const Color feedRoseBottom = Color(0xFF3A1B2B);
|
||||
static const Color feedRosePanel = Color(0xFF24101A);
|
||||
static const Color feedRoseText = Color(0xFFFFF6F9);
|
||||
static const Color feedRoseSubText = Color(0xFFE0B9C6);
|
||||
static const Color feedRoseAccent = Color(0xFFF28FB3);
|
||||
|
||||
static const Color feedSkyTop = Color(0xFF0B1720);
|
||||
static const Color feedSkyBottom = Color(0xFF193547);
|
||||
static const Color feedSkyPanel = Color(0xFF10212D);
|
||||
static const Color feedSkyText = Color(0xFFEFF7FF);
|
||||
static const Color feedSkySubText = Color(0xFFAFC6D9);
|
||||
static const Color feedSkyAccent = Color(0xFF6FD3FF);
|
||||
|
||||
static const Color feedAmberTop = Color(0xFF201A12);
|
||||
static const Color feedAmberBottom = Color(0xFF3A2C1B);
|
||||
static const Color feedAmberPanel = Color(0xFF221B13);
|
||||
static const Color feedAmberText = Color(0xFFFFF7ED);
|
||||
static const Color feedAmberSubText = Color(0xFFD9C8AE);
|
||||
static const Color feedAmberAccent = Color(0xFFFFC074);
|
||||
}
|
||||
|
||||
class SojornSpacing {
|
||||
const SojornSpacing._();
|
||||
|
||||
static const double xxs = 2.0;
|
||||
static const double xs = 4.0;
|
||||
static const double sm = 12.0;
|
||||
static const double md = 16.0;
|
||||
static const double lg = 24.0;
|
||||
static const double xl = 32.0;
|
||||
|
||||
static const double postShort = 16.0;
|
||||
static const double postMedium = 24.0;
|
||||
static const double postLong = 32.0;
|
||||
}
|
||||
|
||||
class SojornRadii {
|
||||
const SojornRadii._();
|
||||
|
||||
static const double xs = 2.0;
|
||||
static const double sm = 4.0;
|
||||
static const double md = 8.0;
|
||||
static const double lg = 12.0;
|
||||
static const double full = 36.0;
|
||||
}
|
||||
|
||||
class SojornLines {
|
||||
const SojornLines._();
|
||||
|
||||
static const double borderThin = 1.0;
|
||||
static const double border = 1.5;
|
||||
static const double borderStrong = 2.0;
|
||||
static const double divider = 1.0;
|
||||
static const double dividerStrong = 2.0;
|
||||
static const double flow = 3.0;
|
||||
}
|
||||
|
|
@ -26,7 +26,7 @@ class ComposeAndChatFab extends StatelessWidget {
|
|||
heroTag: chatHeroTag,
|
||||
tooltip: 'Messages',
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const SecureChatFullScreen(),
|
||||
fullscreenDialog: true,
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ class _SignedMediaImageState extends ConsumerState<SignedMediaImage> {
|
|||
|
||||
if (_shouldSign) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_refreshSignedUrl();
|
||||
});
|
||||
}
|
||||
|
|
@ -91,13 +92,16 @@ class _SignedMediaImageState extends ConsumerState<SignedMediaImage> {
|
|||
}
|
||||
|
||||
Future<void> _refreshSignedUrl() async {
|
||||
if (!mounted) return;
|
||||
if (_refreshing || _hasRefreshed) return;
|
||||
setState(() {
|
||||
_refreshing = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final apiService = ref.read(apiServiceProvider);
|
||||
final apiService =
|
||||
ProviderScope.containerOf(context, listen: false)
|
||||
.read(apiServiceProvider);
|
||||
final signedUrl = await apiService.getSignedMediaUrl(widget.url);
|
||||
if (!mounted) return;
|
||||
if (signedUrl != null && signedUrl.isNotEmpty) {
|
||||
|
|
@ -147,6 +151,7 @@ class _SignedMediaImageState extends ConsumerState<SignedMediaImage> {
|
|||
errorBuilder: (context, error, stackTrace) {
|
||||
if (_shouldSign && !_refreshing && !_hasRefreshed) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_refreshSignedUrl();
|
||||
});
|
||||
return _buildLoading(context);
|
||||
|
|
|
|||
560
sojorn_app/lib/widgets/post/interactive_reply_block.dart
Normal file
|
|
@ -0,0 +1,560 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import '../../models/post.dart';
|
||||
import '../../theme/app_theme.dart';
|
||||
import '../media/signed_media_image.dart';
|
||||
|
||||
enum BlockState {
|
||||
idle, // Preview mode - minimal display
|
||||
active, // Inspector mode - expanded with full details
|
||||
}
|
||||
|
||||
/// InteractiveReplyBlock - A 2-stage interactive widget for reply posts
|
||||
/// Stage 1 (Idle/Preview): Minimalist block with avatar + truncated preview
|
||||
/// Stage 2 (Active/Inspector): Expanded block with full content and actions
|
||||
class InteractiveReplyBlock extends StatefulWidget {
|
||||
final Post post;
|
||||
final VoidCallback? onTap;
|
||||
final BlockState initialState;
|
||||
final bool isSelected;
|
||||
final bool compactPreview;
|
||||
final Widget? reactionStrip;
|
||||
final bool? isLikedOverride;
|
||||
final VoidCallback? onToggleLike;
|
||||
|
||||
const InteractiveReplyBlock({
|
||||
super.key,
|
||||
required this.post,
|
||||
this.onTap,
|
||||
this.initialState = BlockState.idle,
|
||||
this.isSelected = false,
|
||||
this.compactPreview = false,
|
||||
this.reactionStrip,
|
||||
this.isLikedOverride,
|
||||
this.onToggleLike,
|
||||
});
|
||||
|
||||
@override
|
||||
State<InteractiveReplyBlock> createState() => _InteractiveReplyBlockState();
|
||||
}
|
||||
|
||||
class _InteractiveReplyBlockState extends State<InteractiveReplyBlock>
|
||||
with TickerProviderStateMixin {
|
||||
late BlockState _currentState;
|
||||
late AnimationController _expandController;
|
||||
late AnimationController _bounceController;
|
||||
late Animation<double> _expandAnimation;
|
||||
late Animation<double> _bounceAnimation;
|
||||
|
||||
static const Duration _expandDuration = Duration(milliseconds: 400);
|
||||
static const Duration _bounceDuration = Duration(milliseconds: 600);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_currentState = widget.initialState;
|
||||
_initializeAnimations();
|
||||
}
|
||||
|
||||
void _initializeAnimations() {
|
||||
_expandController = AnimationController(
|
||||
duration: _expandDuration,
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_bounceController = AnimationController(
|
||||
duration: _bounceDuration,
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_expandAnimation = CurvedAnimation(
|
||||
parent: _expandController,
|
||||
curve: Curves.easeOutBack,
|
||||
reverseCurve: Curves.easeInCubic,
|
||||
);
|
||||
|
||||
_bounceAnimation = Tween<double>(
|
||||
begin: 0.95,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _bounceController,
|
||||
curve: Curves.elasticOut,
|
||||
));
|
||||
|
||||
if (_currentState == BlockState.active) {
|
||||
_expandController.value = 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_expandController.dispose();
|
||||
_bounceController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleTap() {
|
||||
if (widget.compactPreview) {
|
||||
widget.onTap?.call();
|
||||
return;
|
||||
}
|
||||
if (_currentState == BlockState.idle) {
|
||||
_transitionToActive();
|
||||
} else {
|
||||
widget.onTap?.call();
|
||||
}
|
||||
}
|
||||
|
||||
void _transitionToActive() {
|
||||
setState(() => _currentState = BlockState.active);
|
||||
_expandController.forward();
|
||||
_bounceController.forward(from: 0.0);
|
||||
|
||||
// Haptic feedback for tactile response
|
||||
// HapticFeedback.mediumImpact(); // Uncomment if haptic feedback is desired
|
||||
}
|
||||
|
||||
void _transitionToIdle() {
|
||||
setState(() => _currentState = BlockState.idle);
|
||||
_expandController.reverse();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isActive = _currentState == BlockState.active;
|
||||
if (widget.compactPreview && !isActive) {
|
||||
return SizedBox(
|
||||
width: 160,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: _handleTap,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: AnimatedContainer(
|
||||
duration: _expandDuration,
|
||||
curve: Curves.easeOutBack,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.cardSurface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: AppTheme.brightNavy,
|
||||
width: 1.6,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppTheme.brightNavy.withValues(alpha: 0.14),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
_buildCompactAvatar(),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.post.author?.displayName ?? 'Anonymous',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: GoogleFonts.inter(
|
||||
color: AppTheme.textPrimary,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_compactLabel(),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: GoogleFonts.inter(
|
||||
color: AppTheme.navyText.withValues(alpha: 0.75),
|
||||
fontSize: 12,
|
||||
height: 1.35,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (widget.reactionStrip != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
widget.reactionStrip!,
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: Listenable.merge([_expandAnimation, _bounceAnimation]),
|
||||
builder: (context, child) {
|
||||
final scale = _bounceAnimation.value;
|
||||
|
||||
return Transform.scale(
|
||||
scale: scale,
|
||||
child: AnimatedContainer(
|
||||
duration: _expandDuration,
|
||||
curve: Curves.easeOutBack,
|
||||
margin: EdgeInsets.symmetric(
|
||||
vertical: isActive ? 8.0 : 4.0,
|
||||
horizontal: 16.0,
|
||||
),
|
||||
padding: EdgeInsets.all(
|
||||
isActive ? 20.0 : 16.0,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: widget.isSelected
|
||||
? AppTheme.brightNavy.withValues(alpha: 0.08)
|
||||
: AppTheme.cardSurface,
|
||||
borderRadius: BorderRadius.circular(
|
||||
isActive ? 24.0 : 18.0,
|
||||
),
|
||||
border: Border.all(
|
||||
color: widget.isSelected
|
||||
? AppTheme.brightNavy
|
||||
: isActive
|
||||
? AppTheme.brightNavy.withValues(alpha: 0.4)
|
||||
: AppTheme.navyBlue.withValues(alpha: 0.12),
|
||||
width: widget.isSelected ? 2.0 : (isActive ? 1.6 : 1.2),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: widget.isSelected
|
||||
? AppTheme.brightNavy.withValues(alpha: 0.25)
|
||||
: AppTheme.navyBlue.withValues(alpha: isActive ? 0.14 : 0.06),
|
||||
blurRadius: isActive ? 24.0 : 14.0,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
if (isActive)
|
||||
BoxShadow(
|
||||
color: AppTheme.brightNavy.withValues(alpha: 0.1),
|
||||
blurRadius: 40,
|
||||
offset: const Offset(0, 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: _handleTap,
|
||||
borderRadius: BorderRadius.circular(
|
||||
isActive ? 24.0 : 18.0,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildHeader(isActive),
|
||||
if (isActive) ...[
|
||||
const SizedBox(height: 12),
|
||||
_buildFullContent(),
|
||||
const SizedBox(height: 16),
|
||||
_buildEngagementStats(),
|
||||
if (widget.reactionStrip != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
widget.reactionStrip!,
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
_buildActionButtons(),
|
||||
] else ...[
|
||||
const SizedBox(height: 8),
|
||||
_buildPreviewContent(),
|
||||
if (widget.reactionStrip != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
widget.reactionStrip!,
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
).animate(target: _currentState == BlockState.active ? 1 : 0).fadeIn(
|
||||
duration: 300.ms,
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(bool isActive) {
|
||||
return Row(
|
||||
children: [
|
||||
_buildAvatar(isActive),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.post.author?.displayName ?? 'Anonymous',
|
||||
style: GoogleFonts.inter(
|
||||
color: AppTheme.textPrimary,
|
||||
fontSize: isActive ? 16 : 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
if (isActive) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'@${widget.post.author?.handle ?? 'anonymous'}',
|
||||
style: GoogleFonts.inter(
|
||||
color: AppTheme.textSecondary,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isActive)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.egyptianBlue.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.expand_more,
|
||||
size: 16,
|
||||
color: AppTheme.egyptianBlue,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAvatar(bool isActive) {
|
||||
final avatarUrl = widget.post.author?.avatarUrl;
|
||||
final hasAvatar = avatarUrl != null && avatarUrl.trim().isNotEmpty;
|
||||
final size = isActive ? 40.0 : 32.0;
|
||||
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.brightNavy.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(isActive ? 14 : 10),
|
||||
),
|
||||
child: !hasAvatar
|
||||
? Center(
|
||||
child: Text(
|
||||
_initialForName(widget.post.author?.displayName),
|
||||
style: GoogleFonts.inter(
|
||||
color: AppTheme.brightNavy,
|
||||
fontSize: isActive ? 14 : 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
)
|
||||
: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(isActive ? 14 : 10),
|
||||
child: SignedMediaImage(
|
||||
url: avatarUrl!,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPreviewContent() {
|
||||
final previewText = _getPreviewText(widget.post.body);
|
||||
|
||||
return Text(
|
||||
previewText,
|
||||
style: GoogleFonts.inter(
|
||||
color: AppTheme.navyText.withValues(alpha: 0.8),
|
||||
fontSize: 14,
|
||||
height: 1.4,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFullContent() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.post.body,
|
||||
style: GoogleFonts.inter(
|
||||
color: AppTheme.navyText,
|
||||
fontSize: 16,
|
||||
height: 1.6,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (widget.post.imageUrl != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: SignedMediaImage(
|
||||
url: widget.post.imageUrl!,
|
||||
width: double.infinity,
|
||||
height: 180,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEngagementStats() {
|
||||
return Row(
|
||||
children: [
|
||||
_buildStatItem(
|
||||
icon: Icons.favorite_border,
|
||||
count: widget.post.likeCount ?? 0,
|
||||
color: Colors.red,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_buildStatItem(
|
||||
icon: Icons.chat_bubble_outline,
|
||||
count: widget.post.commentCount ?? 0,
|
||||
color: AppTheme.egyptianBlue,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatItem({
|
||||
required IconData icon,
|
||||
required int count,
|
||||
required Color color,
|
||||
}) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: color.withValues(alpha: 0.7),
|
||||
),
|
||||
if (count > 0) ...[
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
count.toString(),
|
||||
style: GoogleFonts.inter(
|
||||
color: AppTheme.textSecondary,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButtons() {
|
||||
final isLiked = widget.isLikedOverride ?? (widget.post.isLiked ?? false);
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
// Handle reply action
|
||||
widget.onTap?.call();
|
||||
},
|
||||
icon: const Icon(Icons.reply, size: 16),
|
||||
label: const Text('Reply'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.brightNavy,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
onPressed: widget.onToggleLike,
|
||||
icon: Icon(
|
||||
isLiked ? Icons.favorite : Icons.favorite_border,
|
||||
color: isLiked ? Colors.red : AppTheme.textSecondary,
|
||||
),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.08),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _getPreviewText(String fullText) {
|
||||
// Simple preview - take first 80 characters
|
||||
if (fullText.length <= 80) return fullText;
|
||||
|
||||
final preview = fullText.substring(0, 80);
|
||||
final lastSpace = preview.lastIndexOf(' ');
|
||||
|
||||
if (lastSpace > 40) {
|
||||
return '${preview.substring(0, lastSpace)}...';
|
||||
}
|
||||
|
||||
return '$preview...';
|
||||
}
|
||||
|
||||
String _compactLabel() {
|
||||
final trimmed = widget.post.body.trim();
|
||||
if (trimmed.isEmpty) return 'reply..';
|
||||
return _getPreviewText(trimmed);
|
||||
}
|
||||
|
||||
Widget _buildCompactAvatar() {
|
||||
final avatarUrl = widget.post.author?.avatarUrl;
|
||||
return Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.brightNavy.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: avatarUrl == null
|
||||
? Center(
|
||||
child: Text(
|
||||
_initialForName(widget.post.author?.displayName),
|
||||
style: GoogleFonts.inter(
|
||||
color: AppTheme.brightNavy,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
)
|
||||
: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: SignedMediaImage(
|
||||
url: avatarUrl,
|
||||
width: 28,
|
||||
height: 28,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _initialForName(String? name) {
|
||||
final trimmed = name?.trim() ?? '';
|
||||
if (trimmed.isEmpty) return 'S';
|
||||
return trimmed.characters.first.toUpperCase();
|
||||
}
|
||||
}
|
||||
|
|
@ -42,31 +42,33 @@ class PostMedia extends StatelessWidget {
|
|||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: _imageHeight,
|
||||
width: double.infinity,
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: _imageHeight),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
|
||||
child: SignedMediaImage(
|
||||
url: post!.imageUrl!,
|
||||
fit: mode == PostViewMode.feed ? BoxFit.cover : BoxFit.contain,
|
||||
loadingBuilder: (context) => Container(
|
||||
color: AppTheme.queenPink.withValues(alpha: 0.3),
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
errorBuilder: (context, error, stackTrace) => Container(
|
||||
color: Colors.red.withValues(alpha: 0.3),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.broken_image,
|
||||
size: 48, color: Colors.white),
|
||||
const SizedBox(height: 8),
|
||||
Text('Error: $error',
|
||||
style: const TextStyle(
|
||||
color: Colors.white, fontSize: 10)),
|
||||
],
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: SignedMediaImage(
|
||||
url: post!.imageUrl!,
|
||||
fit: BoxFit.contain,
|
||||
loadingBuilder: (context) => Container(
|
||||
color: AppTheme.queenPink.withValues(alpha: 0.3),
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
errorBuilder: (context, error, stackTrace) => Container(
|
||||
color: Colors.red.withValues(alpha: 0.3),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.broken_image,
|
||||
size: 48, color: Colors.white),
|
||||
const SizedBox(height: 8),
|
||||
Text('Error: $error',
|
||||
style: const TextStyle(
|
||||
color: Colors.white, fontSize: 10)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import '../../services/auth_service.dart';
|
|||
import '../../models/post.dart';
|
||||
import '../../providers/api_provider.dart';
|
||||
import '../../theme/app_theme.dart';
|
||||
import '../../theme/sojorn_feed_palette.dart';
|
||||
import '../../theme/theme_extensions.dart';
|
||||
import '../media/signed_media_image.dart';
|
||||
import 'package:timeago/timeago.dart' as timeago;
|
||||
import '../sojorn_snackbar.dart';
|
||||
|
|
@ -163,7 +163,9 @@ class _sojornSwipeablePostState extends ConsumerState<sojornSwipeablePost> {
|
|||
final screenHeight = MediaQuery.of(context).size.height;
|
||||
final hasImage =
|
||||
widget.post.imageUrl != null && widget.post.imageUrl!.isNotEmpty;
|
||||
final palette = sojornFeedPalette.forId(widget.post.id);
|
||||
final palette = Theme.of(context).extension<SojornExt>()!.feedPalettes.forId(
|
||||
widget.post.id,
|
||||
);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
|
|
@ -284,7 +286,7 @@ class _sojornSwipeablePostState extends ConsumerState<sojornSwipeablePost> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildAuthorInfo(sojornFeedPalette palette) {
|
||||
Widget _buildAuthorInfo(SojornFeedPalette palette) {
|
||||
return GestureDetector(
|
||||
onTap: widget.onAuthorTap,
|
||||
child: Row(
|
||||
|
|
@ -347,7 +349,7 @@ class _sojornSwipeablePostState extends ConsumerState<sojornSwipeablePost> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildBackground(sojornFeedPalette palette) {
|
||||
Widget _buildBackground(SojornFeedPalette palette) {
|
||||
if (widget.post.imageUrl != null && widget.post.imageUrl!.isNotEmpty) {
|
||||
return SignedMediaImage(
|
||||
url: widget.post.imageUrl!,
|
||||
|
|
@ -364,7 +366,7 @@ class _sojornSwipeablePostState extends ConsumerState<sojornSwipeablePost> {
|
|||
return _buildGradientBackground(palette);
|
||||
}
|
||||
|
||||
Widget _buildGradientBackground(sojornFeedPalette palette) {
|
||||
Widget _buildGradientBackground(SojornFeedPalette palette) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
|
|
@ -383,7 +385,7 @@ class _sojornSwipeablePostState extends ConsumerState<sojornSwipeablePost> {
|
|||
return AppTheme.tierNew;
|
||||
}
|
||||
|
||||
Widget _buildTextContent(sojornFeedPalette palette,
|
||||
Widget _buildTextContent(SojornFeedPalette palette,
|
||||
{required bool isTextOnly}) {
|
||||
final text = widget.post.body;
|
||||
final maxLines = _textExpanded ? null : (isTextOnly ? 8 : 3);
|
||||
|
|
|
|||
365
sojorn_app/lib/widgets/reactions/reaction_strip.dart
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import '../../theme/app_theme.dart';
|
||||
|
||||
class ReactionStrip extends StatelessWidget {
|
||||
final Map<String, int> reactions;
|
||||
final Set<String> myReactions;
|
||||
final Map<String, List<String>>? reactionUsers;
|
||||
final ValueChanged<String> onToggle;
|
||||
final VoidCallback onAdd;
|
||||
final bool dense;
|
||||
|
||||
const ReactionStrip({
|
||||
super.key,
|
||||
required this.reactions,
|
||||
required this.myReactions,
|
||||
required this.onToggle,
|
||||
required this.onAdd,
|
||||
this.reactionUsers,
|
||||
this.dense = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final keys = reactions.keys.toList()..sort();
|
||||
|
||||
return Wrap(
|
||||
spacing: dense ? 6 : 8,
|
||||
runSpacing: dense ? 6 : 8,
|
||||
children: [
|
||||
for (final reaction in keys)
|
||||
_ReactionChip(
|
||||
reactionId: reaction,
|
||||
count: reactions[reaction] ?? 0,
|
||||
isSelected: myReactions.contains(reaction),
|
||||
tooltipNames: reactionUsers?[reaction],
|
||||
onTap: () => onToggle(reaction),
|
||||
),
|
||||
_ReactionAddButton(onTap: onAdd),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ReactionChip extends StatefulWidget {
|
||||
final String reactionId;
|
||||
final int count;
|
||||
final bool isSelected;
|
||||
final List<String>? tooltipNames;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ReactionChip({
|
||||
required this.reactionId,
|
||||
required this.count,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
this.tooltipNames,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_ReactionChip> createState() => _ReactionChipState();
|
||||
}
|
||||
|
||||
class _ReactionChipState extends State<_ReactionChip> {
|
||||
int _tapCount = 0;
|
||||
|
||||
void _handleTap() {
|
||||
HapticFeedback.selectionClick();
|
||||
setState(() => _tapCount += 1);
|
||||
widget.onTap();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final background = widget.isSelected
|
||||
? AppTheme.brightNavy.withValues(alpha: 0.14)
|
||||
: AppTheme.navyBlue.withValues(alpha: 0.08);
|
||||
final borderColor = widget.isSelected
|
||||
? AppTheme.brightNavy
|
||||
: AppTheme.navyBlue.withValues(alpha: 0.2);
|
||||
|
||||
final chip = InkWell(
|
||||
onTap: _handleTap,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 140),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: borderColor, width: 1.2),
|
||||
boxShadow: widget.isSelected
|
||||
? [
|
||||
BoxShadow(
|
||||
color: AppTheme.brightNavy.withValues(alpha: 0.22),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_ReactionIcon(reactionId: widget.reactionId),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
widget.count.toString(),
|
||||
style: TextStyle(
|
||||
color: AppTheme.navyBlue,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
.animate(key: ValueKey(_tapCount))
|
||||
.scale(
|
||||
begin: const Offset(1, 1),
|
||||
end: const Offset(1.08, 1.08),
|
||||
duration: 120.ms,
|
||||
curve: Curves.easeOut,
|
||||
)
|
||||
.then()
|
||||
.scale(
|
||||
begin: const Offset(1.08, 1.08),
|
||||
end: const Offset(1, 1),
|
||||
duration: 180.ms,
|
||||
curve: Curves.easeOutBack,
|
||||
);
|
||||
|
||||
final names = widget.tooltipNames;
|
||||
if (names == null || names.isEmpty) {
|
||||
return chip;
|
||||
}
|
||||
|
||||
return Tooltip(
|
||||
message: names.take(3).join(', '),
|
||||
child: chip,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ReactionAddButton extends StatelessWidget {
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ReactionAddButton({required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.navyBlue.withValues(alpha: 0.06),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: AppTheme.navyBlue.withValues(alpha: 0.2),
|
||||
width: 1.2,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.add, size: 16, color: AppTheme.navyBlue),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Add',
|
||||
style: TextStyle(
|
||||
color: AppTheme.navyBlue,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ReactionIcon extends StatelessWidget {
|
||||
final String reactionId;
|
||||
|
||||
const _ReactionIcon({required this.reactionId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (reactionId.startsWith('asset:')) {
|
||||
final assetPath = reactionId.replaceFirst('asset:', '');
|
||||
return Image.asset(
|
||||
assetPath,
|
||||
width: 18,
|
||||
height: 18,
|
||||
fit: BoxFit.contain,
|
||||
);
|
||||
}
|
||||
return Text(reactionId, style: const TextStyle(fontSize: 16));
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> showReactionPicker(
|
||||
BuildContext context, {
|
||||
required List<ReactionItem> baseItems,
|
||||
}) {
|
||||
return showModalBottomSheet<String>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => _ReactionPickerSheet(baseItems: baseItems),
|
||||
);
|
||||
}
|
||||
|
||||
class _ReactionPickerSheet extends StatefulWidget {
|
||||
final List<ReactionItem> baseItems;
|
||||
|
||||
const _ReactionPickerSheet({required this.baseItems});
|
||||
|
||||
@override
|
||||
State<_ReactionPickerSheet> createState() => _ReactionPickerSheetState();
|
||||
}
|
||||
|
||||
class _ReactionPickerSheetState extends State<_ReactionPickerSheet> {
|
||||
final TextEditingController _controller = TextEditingController();
|
||||
List<ReactionItem> _assetItems = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadAssetReactions();
|
||||
}
|
||||
|
||||
Future<void> _loadAssetReactions() async {
|
||||
try {
|
||||
final manifest = await DefaultAssetBundle.of(context)
|
||||
.loadString('AssetManifest.json');
|
||||
final map = jsonDecode(manifest) as Map<String, dynamic>;
|
||||
final keys = map.keys
|
||||
.where((key) => key.startsWith('assets/reactions/'))
|
||||
.toList()
|
||||
..sort();
|
||||
final items = keys
|
||||
.map((path) => ReactionItem(
|
||||
id: 'asset:$path',
|
||||
label: _labelForAsset(path),
|
||||
))
|
||||
.toList();
|
||||
if (mounted) {
|
||||
setState(() => _assetItems = items);
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignore manifest parsing errors; picker will show base items only.
|
||||
}
|
||||
}
|
||||
|
||||
String _labelForAsset(String path) {
|
||||
final fileName = path.split('/').last;
|
||||
final name = fileName.split('.').first;
|
||||
return name.replaceAll('_', ' ');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final query = _controller.text.trim().toLowerCase();
|
||||
final items = [...widget.baseItems, ..._assetItems];
|
||||
final filtered = query.isEmpty
|
||||
? items
|
||||
: items
|
||||
.where((item) =>
|
||||
item.label.toLowerCase().contains(query) ||
|
||||
item.id.toLowerCase().contains(query))
|
||||
.toList();
|
||||
|
||||
return Container(
|
||||
height: MediaQuery.of(context).size.height * 0.55,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.cardSurface.withValues(alpha: 0.75),
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Pick a reaction',
|
||||
style: TextStyle(
|
||||
color: AppTheme.navyBlue,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _controller,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search reactions',
|
||||
prefixIcon: Icon(Icons.search, color: AppTheme.navyBlue),
|
||||
filled: true,
|
||||
fillColor: Colors.white.withValues(alpha: 0.2),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: GridView.builder(
|
||||
itemCount: filtered.length,
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 6,
|
||||
mainAxisSpacing: 8,
|
||||
crossAxisSpacing: 8,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final item = filtered[index];
|
||||
return InkWell(
|
||||
onTap: () => Navigator.of(context).pop(item.id),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: AppTheme.navyBlue.withValues(alpha: 0.15),
|
||||
),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: _ReactionIcon(reactionId: item.id),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ReactionItem {
|
||||
final String id;
|
||||
final String label;
|
||||
|
||||
const ReactionItem({
|
||||
required this.id,
|
||||
required this.label,
|
||||
});
|
||||
}
|
||||
|
|
@ -89,6 +89,8 @@ flutter:
|
|||
uses-material-design: true
|
||||
assets:
|
||||
- assets/images/
|
||||
- assets/images/applogo.png
|
||||
- assets/reactions/
|
||||
- assets/rive/
|
||||
- assets/audio/
|
||||
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |