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