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
This commit is contained in:
Patrick Britton 2026-01-31 13:55:59 -06:00
parent 94c5c2095e
commit b76154be3a
54 changed files with 6481 additions and 3358 deletions

309
_tmp_server/main.go Normal file
View 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
View 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
}
}

View 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
}

View file

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

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS post_reactions;

View file

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

View file

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

View file

@ -2,6 +2,7 @@ package handlers
import (
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
@ -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,
})
}

View file

@ -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"`
}

View file

@ -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 {

View file

@ -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
`

View file

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

View file

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

Binary file not shown.

2767
logo.ai

File diff suppressed because it is too large Load diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View file

View 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(),
};
}
}

View 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());

View file

@ -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(

View file

@ -70,6 +70,7 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
_handleHashtagSuggestions();
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_bodyFocusNode.requestFocus();
});
}

View file

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

View file

@ -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(

View file

@ -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,7 +115,9 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
),
),
floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
bottomNavigationBar: BottomAppBar(
bottomNavigationBar: Padding(
padding: const EdgeInsets.only(bottom: 2),
child: BottomAppBar(
notchMargin: 8.0,
padding: EdgeInsets.zero,
height: 58,
@ -161,6 +163,7 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
),
),
),
),
);
}
@ -198,7 +201,7 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
icon: Icon(Icons.chat_bubble_outline, color: AppTheme.navyBlue),
tooltip: 'Messages',
onPressed: () {
Navigator.of(context).push(
Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute(
builder: (_) => const SecureChatFullScreen(),
fullscreenDialog: true,

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -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(),
),
);
}
},
),

View file

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

View file

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

View file

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

View file

@ -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,
),
),
],
),
),
);
}
}

View file

@ -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?;

View file

@ -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(

View file

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

View file

@ -146,19 +146,29 @@ class ImageUploadService {
}
File sanitizedFile;
bool useRawUpload = false;
try {
sanitizedFile = await MediaSanitizer.sanitizeImage(imageFile);
} catch (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 {

View file

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

View file

@ -2,183 +2,91 @@ 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);
// Post Content - Neutral for contrast with purple UI.
static const Color postContent = SojornColors.postContent;
static const Color postContentLight = SojornColors.postContentLight;
// STRICT SEPARATION
static const double borderWidth = 1.5;
static const double dividerThickness = 2.0;
static const double flowLineWidth = 3.0;
// 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!;
@ -186,7 +94,7 @@ class AppTheme {
static TextStyle get postBodyLong => textTheme.bodyLarge!.copyWith(fontSize: 18);
static TextStyle get postBodyReflective => textTheme.bodyLarge!.copyWith(
fontStyle: FontStyle.italic,
color: ksuPurple
color: ksuPurple,
);
// Text Style Getters
@ -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,
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,
),
);
}
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: navyBlue),
iconTheme: IconThemeData(color: brand.navyBlue),
titleTextStyle: textTheme.headlineSmall,
systemOverlayStyle: SystemUiOverlayStyle.dark,
shape: Border(
bottom: BorderSide(color: egyptianBlue, width: isPop ? 2 : borderWidth),
),
bottom: BorderSide(color: brand.egyptianBlue, width: lines.appBarBorder),
),
);
}
// Card Theme (Defined Edges)
cardTheme: CardThemeData(
color: cardSurface,
static CardThemeData _buildCardTheme(SojornBrandColors brand, SojornFlowLines lines) {
return CardThemeData(
color: brand.cardSurface,
elevation: 0,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: egyptianBlue, width: 1),
),
borderRadius: BorderRadius.circular(SojornRadii.lg),
side: BorderSide(color: brand.egyptianBlue, width: lines.cardBorder),
),
);
}
// Buttons
elevatedButtonTheme: ElevatedButtonThemeData(
static ElevatedButtonThemeData _buildElevatedButtonTheme(SojornBrandColors brand) {
return ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: brightNavy,
foregroundColor: white,
backgroundColor: brand.brightNavy,
foregroundColor: SojornColors.textOnAccent,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(
horizontal: SojornSpacing.lg,
vertical: SojornSpacing.md,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(SojornRadii.md),
),
textStyle: GoogleFonts.inter(fontWeight: FontWeight.bold),
),
),
);
}
textButtonTheme: TextButtonThemeData(
static TextButtonThemeData _buildTextButtonTheme(SojornBrandColors brand) {
return TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: egyptianBlue,
foregroundColor: brand.egyptianBlue,
textStyle: GoogleFonts.inter(fontWeight: FontWeight.w600),
),
),
);
}
// Bottom Nav
bottomNavigationBarTheme: BottomNavigationBarThemeData(
backgroundColor: cardSurface,
selectedItemColor: royalPurple,
unselectedItemColor: const Color(0xFF9EA3B0),
static BottomNavigationBarThemeData _buildBottomNavTheme(
SojornBrandColors brand,
SojornThemeOptions options,
) {
return BottomNavigationBarThemeData(
backgroundColor: brand.cardSurface,
selectedItemColor: brand.royalPurple,
unselectedItemColor: SojornColors.bottomNavUnselected,
type: BottomNavigationBarType.fixed,
showSelectedLabels: !isPop,
showUnselectedLabels: !isPop,
elevation: isPop ? 10 : 0,
),
showSelectedLabels: options.showBottomNavLabels,
showUnselectedLabels: options.showBottomNavLabels,
elevation: options.bottomNavElevation,
);
}
// Floating Action Button
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: brightNavy,
foregroundColor: Colors.white,
elevation: isPop ? 4 : 6,
shape: isPop ? const CircleBorder() : null,
),
static FloatingActionButtonThemeData _buildFabTheme(
SojornBrandColors brand,
SojornThemeOptions options,
) {
return FloatingActionButtonThemeData(
backgroundColor: brand.brightNavy,
foregroundColor: SojornColors.textOnAccent,
elevation: options.fabElevation,
shape: options.fabShape,
);
}
// Divider (Hard & Visible)
dividerTheme: DividerThemeData(
color: queenPink,
thickness: 1,
space: 24,
),
static DividerThemeData _buildDividerTheme(
SojornBrandColors brand,
SojornFlowLines lines,
) {
return DividerThemeData(
color: brand.queenPink,
thickness: lines.divider,
space: SojornSpacing.lg,
);
}
// Input Fields
inputDecorationTheme: InputDecorationTheme(
static InputDecorationTheme _buildInputTheme(
SojornBrandColors brand,
SojornFlowLines lines,
) {
return InputDecorationTheme(
filled: true,
fillColor: cardSurface,
contentPadding: const EdgeInsets.all(16),
fillColor: brand.cardSurface,
contentPadding: const EdgeInsets.all(SojornSpacing.md),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: egyptianBlue, width: 1),
borderRadius: BorderRadius.circular(SojornRadii.md),
borderSide: BorderSide(color: brand.egyptianBlue, width: lines.inputBorder),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: egyptianBlue, width: 1),
borderRadius: BorderRadius.circular(SojornRadii.md),
borderSide: BorderSide(color: brand.egyptianBlue, width: lines.inputBorder),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: royalPurple, width: 2),
),
borderRadius: BorderRadius.circular(SojornRadii.md),
borderSide: BorderSide(color: brand.royalPurple, width: lines.inputFocusBorder),
),
);
}

View file

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

View 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),
);
}
}

View 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;
}

View file

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

View file

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

View 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();
}
}

View file

@ -42,14 +42,15 @@ 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: SizedBox(
width: double.infinity,
child: SignedMediaImage(
url: post!.imageUrl!,
fit: mode == PostViewMode.feed ? BoxFit.cover : BoxFit.contain,
fit: BoxFit.contain,
loadingBuilder: (context) => Container(
color: AppTheme.queenPink.withValues(alpha: 0.3),
child: const Center(child: CircularProgressIndicator()),
@ -73,6 +74,7 @@ class PostMedia extends StatelessWidget {
),
),
),
),
],
),
);

View file

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

View 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,
});
}

View file

@ -89,6 +89,8 @@ flutter:
uses-material-design: true
assets:
- assets/images/
- assets/images/applogo.png
- assets/reactions/
- assets/rive/
- assets/audio/

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB