Merge goSojorn into main: OG image R2 proxy, link preview fixes, spacing cleanup
This commit is contained in:
commit
b8e070d121
|
|
@ -127,22 +127,9 @@ func main() {
|
|||
// Initialize content filter (hard blocklist + strike system)
|
||||
contentFilter := services.NewContentFilter(dbPool)
|
||||
|
||||
// Initialize link preview service
|
||||
linkPreviewService := services.NewLinkPreviewService(dbPool)
|
||||
|
||||
hub := realtime.NewHub()
|
||||
wsHandler := handlers.NewWSHandler(hub, cfg.JWTSecret)
|
||||
|
||||
userHandler := handlers.NewUserHandler(userRepo, postRepo, notificationService, assetService)
|
||||
postHandler := handlers.NewPostHandler(postRepo, userRepo, feedService, assetService, notificationService, moderationService, contentFilter, openRouterService, linkPreviewService)
|
||||
chatHandler := handlers.NewChatHandler(chatRepo, notificationService, hub)
|
||||
authHandler := handlers.NewAuthHandler(userRepo, cfg, emailService, sendPulseService)
|
||||
categoryHandler := handlers.NewCategoryHandler(categoryRepo)
|
||||
keyHandler := handlers.NewKeyHandler(userRepo)
|
||||
backupHandler := handlers.NewBackupHandler(repository.NewBackupRepository(dbPool))
|
||||
settingsHandler := handlers.NewSettingsHandler(userRepo, notifRepo)
|
||||
analysisHandler := handlers.NewAnalysisHandler()
|
||||
appealHandler := handlers.NewAppealHandler(appealService)
|
||||
var s3Client *s3.Client
|
||||
if cfg.R2AccessKey != "" && cfg.R2SecretKey != "" && cfg.R2Endpoint != "" {
|
||||
resolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...interface{}) (aws.Endpoint, error) {
|
||||
|
|
@ -161,8 +148,22 @@ func main() {
|
|||
}
|
||||
}
|
||||
|
||||
// Initialize link preview service (after S3 client setup)
|
||||
linkPreviewService := services.NewLinkPreviewService(dbPool, s3Client, cfg.R2MediaBucket, cfg.R2ImgDomain)
|
||||
|
||||
userHandler := handlers.NewUserHandler(userRepo, postRepo, notificationService, assetService)
|
||||
postHandler := handlers.NewPostHandler(postRepo, userRepo, feedService, assetService, notificationService, moderationService, contentFilter, openRouterService, linkPreviewService)
|
||||
chatHandler := handlers.NewChatHandler(chatRepo, notificationService, hub)
|
||||
authHandler := handlers.NewAuthHandler(userRepo, cfg, emailService, sendPulseService)
|
||||
categoryHandler := handlers.NewCategoryHandler(categoryRepo)
|
||||
keyHandler := handlers.NewKeyHandler(userRepo)
|
||||
backupHandler := handlers.NewBackupHandler(repository.NewBackupRepository(dbPool))
|
||||
settingsHandler := handlers.NewSettingsHandler(userRepo, notifRepo)
|
||||
analysisHandler := handlers.NewAnalysisHandler()
|
||||
appealHandler := handlers.NewAppealHandler(appealService)
|
||||
|
||||
// Initialize official accounts service
|
||||
officialAccountsService := services.NewOfficialAccountsService(dbPool, openRouterService)
|
||||
officialAccountsService := services.NewOfficialAccountsService(dbPool, openRouterService, linkPreviewService)
|
||||
officialAccountsService.StartScheduler()
|
||||
defer officialAccountsService.StopScheduler()
|
||||
|
||||
|
|
|
|||
|
|
@ -60,7 +60,8 @@ func (h *PostHandler) enrichLinkPreviews(ctx context.Context, posts []models.Pos
|
|||
posts[i].LinkPreviewURL = &lp.URL
|
||||
posts[i].LinkPreviewTitle = &lp.Title
|
||||
posts[i].LinkPreviewDescription = &lp.Description
|
||||
posts[i].LinkPreviewImageURL = &lp.ImageURL
|
||||
signed := h.assetService.SignImageURL(lp.ImageURL)
|
||||
posts[i].LinkPreviewImageURL = &signed
|
||||
posts[i].LinkPreviewSiteName = &lp.SiteName
|
||||
}
|
||||
}
|
||||
|
|
@ -79,7 +80,8 @@ func (h *PostHandler) enrichSinglePostLinkPreview(ctx context.Context, post *mod
|
|||
post.LinkPreviewURL = &lp.URL
|
||||
post.LinkPreviewTitle = &lp.Title
|
||||
post.LinkPreviewDescription = &lp.Description
|
||||
post.LinkPreviewImageURL = &lp.ImageURL
|
||||
signed := h.assetService.SignImageURL(lp.ImageURL)
|
||||
post.LinkPreviewImageURL = &signed
|
||||
post.LinkPreviewSiteName = &lp.SiteName
|
||||
}
|
||||
}
|
||||
|
|
@ -637,11 +639,13 @@ func (h *PostHandler) CreatePost(c *gin.Context) {
|
|||
fastCancel()
|
||||
|
||||
if lpErr == nil && lp != nil {
|
||||
h.linkPreviewService.ProxyImageToR2(c.Request.Context(), lp)
|
||||
_ = h.linkPreviewService.SaveLinkPreview(c.Request.Context(), post.ID.String(), lp)
|
||||
post.LinkPreviewURL = &lp.URL
|
||||
post.LinkPreviewTitle = &lp.Title
|
||||
post.LinkPreviewDescription = &lp.Description
|
||||
post.LinkPreviewImageURL = &lp.ImageURL
|
||||
signedImg := h.assetService.SignImageURL(lp.ImageURL)
|
||||
post.LinkPreviewImageURL = &signedImg
|
||||
post.LinkPreviewSiteName = &lp.SiteName
|
||||
} else if lpErr != nil {
|
||||
// Timed out or failed — fire async so it's saved for later views
|
||||
|
|
@ -651,6 +655,7 @@ func (h *PostHandler) CreatePost(c *gin.Context) {
|
|||
defer bgCancel()
|
||||
bgLp, bgErr := h.linkPreviewService.FetchPreview(bgCtx, linkURL, isOfficial)
|
||||
if bgErr == nil && bgLp != nil {
|
||||
h.linkPreviewService.ProxyImageToR2(bgCtx, bgLp)
|
||||
_ = h.linkPreviewService.SaveLinkPreview(bgCtx, postID, bgLp)
|
||||
}
|
||||
}()
|
||||
|
|
|
|||
|
|
@ -167,7 +167,8 @@ func (r *PostRepository) GetFeed(ctx context.Context, userID string, categorySlu
|
|||
COALESCE((SELECT jsonb_object_agg(emoji, count) FROM (SELECT emoji, COUNT(*) as count FROM public.post_reactions WHERE post_id = p.id GROUP BY emoji) r), '{}'::jsonb) as reaction_counts,
|
||||
CASE WHEN ($4::text) != '' THEN COALESCE((SELECT jsonb_agg(emoji) FROM public.post_reactions WHERE post_id = p.id AND user_id = $4::text::uuid), '[]'::jsonb) ELSE '[]'::jsonb END as my_reactions,
|
||||
COALESCE(p.is_nsfw, FALSE) as is_nsfw,
|
||||
COALESCE(p.nsfw_reason, '') as nsfw_reason
|
||||
COALESCE(p.nsfw_reason, '') as nsfw_reason,
|
||||
p.link_preview_url, p.link_preview_title, p.link_preview_description, p.link_preview_image_url, p.link_preview_site_name
|
||||
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
|
||||
|
|
@ -216,6 +217,7 @@ func (r *PostRepository) GetFeed(ctx context.Context, userID string, categorySlu
|
|||
&p.LikeCount, &p.CommentCount, &p.IsLiked,
|
||||
&p.AllowChain, &p.Visibility, &p.Reactions, &p.MyReactions,
|
||||
&p.IsNSFW, &p.NSFWReason,
|
||||
&p.LinkPreviewURL, &p.LinkPreviewTitle, &p.LinkPreviewDescription, &p.LinkPreviewImageURL, &p.LinkPreviewSiteName,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -272,7 +274,8 @@ func (r *PostRepository) GetPostsByAuthor(ctx context.Context, authorID string,
|
|||
COALESCE((SELECT jsonb_object_agg(emoji, count) FROM (SELECT emoji, COUNT(*) as count FROM public.post_reactions WHERE post_id = p.id GROUP BY emoji) r), '{}'::jsonb) as reaction_counts,
|
||||
CASE WHEN ($4::text) != '' THEN COALESCE((SELECT jsonb_agg(emoji) FROM public.post_reactions WHERE post_id = p.id AND user_id = $4::text::uuid), '[]'::jsonb) ELSE '[]'::jsonb END as my_reactions,
|
||||
COALESCE(p.is_nsfw, FALSE) as is_nsfw,
|
||||
COALESCE(p.nsfw_reason, '') as nsfw_reason
|
||||
COALESCE(p.nsfw_reason, '') as nsfw_reason,
|
||||
p.link_preview_url, p.link_preview_title, p.link_preview_description, p.link_preview_image_url, p.link_preview_site_name
|
||||
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
|
||||
|
|
@ -308,6 +311,7 @@ func (r *PostRepository) GetPostsByAuthor(ctx context.Context, authorID string,
|
|||
&p.AuthorHandle, &p.AuthorDisplayName, &p.AuthorAvatarURL,
|
||||
&p.LikeCount, &p.CommentCount, &p.IsLiked, &p.AllowChain, &p.Visibility, &p.Reactions, &p.MyReactions,
|
||||
&p.IsNSFW, &p.NSFWReason,
|
||||
&p.LinkPreviewURL, &p.LinkPreviewTitle, &p.LinkPreviewDescription, &p.LinkPreviewImageURL, &p.LinkPreviewSiteName,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -351,7 +355,8 @@ func (r *PostRepository) GetPostByID(ctx context.Context, postID string, userID
|
|||
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,
|
||||
COALESCE(p.is_nsfw, FALSE) as is_nsfw,
|
||||
COALESCE(p.nsfw_reason, '') as nsfw_reason
|
||||
COALESCE(p.nsfw_reason, '') as nsfw_reason,
|
||||
p.link_preview_url, p.link_preview_title, p.link_preview_description, p.link_preview_image_url, p.link_preview_site_name
|
||||
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
|
||||
|
|
@ -375,6 +380,7 @@ func (r *PostRepository) GetPostByID(ctx context.Context, postID string, userID
|
|||
&p.LikeCount, &p.CommentCount, &p.IsLiked,
|
||||
&p.AllowChain, &p.Visibility,
|
||||
&p.IsNSFW, &p.NSFWReason,
|
||||
&p.LinkPreviewURL, &p.LinkPreviewTitle, &p.LinkPreviewDescription, &p.LinkPreviewImageURL, &p.LinkPreviewSiteName,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -602,7 +608,8 @@ func (r *PostRepository) GetSavedPosts(ctx context.Context, userID string, limit
|
|||
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::uuid) as is_liked,
|
||||
COALESCE(p.is_nsfw, FALSE) as is_nsfw,
|
||||
COALESCE(p.nsfw_reason, '') as nsfw_reason
|
||||
COALESCE(p.nsfw_reason, '') as nsfw_reason,
|
||||
p.link_preview_url, p.link_preview_title, p.link_preview_description, p.link_preview_image_url, p.link_preview_site_name
|
||||
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
|
||||
|
|
@ -626,6 +633,7 @@ func (r *PostRepository) GetSavedPosts(ctx context.Context, userID string, limit
|
|||
&p.AuthorHandle, &p.AuthorDisplayName, &p.AuthorAvatarURL,
|
||||
&p.LikeCount, &p.CommentCount, &p.IsLiked,
|
||||
&p.IsNSFW, &p.NSFWReason,
|
||||
&p.LinkPreviewURL, &p.LinkPreviewTitle, &p.LinkPreviewDescription, &p.LinkPreviewImageURL, &p.LinkPreviewSiteName,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -655,7 +663,8 @@ func (r *PostRepository) GetLikedPosts(ctx context.Context, userID string, limit
|
|||
COALESCE(m.like_count, 0) as like_count, COALESCE(m.comment_count, 0) as comment_count,
|
||||
TRUE as is_liked,
|
||||
COALESCE(p.is_nsfw, FALSE) as is_nsfw,
|
||||
COALESCE(p.nsfw_reason, '') as nsfw_reason
|
||||
COALESCE(p.nsfw_reason, '') as nsfw_reason,
|
||||
p.link_preview_url, p.link_preview_title, p.link_preview_description, p.link_preview_image_url, p.link_preview_site_name
|
||||
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
|
||||
|
|
@ -679,6 +688,7 @@ func (r *PostRepository) GetLikedPosts(ctx context.Context, userID string, limit
|
|||
&p.AuthorHandle, &p.AuthorDisplayName, &p.AuthorAvatarURL,
|
||||
&p.LikeCount, &p.CommentCount, &p.IsLiked,
|
||||
&p.IsNSFW, &p.NSFWReason,
|
||||
&p.LinkPreviewURL, &p.LinkPreviewTitle, &p.LinkPreviewDescription, &p.LinkPreviewImageURL, &p.LinkPreviewSiteName,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -820,7 +830,8 @@ func (r *PostRepository) SearchPosts(ctx context.Context, query string, viewerID
|
|||
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 $3 != '' THEN EXISTS(SELECT 1 FROM public.post_likes WHERE post_id = p.id AND user_id = $3::uuid) ELSE FALSE END as is_liked
|
||||
CASE WHEN $3 != '' THEN EXISTS(SELECT 1 FROM public.post_likes WHERE post_id = p.id AND user_id = $3::uuid) ELSE FALSE END as is_liked,
|
||||
p.link_preview_url, p.link_preview_title, p.link_preview_description, p.link_preview_image_url, p.link_preview_site_name
|
||||
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
|
||||
|
|
@ -854,6 +865,7 @@ func (r *PostRepository) SearchPosts(ctx context.Context, query string, viewerID
|
|||
&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.LinkPreviewURL, &p.LinkPreviewTitle, &p.LinkPreviewDescription, &p.LinkPreviewImageURL, &p.LinkPreviewSiteName,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -1056,7 +1068,8 @@ func (r *PostRepository) GetPostFocusContext(ctx context.Context, postID string,
|
|||
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,
|
||||
COALESCE(p.is_nsfw, FALSE) as is_nsfw,
|
||||
COALESCE(p.nsfw_reason, '') as nsfw_reason
|
||||
COALESCE(p.nsfw_reason, '') as nsfw_reason,
|
||||
p.link_preview_url, p.link_preview_title, p.link_preview_description, p.link_preview_image_url, p.link_preview_site_name
|
||||
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
|
||||
|
|
@ -1087,6 +1100,7 @@ func (r *PostRepository) GetPostFocusContext(ctx context.Context, postID string,
|
|||
&p.LikeCount, &p.CommentCount, &p.IsLiked,
|
||||
&p.AllowChain, &p.Visibility,
|
||||
&p.IsNSFW, &p.NSFWReason,
|
||||
&p.LinkPreviewURL, &p.LinkPreviewTitle, &p.LinkPreviewDescription, &p.LinkPreviewImageURL, &p.LinkPreviewSiteName,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan child post: %w", err)
|
||||
|
|
@ -1128,6 +1142,7 @@ func (r *PostRepository) GetPostFocusContext(ctx context.Context, postID string,
|
|||
&p.LikeCount, &p.CommentCount, &p.IsLiked,
|
||||
&p.AllowChain, &p.Visibility,
|
||||
&p.IsNSFW, &p.NSFWReason,
|
||||
&p.LinkPreviewURL, &p.LinkPreviewTitle, &p.LinkPreviewDescription, &p.LinkPreviewImageURL, &p.LinkPreviewSiteName,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan parent child post: %w", err)
|
||||
|
|
@ -1377,7 +1392,8 @@ func (r *PostRepository) GetPopularPublicPosts(ctx context.Context, viewerID str
|
|||
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::text) != '' THEN EXISTS(SELECT 1 FROM public.post_likes WHERE post_id = p.id AND user_id = $2::text::uuid) ELSE FALSE END as is_liked,
|
||||
p.allow_chain, p.visibility
|
||||
p.allow_chain, p.visibility,
|
||||
p.link_preview_url, p.link_preview_title, p.link_preview_description, p.link_preview_image_url, p.link_preview_site_name
|
||||
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
|
||||
|
|
@ -1402,6 +1418,7 @@ func (r *PostRepository) GetPopularPublicPosts(ctx context.Context, viewerID str
|
|||
&p.AuthorHandle, &p.AuthorDisplayName, &p.AuthorAvatarURL,
|
||||
&p.LikeCount, &p.CommentCount, &p.IsLiked,
|
||||
&p.AllowChain, &p.Visibility,
|
||||
&p.LinkPreviewURL, &p.LinkPreviewTitle, &p.LinkPreviewDescription, &p.LinkPreviewImageURL, &p.LinkPreviewSiteName,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
|
|||
|
|
@ -1,17 +1,22 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
|
@ -32,13 +37,19 @@ type LinkPreview struct {
|
|||
|
||||
// LinkPreviewService fetches and parses OpenGraph metadata from URLs.
|
||||
type LinkPreviewService struct {
|
||||
pool *pgxpool.Pool
|
||||
httpClient *http.Client
|
||||
pool *pgxpool.Pool
|
||||
httpClient *http.Client
|
||||
s3Client *s3.Client
|
||||
mediaBucket string
|
||||
imgDomain string
|
||||
}
|
||||
|
||||
func NewLinkPreviewService(pool *pgxpool.Pool) *LinkPreviewService {
|
||||
func NewLinkPreviewService(pool *pgxpool.Pool, s3Client *s3.Client, mediaBucket string, imgDomain string) *LinkPreviewService {
|
||||
return &LinkPreviewService{
|
||||
pool: pool,
|
||||
pool: pool,
|
||||
s3Client: s3Client,
|
||||
mediaBucket: mediaBucket,
|
||||
imgDomain: imgDomain,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 8 * time.Second,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
|
|
@ -327,6 +338,89 @@ func (s *LinkPreviewService) SaveLinkPreview(ctx context.Context, postID string,
|
|||
return err
|
||||
}
|
||||
|
||||
// ProxyImageToR2 downloads an external OG image and uploads it to R2.
|
||||
// On success, lp.ImageURL is replaced with the R2 object key (e.g. "og/abc123.jpg").
|
||||
// If S3 is not configured or the download fails, the original URL is left unchanged.
|
||||
func (s *LinkPreviewService) ProxyImageToR2(ctx context.Context, lp *LinkPreview) {
|
||||
if s.s3Client == nil || s.mediaBucket == "" || lp == nil || lp.ImageURL == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Only proxy external http(s) URLs
|
||||
if !strings.HasPrefix(lp.ImageURL, "http://") && !strings.HasPrefix(lp.ImageURL, "https://") {
|
||||
return
|
||||
}
|
||||
|
||||
// Download the image with a short timeout
|
||||
dlCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(dlCtx, "GET", lp.ImageURL, nil)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("url", lp.ImageURL).Msg("[LinkPreview] Failed to create image download request")
|
||||
return
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("url", lp.ImageURL).Msg("[LinkPreview] Failed to download OG image")
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
log.Warn().Int("status", resp.StatusCode).Str("url", lp.ImageURL).Msg("[LinkPreview] OG image download returned non-200")
|
||||
return
|
||||
}
|
||||
|
||||
// Read max 5MB
|
||||
imgBytes, err := io.ReadAll(io.LimitReader(resp.Body, 5*1024*1024))
|
||||
if err != nil || len(imgBytes) == 0 {
|
||||
log.Warn().Err(err).Str("url", lp.ImageURL).Msg("[LinkPreview] Failed to read OG image bytes")
|
||||
return
|
||||
}
|
||||
|
||||
// Determine content type and extension
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
ext := ".jpg"
|
||||
switch {
|
||||
case strings.Contains(ct, "png"):
|
||||
ext = ".png"
|
||||
case strings.Contains(ct, "gif"):
|
||||
ext = ".gif"
|
||||
case strings.Contains(ct, "webp"):
|
||||
ext = ".webp"
|
||||
case strings.Contains(ct, "svg"):
|
||||
ext = ".svg"
|
||||
}
|
||||
|
||||
// Generate a deterministic key from the source URL hash
|
||||
hash := sha256.Sum256([]byte(lp.ImageURL))
|
||||
hashStr := hex.EncodeToString(hash[:12])
|
||||
objectKey := path.Join("og", hashStr+ext)
|
||||
|
||||
// Upload to R2
|
||||
contentType := ct
|
||||
if contentType == "" {
|
||||
contentType = "image/jpeg"
|
||||
}
|
||||
reader := bytes.NewReader(imgBytes)
|
||||
_, err = s.s3Client.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: &s.mediaBucket,
|
||||
Key: &objectKey,
|
||||
Body: reader,
|
||||
ContentType: &contentType,
|
||||
})
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("key", objectKey).Msg("[LinkPreview] Failed to upload OG image to R2")
|
||||
return
|
||||
}
|
||||
|
||||
log.Info().Str("key", objectKey).Str("original", lp.ImageURL).Msg("[LinkPreview] OG image proxied to R2")
|
||||
lp.ImageURL = objectKey
|
||||
}
|
||||
|
||||
// ── Safe Domains ─────────────────────────────────────
|
||||
|
||||
// SafeDomain represents a row in the safe_domains table.
|
||||
|
|
|
|||
|
|
@ -128,17 +128,19 @@ type CachedArticle struct {
|
|||
|
||||
// OfficialAccountsService manages official account automation
|
||||
type OfficialAccountsService struct {
|
||||
pool *pgxpool.Pool
|
||||
openRouterService *OpenRouterService
|
||||
httpClient *http.Client
|
||||
stopCh chan struct{}
|
||||
wg sync.WaitGroup
|
||||
pool *pgxpool.Pool
|
||||
openRouterService *OpenRouterService
|
||||
linkPreviewService *LinkPreviewService
|
||||
httpClient *http.Client
|
||||
stopCh chan struct{}
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
func NewOfficialAccountsService(pool *pgxpool.Pool, openRouterService *OpenRouterService) *OfficialAccountsService {
|
||||
func NewOfficialAccountsService(pool *pgxpool.Pool, openRouterService *OpenRouterService, linkPreviewService *LinkPreviewService) *OfficialAccountsService {
|
||||
return &OfficialAccountsService{
|
||||
pool: pool,
|
||||
openRouterService: openRouterService,
|
||||
pool: pool,
|
||||
openRouterService: openRouterService,
|
||||
linkPreviewService: linkPreviewService,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
|
|
@ -771,14 +773,14 @@ func (s *OfficialAccountsService) CreatePostForAccount(ctx context.Context, conf
|
|||
linkURL = article.Link
|
||||
}
|
||||
if linkURL != "" {
|
||||
lps := NewLinkPreviewService(s.pool)
|
||||
lp, lpErr := lps.FetchPreview(bgCtx, linkURL, true)
|
||||
lp, lpErr := s.linkPreviewService.FetchPreview(bgCtx, linkURL, true)
|
||||
if lpErr != nil {
|
||||
log.Warn().Err(lpErr).Str("post_id", postID.String()).Str("url", linkURL).Msg("[OfficialAccounts] Link preview fetch failed")
|
||||
return
|
||||
}
|
||||
if lp != nil {
|
||||
if saveErr := lps.SaveLinkPreview(bgCtx, postID.String(), lp); saveErr != nil {
|
||||
s.linkPreviewService.ProxyImageToR2(bgCtx, lp)
|
||||
if saveErr := s.linkPreviewService.SaveLinkPreview(bgCtx, postID.String(), lp); saveErr != nil {
|
||||
log.Warn().Err(saveErr).Str("post_id", postID.String()).Msg("[OfficialAccounts] Link preview save failed")
|
||||
} else {
|
||||
log.Info().Str("post_id", postID.String()).Str("url", linkURL).Str("title", lp.Title).Msg("[OfficialAccounts] Link preview saved")
|
||||
|
|
@ -851,14 +853,14 @@ func (s *OfficialAccountsService) CreatePostForArticle(ctx context.Context, conf
|
|||
linkURL = article.Link
|
||||
}
|
||||
if linkURL != "" {
|
||||
lps := NewLinkPreviewService(s.pool)
|
||||
lp, lpErr := lps.FetchPreview(bgCtx, linkURL, true)
|
||||
lp, lpErr := s.linkPreviewService.FetchPreview(bgCtx, linkURL, true)
|
||||
if lpErr != nil {
|
||||
log.Warn().Err(lpErr).Str("post_id", postID.String()).Str("url", linkURL).Msg("[OfficialAccounts] Link preview fetch failed")
|
||||
return
|
||||
}
|
||||
if lp != nil {
|
||||
if saveErr := lps.SaveLinkPreview(bgCtx, postID.String(), lp); saveErr != nil {
|
||||
s.linkPreviewService.ProxyImageToR2(bgCtx, lp)
|
||||
if saveErr := s.linkPreviewService.SaveLinkPreview(bgCtx, postID.String(), lp); saveErr != nil {
|
||||
log.Warn().Err(saveErr).Str("post_id", postID.String()).Msg("[OfficialAccounts] Link preview save failed")
|
||||
} else {
|
||||
log.Info().Str("post_id", postID.String()).Str("url", linkURL).Str("title", lp.Title).Msg("[OfficialAccounts] Link preview saved")
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import '../screens/home/feed_personal_screen.dart';
|
|||
import '../screens/home/home_shell.dart';
|
||||
import '../screens/quips/create/quip_creation_flow.dart';
|
||||
import '../screens/quips/feed/quips_feed_screen.dart';
|
||||
import '../screens/profile/profile_screen.dart';
|
||||
import '../screens/profile/viewable_profile_screen.dart';
|
||||
import '../screens/profile/blocked_users_screen.dart';
|
||||
import '../screens/auth/auth_gate.dart';
|
||||
|
|
@ -55,7 +54,7 @@ class AppRoutes {
|
|||
GoRoute(
|
||||
path: '$userPrefix/:username',
|
||||
parentNavigatorKey: rootNavigatorKey,
|
||||
builder: (_, state) => ViewableProfileScreen(
|
||||
builder: (_, state) => UnifiedProfileScreen(
|
||||
handle: state.pathParameters['username'] ?? '',
|
||||
),
|
||||
),
|
||||
|
|
@ -121,7 +120,7 @@ class AppRoutes {
|
|||
routes: [
|
||||
GoRoute(
|
||||
path: profile,
|
||||
builder: (_, __) => const ProfileScreen(),
|
||||
builder: (_, __) => const UnifiedProfileScreen(),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'blocked',
|
||||
|
|
|
|||
|
|
@ -253,7 +253,7 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
|||
void _navigateToProfile(String handle) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ViewableProfileScreen(handle: handle),
|
||||
builder: (_) => UnifiedProfileScreen(handle: handle),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import '../../providers/api_provider.dart';
|
|||
import '../../providers/feed_refresh_provider.dart';
|
||||
import '../../models/post.dart';
|
||||
import '../../theme/app_theme.dart';
|
||||
import '../../widgets/post_item.dart';
|
||||
import '../../widgets/sojorn_post_card.dart';
|
||||
import '../../widgets/app_scaffold.dart';
|
||||
import '../compose/compose_screen.dart';
|
||||
import '../post/post_detail_screen.dart';
|
||||
|
|
@ -142,11 +142,10 @@ class _FeedPersonalScreenState extends ConsumerState<FeedPersonalScreen> {
|
|||
}
|
||||
|
||||
final post = _posts[index];
|
||||
return UnifiedPostTile(
|
||||
return sojornPostCard(
|
||||
post: post,
|
||||
onTap: () => _openPostDetail(post),
|
||||
onChain: () => _openChainComposer(post),
|
||||
showDivider: index != _posts.length - 1,
|
||||
);
|
||||
},
|
||||
childCount: _posts.length,
|
||||
|
|
|
|||
|
|
@ -141,7 +141,7 @@ class _FeedsojornScreenState extends ConsumerState<FeedsojornScreen> {
|
|||
if (post.author != null && post.author!.handle.isNotEmpty) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ViewableProfileScreen(handle: post.author!.handle),
|
||||
builder: (_) => UnifiedProfileScreen(handle: post.author!.handle),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -272,7 +272,7 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
|
|||
if (notification.actor != null) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ViewableProfileScreen(handle: notification.actor!.handle),
|
||||
builder: (_) => UnifiedProfileScreen(handle: notification.actor!.handle),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -272,7 +272,7 @@ class _FollowersFollowingScreenState
|
|||
void _navigateToProfile(UserListItem user) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ViewableProfileScreen(handle: user.handle),
|
||||
builder: (context) => UnifiedProfileScreen(handle: user.handle),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -296,7 +296,7 @@ class _FollowingScreenState extends ConsumerState<FollowingScreen> {
|
|||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ViewableProfileScreen(handle: user.handle),
|
||||
builder: (_) => UnifiedProfileScreen(handle: user.handle),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -13,7 +13,7 @@ import '../../providers/api_provider.dart';
|
|||
import '../../theme/app_theme.dart';
|
||||
import '../../utils/country_flag.dart';
|
||||
import '../../utils/url_launcher_helper.dart';
|
||||
import '../../widgets/post_item.dart';
|
||||
import '../../widgets/sojorn_post_card.dart';
|
||||
import '../../widgets/media/signed_media_image.dart';
|
||||
import '../compose/compose_screen.dart';
|
||||
import '../secure_chat/secure_chat_screen.dart';
|
||||
|
|
@ -21,25 +21,37 @@ import '../../services/auth_service.dart';
|
|||
import '../../services/secure_chat_service.dart';
|
||||
import '../post/post_detail_screen.dart';
|
||||
import 'profile_settings_screen.dart';
|
||||
import 'profile_screen.dart';
|
||||
import 'followers_following_screen.dart';
|
||||
|
||||
/// Screen for viewing another user's profile
|
||||
class ViewableProfileScreen extends ConsumerStatefulWidget {
|
||||
final String handle;
|
||||
/// Unified profile screen - handles both own profile and viewing others.
|
||||
///
|
||||
/// When [handle] is null, loads the current user's own profile with
|
||||
/// edit controls, privacy settings, and avatar actions.
|
||||
/// When [handle] is provided, loads the target user's profile with
|
||||
/// follow/message actions.
|
||||
class UnifiedProfileScreen extends ConsumerStatefulWidget {
|
||||
final String? handle;
|
||||
|
||||
const ViewableProfileScreen({
|
||||
const UnifiedProfileScreen({
|
||||
super.key,
|
||||
required this.handle,
|
||||
this.handle,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ViewableProfileScreen> createState() =>
|
||||
_ViewableProfileScreenState();
|
||||
ConsumerState<UnifiedProfileScreen> createState() =>
|
||||
_UnifiedProfileScreenState();
|
||||
}
|
||||
|
||||
class _ViewableProfileScreenState extends ConsumerState<ViewableProfileScreen>
|
||||
String _resolveAvatar(String? url) {
|
||||
if (url == null || url.isEmpty) return '';
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) return url;
|
||||
return 'https://img.sojorn.net/${url.replaceFirst(RegExp('^/'), '')}';
|
||||
}
|
||||
|
||||
class _UnifiedProfileScreenState extends ConsumerState<UnifiedProfileScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
static const int _postsPageSize = 20;
|
||||
StreamSubscription? _authSubscription;
|
||||
|
||||
Profile? _profile;
|
||||
ProfileStats? _stats;
|
||||
|
|
@ -52,9 +64,13 @@ class _ViewableProfileScreenState extends ConsumerState<ViewableProfileScreen>
|
|||
String? _profileError;
|
||||
bool _isPrivate = false;
|
||||
bool _isOwnProfile = false;
|
||||
bool _isCreatingProfile = false;
|
||||
ProfilePrivacySettings? _privacySettings;
|
||||
bool _isPrivacyLoading = false;
|
||||
|
||||
/// True when no handle was provided (bottom-nav profile tab)
|
||||
bool get _isOwnProfileMode => widget.handle == null;
|
||||
|
||||
late TabController _tabController;
|
||||
int _activeTab = 0;
|
||||
|
||||
|
|
@ -79,7 +95,8 @@ class _ViewableProfileScreenState extends ConsumerState<ViewableProfileScreen>
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 4, vsync: this);
|
||||
// Own profile gets 3 tabs (Posts, Saved, Chains), others get 4 (+About)
|
||||
_tabController = TabController(length: _isOwnProfileMode ? 3 : 4, vsync: this);
|
||||
_tabController.addListener(() {
|
||||
if (!_tabController.indexIsChanging) {
|
||||
setState(() {
|
||||
|
|
@ -90,10 +107,21 @@ class _ViewableProfileScreenState extends ConsumerState<ViewableProfileScreen>
|
|||
});
|
||||
|
||||
_loadProfile();
|
||||
|
||||
// Listen for auth changes when viewing own profile
|
||||
if (_isOwnProfileMode) {
|
||||
_authSubscription = AuthService.instance.authStateChanges.listen((data) {
|
||||
if (data.event == AuthChangeEvent.signedIn ||
|
||||
data.event == AuthChangeEvent.tokenRefreshed) {
|
||||
_loadProfile();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_authSubscription?.cancel();
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
|
@ -106,7 +134,10 @@ class _ViewableProfileScreenState extends ConsumerState<ViewableProfileScreen>
|
|||
|
||||
try {
|
||||
final apiService = ref.read(apiServiceProvider);
|
||||
final data = await apiService.getProfile(handle: widget.handle);
|
||||
// Own profile: no handle arg; other profile: pass handle
|
||||
final data = _isOwnProfileMode
|
||||
? await apiService.getProfile()
|
||||
: await apiService.getProfile(handle: widget.handle);
|
||||
final profile = data['profile'] as Profile;
|
||||
final stats = data['stats'] as ProfileStats;
|
||||
final followStatus = data['follow_status'] as String?;
|
||||
|
|
@ -115,8 +146,9 @@ class _ViewableProfileScreenState extends ConsumerState<ViewableProfileScreen>
|
|||
final isFriend = data['is_friend'] as bool? ?? false;
|
||||
final isPrivate = data['is_private'] as bool? ?? false;
|
||||
final currentUserId = AuthService.instance.currentUser?.id;
|
||||
final isOwnProfile = currentUserId != null &&
|
||||
currentUserId.toLowerCase() == profile.id.toLowerCase();
|
||||
final isOwnProfile = _isOwnProfileMode ||
|
||||
(currentUserId != null &&
|
||||
currentUserId.toLowerCase() == profile.id.toLowerCase());
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
|
|
@ -137,6 +169,13 @@ class _ViewableProfileScreenState extends ConsumerState<ViewableProfileScreen>
|
|||
await _loadPosts(refresh: true);
|
||||
} catch (error) {
|
||||
if (!mounted) return;
|
||||
|
||||
// Auto-create profile if own profile and profile not found
|
||||
if (_isOwnProfileMode && _shouldAutoCreateProfile(error)) {
|
||||
await _createProfileIfMissing();
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_profileError = error.toString().replaceAll('Exception: ', '');
|
||||
});
|
||||
|
|
@ -149,6 +188,54 @@ class _ViewableProfileScreenState extends ConsumerState<ViewableProfileScreen>
|
|||
}
|
||||
}
|
||||
|
||||
bool _shouldAutoCreateProfile(dynamic error) {
|
||||
final errorStr = error.toString().toLowerCase();
|
||||
return errorStr.contains('profile not found') ||
|
||||
errorStr.contains('no profile');
|
||||
}
|
||||
|
||||
Future<void> _createProfileIfMissing() async {
|
||||
if (_isCreatingProfile) return;
|
||||
|
||||
setState(() {
|
||||
_isCreatingProfile = true;
|
||||
_profileError = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final apiService = ref.read(apiServiceProvider);
|
||||
final user = AuthService.instance.currentUser;
|
||||
|
||||
if (user == null) {
|
||||
throw Exception('No authenticated user');
|
||||
}
|
||||
|
||||
final defaultHandle =
|
||||
user.email?.split('@').first ?? 'user${user.id.substring(0, 8)}';
|
||||
final defaultDisplayName = user.email?.split('@').first ?? 'User';
|
||||
|
||||
await apiService.createProfile(
|
||||
handle: defaultHandle,
|
||||
displayName: defaultDisplayName,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
await _loadProfile();
|
||||
} catch (error) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_profileError =
|
||||
'Could not create profile: ${error.toString().replaceAll('Exception: ', '')}';
|
||||
});
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isCreatingProfile = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadActiveFeed() async {
|
||||
switch (_activeTab) {
|
||||
case 0:
|
||||
|
|
@ -636,35 +723,164 @@ class _ViewableProfileScreenState extends ConsumerState<ViewableProfileScreen>
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _refreshAll() async {
|
||||
await _loadProfile();
|
||||
}
|
||||
|
||||
void _navigateToConnections(int tabIndex) {
|
||||
if (_profile == null) return;
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => FollowersFollowingScreen(
|
||||
userId: _profile!.id,
|
||||
initialTabIndex: tabIndex,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAvatarActions() {
|
||||
final profile = _profile;
|
||||
if (profile == null) return;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(AppTheme.spacingLg),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).cardColor,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.visibility),
|
||||
title: const Text('View profile photo'),
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
_showAvatarPreview(profile);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_camera),
|
||||
title: const Text('Change profile photo'),
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
_openSettings();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showAvatarPreview(Profile profile) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
final avatarUrl = _resolveAvatar(profile.avatarUrl);
|
||||
return Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
insetPadding: const EdgeInsets.all(AppTheme.spacingLg),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(AppTheme.spacingLg),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).cardColor,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'Profile Photo',
|
||||
style: AppTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingLg),
|
||||
CircleAvatar(
|
||||
radius: 72,
|
||||
backgroundColor: AppTheme.queenPink,
|
||||
child: avatarUrl.isNotEmpty
|
||||
? ClipOval(
|
||||
child: SizedBox(
|
||||
width: 144,
|
||||
height: 144,
|
||||
child: SignedMediaImage(
|
||||
url: avatarUrl,
|
||||
width: 144,
|
||||
height: 144,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
profile.displayName.isNotEmpty
|
||||
? profile.displayName[0].toUpperCase()
|
||||
: '?',
|
||||
style: AppTheme.headlineMedium.copyWith(
|
||||
color: AppTheme.royalPurple,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingLg),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final titleText = _isOwnProfileMode ? 'Profile' : '@${widget.handle ?? ''}';
|
||||
|
||||
if (_profileError != null && _profile == null && !_isLoadingProfile) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('@${widget.handle}'),
|
||||
),
|
||||
appBar: _isOwnProfileMode ? null : AppBar(title: Text(titleText)),
|
||||
body: _buildErrorState(),
|
||||
);
|
||||
}
|
||||
|
||||
if (_isLoadingProfile && _profile == null) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('@${widget.handle}'),
|
||||
),
|
||||
appBar: _isOwnProfileMode ? null : AppBar(title: Text(titleText)),
|
||||
body: const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
if (_profile == null) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('@${widget.handle}'),
|
||||
),
|
||||
body: const Center(child: Text('Profile not found')),
|
||||
appBar: _isOwnProfileMode ? null : AppBar(title: Text(titleText)),
|
||||
body: const Center(child: Text('No profile found')),
|
||||
);
|
||||
}
|
||||
|
||||
// Own profile: no top AppBar (SliverAppBar only, like the old ProfileScreen)
|
||||
if (_isOwnProfileMode) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.scaffoldBg,
|
||||
body: NestedScrollView(
|
||||
headerSliverBuilder: (context, innerBoxIsScrolled) {
|
||||
return [
|
||||
_buildSliverAppBar(_profile!),
|
||||
_buildSliverTabBar(),
|
||||
];
|
||||
},
|
||||
body: _buildTabBarView(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Viewing another user: AppBar with back button
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.scaffoldBg,
|
||||
appBar: AppBar(
|
||||
|
|
@ -675,7 +891,6 @@ class _ViewableProfileScreenState extends ConsumerState<ViewableProfileScreen>
|
|||
if (Navigator.of(context).canPop()) {
|
||||
Navigator.of(context).pop();
|
||||
} else {
|
||||
// Safely return to home/feed instead of pushing a redundant ProfileScreen
|
||||
context.go(AppRoutes.homeAlias);
|
||||
}
|
||||
},
|
||||
|
|
@ -720,7 +935,7 @@ class _ViewableProfileScreenState extends ConsumerState<ViewableProfileScreen>
|
|||
|
||||
Widget _buildSliverAppBar(Profile profile) {
|
||||
return SliverAppBar(
|
||||
expandedHeight: 295,
|
||||
expandedHeight: _isOwnProfile ? 255 : 295,
|
||||
pinned: true,
|
||||
toolbarHeight: 0,
|
||||
collapsedHeight: 0,
|
||||
|
|
@ -741,6 +956,8 @@ class _ViewableProfileScreenState extends ConsumerState<ViewableProfileScreen>
|
|||
onMessageTap: _openMessage,
|
||||
onSettingsTap: _openSettings,
|
||||
onPrivacyTap: _openPrivacyMenu,
|
||||
onAvatarTap: _isOwnProfile ? _showAvatarActions : null,
|
||||
onConnectionsTap: _isOwnProfile ? _navigateToConnections : null,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -757,11 +974,11 @@ class _ViewableProfileScreenState extends ConsumerState<ViewableProfileScreen>
|
|||
indicatorColor: AppTheme.royalPurple,
|
||||
indicatorWeight: 3,
|
||||
labelStyle: AppTheme.labelMedium,
|
||||
tabs: const [
|
||||
Tab(text: 'Posts'),
|
||||
Tab(text: 'Saved'),
|
||||
Tab(text: 'Chains'),
|
||||
Tab(text: 'About'),
|
||||
tabs: [
|
||||
const Tab(text: 'Posts'),
|
||||
const Tab(text: 'Saved'),
|
||||
const Tab(text: 'Chains'),
|
||||
if (!_isOwnProfileMode) const Tab(text: 'About'),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -866,7 +1083,7 @@ class _ViewableProfileScreenState extends ConsumerState<ViewableProfileScreen>
|
|||
? 0
|
||||
: AppTheme.spacingSm,
|
||||
),
|
||||
child: PostItem(
|
||||
child: sojornPostCard(
|
||||
post: post,
|
||||
onTap: () => _openPostDetail(post),
|
||||
onChain: () => _openChainComposer(post),
|
||||
|
|
@ -1146,7 +1363,7 @@ class _ViewableProfileScreenState extends ConsumerState<ViewableProfileScreen>
|
|||
}
|
||||
|
||||
// ==============================================================================
|
||||
// PROFILE HEADER FOR VIEWABLE PROFILE
|
||||
// UNIFIED PROFILE HEADER
|
||||
// ==============================================================================
|
||||
|
||||
class _ProfileHeader extends StatelessWidget {
|
||||
|
|
@ -1162,6 +1379,8 @@ class _ProfileHeader extends StatelessWidget {
|
|||
final VoidCallback onMessageTap;
|
||||
final VoidCallback onSettingsTap;
|
||||
final VoidCallback onPrivacyTap;
|
||||
final VoidCallback? onAvatarTap;
|
||||
final void Function(int tabIndex)? onConnectionsTap;
|
||||
|
||||
const _ProfileHeader({
|
||||
required this.profile,
|
||||
|
|
@ -1176,6 +1395,8 @@ class _ProfileHeader extends StatelessWidget {
|
|||
required this.onMessageTap,
|
||||
required this.onSettingsTap,
|
||||
required this.onPrivacyTap,
|
||||
this.onAvatarTap,
|
||||
this.onConnectionsTap,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -1222,10 +1443,20 @@ class _ProfileHeader extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
),
|
||||
_HarmonyAvatar(
|
||||
profile: profile,
|
||||
radius: avatarRadius,
|
||||
),
|
||||
if (onAvatarTap != null)
|
||||
InkResponse(
|
||||
onTap: onAvatarTap,
|
||||
radius: 40,
|
||||
child: _HarmonyAvatar(
|
||||
profile: profile,
|
||||
radius: avatarRadius,
|
||||
),
|
||||
)
|
||||
else
|
||||
_HarmonyAvatar(
|
||||
profile: profile,
|
||||
radius: avatarRadius,
|
||||
),
|
||||
SizedBox(height: isCompact ? 4 : 6),
|
||||
Text(
|
||||
profile.displayName,
|
||||
|
|
@ -1412,9 +1643,17 @@ class _ProfileHeader extends StatelessWidget {
|
|||
children: [
|
||||
_StatItem(label: 'Posts', value: stats.posts.toString()),
|
||||
const SizedBox(width: AppTheme.spacingMd),
|
||||
_StatItem(label: 'Followers', value: stats.followers.toString()),
|
||||
_StatItem(
|
||||
label: 'Followers',
|
||||
value: stats.followers.toString(),
|
||||
onTap: onConnectionsTap != null ? () => onConnectionsTap!(0) : null,
|
||||
),
|
||||
const SizedBox(width: AppTheme.spacingMd),
|
||||
_StatItem(label: 'Following', value: stats.following.toString()),
|
||||
_StatItem(
|
||||
label: 'Following',
|
||||
value: stats.following.toString(),
|
||||
onTap: onConnectionsTap != null ? () => onConnectionsTap!(1) : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -1443,15 +1682,17 @@ class _ProfileHeader extends StatelessWidget {
|
|||
class _StatItem extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _StatItem({
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
final content = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
|
|
@ -1482,6 +1723,11 @@ class _StatItem extends StatelessWidget {
|
|||
),
|
||||
],
|
||||
);
|
||||
|
||||
if (onTap != null) {
|
||||
return GestureDetector(onTap: onTap, child: content);
|
||||
}
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -385,7 +385,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) =>
|
||||
ViewableProfileScreen(handle: user.username)),
|
||||
UnifiedProfileScreen(handle: user.username)),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ class TurnstileWidget extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
// On web, use the full API URL
|
||||
// On mobile, Turnstile handles its own endpoints
|
||||
final effectiveBaseUrl = kIsWeb ? (baseUrl ?? ApiConfig.baseUrl) : null;
|
||||
final effectiveBaseUrl = baseUrl ?? ApiConfig.baseUrl;
|
||||
|
||||
return CloudflareTurnstile(
|
||||
siteKey: siteKey,
|
||||
|
|
@ -30,11 +30,6 @@ class TurnstileWidget extends StatelessWidget {
|
|||
print('Turnstile error: $error');
|
||||
}
|
||||
},
|
||||
onExpired: () {
|
||||
if (kDebugMode) {
|
||||
print('Turnstile token expired');
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,60 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../screens/compose/compose_screen.dart';
|
||||
import '../screens/secure_chat/secure_chat_full_screen.dart';
|
||||
|
||||
/// Floating action buttons for composing new posts and accessing chat
|
||||
class ComposeAndChatFab extends StatelessWidget {
|
||||
final String? composeHeroTag;
|
||||
final String? chatHeroTag;
|
||||
|
||||
const ComposeAndChatFab({
|
||||
super.key,
|
||||
this.composeHeroTag,
|
||||
this.chatHeroTag,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
// Chat Button - Opens unified secure chat
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16.0),
|
||||
child: FloatingActionButton(
|
||||
heroTag: chatHeroTag,
|
||||
tooltip: 'Messages',
|
||||
onPressed: () {
|
||||
Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const SecureChatFullScreen(),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
},
|
||||
backgroundColor: AppTheme.queenPink,
|
||||
child: const Icon(Icons.chat_bubble_outline, color: AppTheme.white),
|
||||
),
|
||||
),
|
||||
// Compose Button
|
||||
FloatingActionButton(
|
||||
heroTag: composeHeroTag,
|
||||
tooltip: 'Compose',
|
||||
onPressed: () async {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const ComposeScreen(),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
// Reset focus so the underlying screen is interactive on web
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
},
|
||||
backgroundColor: AppTheme.brightNavy,
|
||||
child: const Icon(Icons.edit_outlined, color: AppTheme.white),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,906 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'dart:ui';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:timeago/timeago.dart' as timeago;
|
||||
import '../models/post.dart';
|
||||
import '../models/thread_node.dart';
|
||||
import '../providers/api_provider.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../widgets/media/signed_media_image.dart';
|
||||
import 'kinetic_thread_widget.dart';
|
||||
|
||||
/// Glassmorphic TikTok-style quips sheet with HUD design
|
||||
class GlassmorphicQuipsSheet extends ConsumerStatefulWidget {
|
||||
final String postId;
|
||||
final int initialQuipCount;
|
||||
final VoidCallback? onQuipPosted;
|
||||
|
||||
const GlassmorphicQuipsSheet({
|
||||
super.key,
|
||||
required this.postId,
|
||||
this.initialQuipCount = 0,
|
||||
this.onQuipPosted,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<GlassmorphicQuipsSheet> createState() => _GlassmorphicQuipsSheetState();
|
||||
}
|
||||
|
||||
class _GlassmorphicQuipsSheetState extends ConsumerState<GlassmorphicQuipsSheet>
|
||||
with SingleTickerProviderStateMixin {
|
||||
|
||||
late AnimationController _glassController;
|
||||
late Animation<double> _glassAnimation;
|
||||
late Animation<double> _blurAnimation;
|
||||
|
||||
List<Post> _quips = [];
|
||||
ThreadNode? _threadTree;
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
final TextEditingController _quipController = TextEditingController();
|
||||
bool _isPostingQuip = false;
|
||||
|
||||
// Expanded quip state
|
||||
String? _expandedQuipId;
|
||||
ThreadNode? _expandedThreadNode;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_glassController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 500),
|
||||
);
|
||||
|
||||
_glassAnimation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _glassController,
|
||||
curve: Curves.easeOutCubic,
|
||||
));
|
||||
|
||||
_blurAnimation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 10.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _glassController,
|
||||
curve: Curves.easeOutCubic,
|
||||
));
|
||||
|
||||
_loadQuips();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_glassController.dispose();
|
||||
_quipController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadQuips() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final api = ref.read(apiServiceProvider);
|
||||
final quips = await api.getPostChain(widget.postId);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_quips = quips;
|
||||
_threadTree = quips.isEmpty ? null : ThreadNode.buildTree(quips);
|
||||
_isLoading = false;
|
||||
});
|
||||
_glassController.forward();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _postQuip() async {
|
||||
if (_quipController.text.trim().isEmpty) return;
|
||||
|
||||
setState(() => _isPostingQuip = true);
|
||||
|
||||
try {
|
||||
final api = ref.read(apiServiceProvider);
|
||||
await api.publishPost(
|
||||
body: _quipController.text.trim(),
|
||||
chainParentId: widget.postId,
|
||||
allowChain: true,
|
||||
);
|
||||
|
||||
_quipController.clear();
|
||||
|
||||
// Refresh quips
|
||||
await _loadQuips();
|
||||
|
||||
widget.onQuipPosted?.call();
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Quip posted!'),
|
||||
backgroundColor: Colors.green,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to post quip: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isPostingQuip = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _expandQuip(ThreadNode node) {
|
||||
setState(() {
|
||||
_expandedQuipId = node.post.id;
|
||||
_expandedThreadNode = node;
|
||||
});
|
||||
}
|
||||
|
||||
void _collapseQuip() {
|
||||
setState(() {
|
||||
_expandedQuipId = null;
|
||||
_expandedThreadNode = null;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DraggableScrollableSheet(
|
||||
initialChildSize: 0.6,
|
||||
minChildSize: 0.4,
|
||||
maxChildSize: 0.95,
|
||||
snap: true,
|
||||
snapSizes: const [0.6, 0.8, 0.95],
|
||||
builder: (context, scrollController) {
|
||||
return AnimatedBuilder(
|
||||
animation: _glassController,
|
||||
builder: (context, child) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.scaffoldBg.withValues(alpha: 0.9 + (0.1 * _glassAnimation.value)),
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.3 * _glassAnimation.value),
|
||||
blurRadius: 30,
|
||||
offset: const Offset(0, -10),
|
||||
),
|
||||
],
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.1 * _glassAnimation.value),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(
|
||||
sigmaX: _blurAnimation.value,
|
||||
sigmaY: _blurAnimation.value,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Glassmorphic drag handle
|
||||
_buildGlassDragHandle(),
|
||||
|
||||
// HUD-style header
|
||||
_buildHUDHeader(),
|
||||
|
||||
// Main content area
|
||||
Expanded(
|
||||
child: _buildMainContent(scrollController),
|
||||
),
|
||||
|
||||
// Glassmorphic quip input
|
||||
_buildGlassQuipInput(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGlassDragHandle() {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Container(
|
||||
width: 48,
|
||||
height: 5,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHUDHeader() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// HUD-style title
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.brightNavy.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: AppTheme.brightNavy.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.chat_bubble_outline,
|
||||
size: 16,
|
||||
color: AppTheme.brightNavy,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'QUIPS',
|
||||
style: GoogleFonts.inter(
|
||||
color: AppTheme.brightNavy,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// Animated count
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'${(_threadTree?.totalCount ?? widget.initialQuipCount)}',
|
||||
style: GoogleFonts.inter(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Refresh button
|
||||
GestureDetector(
|
||||
onTap: _loadQuips,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.refresh,
|
||||
size: 18,
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMainContent(ScrollController scrollController) {
|
||||
if (_isLoading) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(AppTheme.brightNavy),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Loading quips...',
|
||||
style: GoogleFonts.inter(
|
||||
color: Colors.white.withValues(alpha: 0.7),
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_error != null) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: Colors.red.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 48,
|
||||
color: Colors.red.withValues(alpha: 0.8),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Failed to load quips',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_error!,
|
||||
textAlign: TextAlign.center,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 14,
|
||||
color: Colors.white.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _loadQuips,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red.withValues(alpha: 0.8),
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: Text('Try Again'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_threadTree == null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.chat_bubble_outline,
|
||||
size: 48,
|
||||
color: Colors.white.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No quips yet',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Be the first to drop a quip!',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 14,
|
||||
color: Colors.white.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// If we have an expanded quip, show the kinetic thread widget
|
||||
if (_expandedThreadNode != null) {
|
||||
return Column(
|
||||
children: [
|
||||
// Back button
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: _collapseQuip,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.arrow_back,
|
||||
size: 18,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Back to Quips',
|
||||
style: GoogleFonts.inter(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Kinetic thread view
|
||||
Expanded(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: KineticThreadWidget(
|
||||
rootNode: _expandedThreadNode!,
|
||||
onReplyPosted: _loadQuips,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Show glassmorphic quip cards
|
||||
return FadeTransition(
|
||||
opacity: _glassAnimation,
|
||||
child: ListView.builder(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
itemCount: _threadTree!.children.length,
|
||||
itemBuilder: (context, index) {
|
||||
final child = _threadTree!.children[index];
|
||||
return _buildGlassQuipCard(child, index);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGlassQuipCard(ThreadNode node, int index) {
|
||||
final isExpanded = _expandedQuipId == node.post.id;
|
||||
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
transform: Matrix4.translationValues(0, 0, 0),
|
||||
child: GestureDetector(
|
||||
onTap: () => _expandQuip(node),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.white.withValues(alpha: 0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Author row
|
||||
Row(
|
||||
children: [
|
||||
// Glassmorphic avatar
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
AppTheme.brightNavy.withValues(alpha: 0.3),
|
||||
AppTheme.egyptianBlue.withValues(alpha: 0.3),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'L${node.depth}',
|
||||
style: GoogleFonts.inter(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Author info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
node.post.author?.displayName ?? 'Anonymous',
|
||||
style: GoogleFonts.inter(
|
||||
color: Colors.white,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
timeago.format(node.post.createdAt),
|
||||
style: GoogleFonts.inter(
|
||||
color: Colors.white.withValues(alpha: 0.6),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Reply count indicator
|
||||
if (node.hasChildren)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.egyptianBlue.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: AppTheme.egyptianBlue.withValues(alpha: 0.4),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.subdirectory_arrow_right,
|
||||
size: 12,
|
||||
color: AppTheme.egyptianBlue,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${node.totalDescendants}',
|
||||
style: GoogleFonts.inter(
|
||||
color: AppTheme.egyptianBlue,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Quip content
|
||||
Text(
|
||||
node.post.body,
|
||||
style: GoogleFonts.inter(
|
||||
color: Colors.white.withValues(alpha: 0.9),
|
||||
fontSize: 15,
|
||||
height: 1.4,
|
||||
),
|
||||
maxLines: isExpanded ? null : 3,
|
||||
overflow: isExpanded ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
// Media if present
|
||||
if (node.post.imageUrl != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: SignedMediaImage(
|
||||
url: node.post.imageUrl!,
|
||||
width: double.infinity,
|
||||
height: 150,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
if (isExpanded && node.hasChildren) ...[
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Nested replies preview
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Thread Replies',
|
||||
style: GoogleFonts.inter(
|
||||
color: Colors.white.withValues(alpha: 0.7),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...node.children.take(3).map((child) =>
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'L${child.depth}',
|
||||
style: GoogleFonts.inter(
|
||||
color: Colors.white.withValues(alpha: 0.7),
|
||||
fontSize: 8,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
child.post.body,
|
||||
style: GoogleFonts.inter(
|
||||
color: Colors.white.withValues(alpha: 0.6),
|
||||
fontSize: 12,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
).toList(),
|
||||
|
||||
if (node.children.length > 3)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
'+${node.children.length - 3} more replies',
|
||||
style: GoogleFonts.inter(
|
||||
color: AppTheme.egyptianBlue,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Action buttons
|
||||
Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => _expandQuip(node),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
isExpanded ? Icons.expand_less : Icons.expand_more,
|
||||
size: 16,
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
isExpanded ? 'Collapse' : 'Expand Thread',
|
||||
style: GoogleFonts.inter(
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGlassQuipInput() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.05),
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _quipController,
|
||||
maxLines: 2,
|
||||
minLines: 1,
|
||||
style: GoogleFonts.inter(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Drop a quip...',
|
||||
hintStyle: GoogleFonts.inter(
|
||||
color: Colors.white.withValues(alpha: 0.5),
|
||||
fontSize: 14,
|
||||
),
|
||||
border: InputBorder.none,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: FloatingActionButton(
|
||||
onPressed: (_quipController.text.trim().isNotEmpty && !_isPostingQuip)
|
||||
? _postQuip
|
||||
: null,
|
||||
backgroundColor: _quipController.text.isNotEmpty
|
||||
? AppTheme.brightNavy.withValues(alpha: 0.9)
|
||||
: Colors.white.withValues(alpha: 0.2),
|
||||
mini: true,
|
||||
child: _isPostingQuip
|
||||
? SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
Icons.send,
|
||||
size: 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -51,6 +51,8 @@ class PostBody extends StatelessWidget {
|
|||
return null; // Show all in detail
|
||||
case PostViewMode.compact:
|
||||
return 6; // More compact in profile lists
|
||||
case PostViewMode.thread:
|
||||
return 4; // Very compact in thread replies
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,9 +4,6 @@ import '../../models/post.dart';
|
|||
import '../../theme/app_theme.dart';
|
||||
import 'post_view_mode.dart';
|
||||
|
||||
/// Full-width link preview card shown below the post body.
|
||||
/// Displays OG image as a full-width thumbnail (same sizing as post images),
|
||||
/// with title, description, and site name overlaid/below.
|
||||
class PostLinkPreview extends StatelessWidget {
|
||||
final Post post;
|
||||
final PostViewMode mode;
|
||||
|
|
@ -25,6 +22,8 @@ class PostLinkPreview extends StatelessWidget {
|
|||
return 280.0;
|
||||
case PostViewMode.compact:
|
||||
return 160.0;
|
||||
case PostViewMode.thread:
|
||||
return 120.0;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -38,9 +37,7 @@ class PostLinkPreview extends StatelessWidget {
|
|||
final description = post.linkPreviewDescription ?? '';
|
||||
final siteName = post.linkPreviewSiteName ?? '';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: GestureDetector(
|
||||
return GestureDetector(
|
||||
onTap: () => _launchUrl(post.linkPreviewUrl!),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
|
|
@ -146,7 +143,6 @@ class PostLinkPreview extends StatelessWidget {
|
|||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ class PostMedia extends StatelessWidget {
|
|||
return 600.0;
|
||||
case PostViewMode.compact:
|
||||
return 200.0;
|
||||
case PostViewMode.thread:
|
||||
return 150.0;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,4 +10,8 @@ enum PostViewMode {
|
|||
|
||||
/// Compact view for profile lists - minimal header, reduced spacing
|
||||
compact,
|
||||
|
||||
/// Thread view - reduced padding, no card elevation, smaller avatars,
|
||||
/// connecting lines align correctly, media collapsed
|
||||
thread,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,107 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../models/post.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import 'chain_quote_widget.dart';
|
||||
import 'post/post_actions.dart';
|
||||
import 'post/post_body.dart';
|
||||
import 'post/post_header.dart';
|
||||
import 'post/post_media.dart';
|
||||
|
||||
enum PostCardVariant {
|
||||
feed,
|
||||
detail,
|
||||
profile,
|
||||
}
|
||||
|
||||
/// @deprecated
|
||||
/// Use [UnifiedPostTile] from post_item.dart instead.
|
||||
/// PostCard is deprecated in favor of the "Strict Flat" design system.
|
||||
@Deprecated('Use UnifiedPostTile from post_item.dart instead. PostCard is deprecated.')
|
||||
class PostCard extends StatelessWidget {
|
||||
final Post post;
|
||||
final PostCardVariant variant;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onChain;
|
||||
final VoidCallback? onChainParentTap;
|
||||
final bool isAlternate;
|
||||
final bool showChainContext;
|
||||
|
||||
const PostCard({
|
||||
super.key,
|
||||
required this.post,
|
||||
this.variant = PostCardVariant.feed,
|
||||
this.onTap,
|
||||
this.onChain,
|
||||
this.onChainParentTap,
|
||||
this.isAlternate = false,
|
||||
this.showChainContext = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final padding = switch (variant) {
|
||||
PostCardVariant.detail =>
|
||||
const EdgeInsets.symmetric(vertical: AppTheme.spacingMd),
|
||||
_ => EdgeInsets.zero,
|
||||
};
|
||||
|
||||
const contentPadding = EdgeInsets.symmetric(
|
||||
horizontal: AppTheme.spacingLg,
|
||||
vertical: AppTheme.spacingSm,
|
||||
);
|
||||
|
||||
Widget content = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Chain Context (The Quote Box)
|
||||
if (showChainContext && post.chainParent != null) ...[
|
||||
ChainQuoteWidget(
|
||||
parent: post.chainParent!,
|
||||
onTap: onChainParentTap,
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingSm),
|
||||
],
|
||||
|
||||
// Main Post Content
|
||||
PostHeader(post: post),
|
||||
const SizedBox(height: AppTheme.spacingMd),
|
||||
PostBody(text: post.body, bodyFormat: post.bodyFormat),
|
||||
PostMedia(post: post),
|
||||
const SizedBox(height: AppTheme.spacingMd),
|
||||
PostActions(
|
||||
post: post,
|
||||
onChain: onChain,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
if (isAlternate) {
|
||||
content = Container(
|
||||
padding: contentPadding,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.white, // Replaced AppTheme.surface
|
||||
borderRadius: BorderRadius.circular(8.0), // Replaced AppTheme.radiusMd
|
||||
),
|
||||
child: content,
|
||||
);
|
||||
} else {
|
||||
content = Padding(
|
||||
padding: contentPadding,
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
highlightColor: Colors.transparent,
|
||||
splashColor: Colors.transparent,
|
||||
child: Padding(
|
||||
padding: padding,
|
||||
child: content,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,139 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../models/post.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import 'sojorn_post_card.dart';
|
||||
import 'post/post_view_mode.dart';
|
||||
|
||||
/// UnifiedPostTile - Backward-compatible wrapper around sojornPostCard.
|
||||
///
|
||||
/// This class is now DEPRECATED in favor of sojornPostCard.
|
||||
/// It exists only for backward compatibility with existing code.
|
||||
///
|
||||
/// ## New Usage
|
||||
/// ```dart
|
||||
/// sojornPostCard(
|
||||
/// post: post,
|
||||
/// mode: PostViewMode.feed,
|
||||
/// onTap: () {},
|
||||
/// )
|
||||
/// ```
|
||||
///
|
||||
/// ## Migration Guide
|
||||
/// Replace `UnifiedPostTile(...)` with `sojornPostCard(...)`
|
||||
class UnifiedPostTile extends StatelessWidget {
|
||||
final Post post;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onChain;
|
||||
final bool showDivider;
|
||||
final bool isDetailView;
|
||||
final bool showThreadSpine;
|
||||
final double? avatarSize;
|
||||
final bool isThreadView;
|
||||
|
||||
const UnifiedPostTile({
|
||||
super.key,
|
||||
required this.post,
|
||||
this.onTap,
|
||||
this.onChain,
|
||||
this.showDivider = true,
|
||||
this.isDetailView = false,
|
||||
this.showThreadSpine = false,
|
||||
this.avatarSize,
|
||||
this.isThreadView = false,
|
||||
});
|
||||
|
||||
/// Convert legacy parameters to PostViewMode
|
||||
PostViewMode get _viewMode {
|
||||
if (isDetailView) return PostViewMode.detail;
|
||||
if (avatarSize != null && avatarSize! < 32) return PostViewMode.compact;
|
||||
return PostViewMode.feed;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Thread spine line (if enabled in threaded view)
|
||||
if (showThreadSpine) _buildThreadSpine(context),
|
||||
|
||||
// Backward compatibility: wrap in divider if needed
|
||||
if (showDivider)
|
||||
Column(
|
||||
children: [
|
||||
_buildPostContent(context),
|
||||
_buildStrictDivider(),
|
||||
],
|
||||
)
|
||||
else
|
||||
_buildPostContent(context),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPostContent(BuildContext context) {
|
||||
return sojornPostCard(
|
||||
post: post,
|
||||
mode: _viewMode,
|
||||
onTap: onTap,
|
||||
onChain: onChain,
|
||||
isThreadView: isThreadView,
|
||||
);
|
||||
}
|
||||
|
||||
/// STRICT SEPARATION: Full-width Divider at bottom of every post
|
||||
Widget _buildStrictDivider() {
|
||||
return Container(
|
||||
height: 1.0,
|
||||
width: double.infinity,
|
||||
color: AppTheme.egyptianBlue.withOpacity(0.15),
|
||||
margin: EdgeInsets.zero,
|
||||
);
|
||||
}
|
||||
|
||||
/// Thread spine: vertical line connecting parent to child in threaded view
|
||||
Widget _buildThreadSpine(BuildContext context) {
|
||||
return CustomPaint(
|
||||
size: const Size(2, AppTheme.spacingMd),
|
||||
painter: ThreadSpinePainter(
|
||||
color: AppTheme.egyptianBlue.withOpacity(0.3),
|
||||
width: 2,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread Spine Painter - draws vertical line for threaded views
|
||||
class ThreadSpinePainter extends CustomPainter {
|
||||
final Color color;
|
||||
final double width;
|
||||
|
||||
ThreadSpinePainter({required this.color, required this.width});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..strokeWidth = width
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
// Draw vertical line from center-top to center-bottom
|
||||
canvas.drawRect(
|
||||
Rect.fromLTWH(
|
||||
size.width / 2 - width / 2,
|
||||
0,
|
||||
width,
|
||||
size.height,
|
||||
),
|
||||
paint,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(ThreadSpinePainter oldDelegate) {
|
||||
return oldDelegate.color != color || oldDelegate.width != width;
|
||||
}
|
||||
}
|
||||
|
||||
/// Backward-compatible alias for UnifiedPostTile
|
||||
typedef PostItem = UnifiedPostTile;
|
||||
|
|
@ -1,377 +0,0 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:flutter_svg/flutter_svg.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:') || reactionId.startsWith('assets/')) {
|
||||
final assetPath = reactionId.startsWith('asset:')
|
||||
? reactionId.replaceFirst('asset:', '')
|
||||
: reactionId;
|
||||
|
||||
if (assetPath.endsWith('.svg')) {
|
||||
return SvgPicture.asset(
|
||||
assetPath,
|
||||
width: 18,
|
||||
height: 18,
|
||||
placeholderBuilder: (_) => const SizedBox(width: 18, height: 18),
|
||||
);
|
||||
}
|
||||
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 AssetManifest.loadFromAssetBundle(DefaultAssetBundle.of(context));
|
||||
final keys = manifest.listAssets()
|
||||
.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,
|
||||
});
|
||||
}
|
||||
|
|
@ -1,125 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import '../../theme/app_theme.dart';
|
||||
|
||||
class SmartReactionButton extends ConsumerWidget {
|
||||
final Map<String, int> reactionCounts;
|
||||
final Set<String> myReactions;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
const SmartReactionButton({
|
||||
super.key,
|
||||
required this.reactionCounts,
|
||||
required this.myReactions,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
Widget _buildReactionContent(String reaction) {
|
||||
if (reaction.startsWith('assets/') || reaction.startsWith('asset:')) {
|
||||
final assetPath = reaction.startsWith('asset:')
|
||||
? reaction.replaceFirst('asset:', '')
|
||||
: reaction;
|
||||
|
||||
if (assetPath.endsWith('.svg')) {
|
||||
return SvgPicture.asset(
|
||||
assetPath,
|
||||
width: 18,
|
||||
height: 18,
|
||||
);
|
||||
}
|
||||
return Image.asset(
|
||||
assetPath,
|
||||
width: 18,
|
||||
height: 18,
|
||||
fit: BoxFit.contain,
|
||||
);
|
||||
}
|
||||
return Text(
|
||||
reaction,
|
||||
style: const TextStyle(fontSize: 18),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Determine what to show
|
||||
if (myReactions.isNotEmpty) {
|
||||
// Show user's reaction + total count
|
||||
final myReaction = myReactions.first;
|
||||
final totalCount = reactionCounts.values.fold(0, (a, b) => a + b);
|
||||
|
||||
return IconButton(
|
||||
onPressed: onPressed,
|
||||
icon: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildReactionContent(myReaction),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
totalCount > 99 ? '99+' : '$totalCount',
|
||||
style: GoogleFonts.inter(
|
||||
color: AppTheme.brightNavy,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: AppTheme.brightNavy.withValues(alpha: 0.15),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (reactionCounts.isNotEmpty) {
|
||||
// Show top reaction + total count
|
||||
final topReaction = reactionCounts.entries
|
||||
.reduce((a, b) => a.value > b.value ? a : b);
|
||||
final totalCount = reactionCounts.values.fold(0, (a, b) => a + b);
|
||||
|
||||
return IconButton(
|
||||
onPressed: onPressed,
|
||||
icon: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildReactionContent(topReaction.key),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
totalCount > 99 ? '99+' : '$totalCount',
|
||||
style: GoogleFonts.inter(
|
||||
color: AppTheme.textSecondary,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.08),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Show plus button
|
||||
return IconButton(
|
||||
onPressed: onPressed,
|
||||
icon: Icon(
|
||||
Icons.add_reaction_outlined,
|
||||
color: AppTheme.textSecondary,
|
||||
size: 20,
|
||||
),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.08),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,338 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:timeago/timeago.dart' as timeago;
|
||||
import '../models/post.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import 'media/signed_media_image.dart';
|
||||
import '../routes/app_routes.dart';
|
||||
|
||||
class ReadingPostCard extends StatefulWidget {
|
||||
final Post post;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onSave;
|
||||
final bool isSaved;
|
||||
final bool showDivider;
|
||||
|
||||
const ReadingPostCard({
|
||||
super.key,
|
||||
required this.post,
|
||||
this.onTap,
|
||||
this.onSave,
|
||||
this.isSaved = false,
|
||||
this.showDivider = true,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ReadingPostCard> createState() => _ReadingPostCardState();
|
||||
}
|
||||
|
||||
class _ReadingPostCardState extends State<ReadingPostCard> {
|
||||
bool _isPressed = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildPostCard(),
|
||||
if (widget.showDivider) _buildDivider(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPostCard() {
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxWidth: 680),
|
||||
margin: _getMargin(),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.white,
|
||||
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
|
||||
border: Border(
|
||||
left: BorderSide(
|
||||
color: _isPressed ? AppTheme.brightNavy : AppTheme.egyptianBlue,
|
||||
width: AppTheme.flowLineWidth,
|
||||
),
|
||||
right: BorderSide(color: AppTheme.egyptianBlue, width: 1),
|
||||
top: BorderSide(color: AppTheme.egyptianBlue, width: 1),
|
||||
bottom: BorderSide(color: AppTheme.egyptianBlue, width: 1),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.03),
|
||||
blurRadius: 8,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppTheme.spacingMd,
|
||||
AppTheme.spacingSm,
|
||||
AppTheme.spacingLg,
|
||||
AppTheme.spacingMd,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildAuthorRow(),
|
||||
const SizedBox(height: AppTheme.spacingMd),
|
||||
// White space area - clickable for post detail with full background coverage
|
||||
InkWell(
|
||||
onTap: widget.onTap,
|
||||
onTapDown: (_) => setState(() => _isPressed = true),
|
||||
onTapUp: (_) => setState(() => _isPressed = false),
|
||||
onTapCancel: () => setState(() => _isPressed = false),
|
||||
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
|
||||
splashColor: AppTheme.queenPink.withValues(alpha: 0.3),
|
||||
highlightColor: Colors.transparent,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: _buildBodyText(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingLg),
|
||||
_buildActionRow(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDivider() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppTheme.spacingMd),
|
||||
child: Container(
|
||||
height: AppTheme.dividerThickness,
|
||||
decoration: BoxDecoration(color: AppTheme.egyptianBlue),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
EdgeInsets _getMargin() {
|
||||
final charCount = widget.post.body.length;
|
||||
if (charCount < 100) {
|
||||
return EdgeInsets.only(
|
||||
left: AppTheme.spacingMd,
|
||||
right: AppTheme.spacingMd,
|
||||
top: AppTheme.spacingPostShort);
|
||||
} else if (charCount < 300) {
|
||||
return EdgeInsets.only(
|
||||
left: AppTheme.spacingMd,
|
||||
right: AppTheme.spacingMd,
|
||||
top: AppTheme.spacingPostMedium);
|
||||
}
|
||||
return EdgeInsets.only(
|
||||
left: AppTheme.spacingMd,
|
||||
right: AppTheme.spacingMd,
|
||||
top: AppTheme.spacingPostLong);
|
||||
}
|
||||
|
||||
Widget _buildAuthorRow() {
|
||||
final avatarUrl = widget.post.author?.avatarUrl;
|
||||
final handle = widget.post.author?.handle ?? '';
|
||||
final fallbackColor = _getAvatarColor(handle);
|
||||
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
if (handle.isNotEmpty && handle != 'unknown') {
|
||||
AppRoutes.navigateToProfile(context, handle);
|
||||
}
|
||||
},
|
||||
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: fallbackColor,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: avatarUrl != null && avatarUrl.isNotEmpty
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
child: SignedMediaImage(
|
||||
url: avatarUrl,
|
||||
width: 36,
|
||||
height: 36,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Text(
|
||||
handle.isNotEmpty ? handle[0].toUpperCase() : '?',
|
||||
style: AppTheme.textTheme.labelMedium?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppTheme.spacingSm),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
widget.post.author?.displayName ?? 'Unknown',
|
||||
style: AppTheme.textTheme.labelMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppTheme.navyBlue,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (widget.post.author?.isOfficial == true) ...[
|
||||
const SizedBox(width: AppTheme.spacingXs),
|
||||
_buildOfficialBadge(),
|
||||
],
|
||||
if (widget.post.author?.isOfficial != true &&
|
||||
widget.post.author?.trustState != null) ...[
|
||||
const SizedBox(width: AppTheme.spacingXs),
|
||||
_buildTrustBadge(),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
timeago.format(widget.post.createdAt),
|
||||
style: AppTheme.textTheme.labelSmall
|
||||
?.copyWith(color: AppTheme.egyptianBlue),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBodyText() {
|
||||
final charCount = widget.post.body.length;
|
||||
final style = charCount >= 10 ? AppTheme.postBodyLong : AppTheme.postBody;
|
||||
return Text(widget.post.body, style: style);
|
||||
}
|
||||
|
||||
Widget _buildActionRow() {
|
||||
return Row(
|
||||
children: [
|
||||
if (widget.post.contentIntegrityScore < 1.0) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppTheme.spacingSm, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: _getCISColor(widget.post.contentIntegrityScore)
|
||||
.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(AppTheme.radiusXs),
|
||||
),
|
||||
child: Text(
|
||||
'CIS ${(widget.post.contentIntegrityScore * 100).toInt()}%',
|
||||
style: AppTheme.textTheme.labelSmall?.copyWith(
|
||||
color: _getCISColor(widget.post.contentIntegrityScore),
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppTheme.spacingMd),
|
||||
],
|
||||
const Spacer(),
|
||||
_buildActionButton(
|
||||
icon: widget.isSaved ? Icons.bookmark : Icons.bookmark_border,
|
||||
isActive: widget.isSaved,
|
||||
onPressed: widget.onSave,
|
||||
color: AppTheme.brightNavy,
|
||||
),
|
||||
const SizedBox(width: AppTheme.spacingMd),
|
||||
_buildActionButton(
|
||||
icon: Icons.share_outlined,
|
||||
isActive: false,
|
||||
onPressed: null, // TODO: Implement share functionality
|
||||
color: AppTheme.brightNavy,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButton({
|
||||
required IconData icon,
|
||||
required bool isActive,
|
||||
VoidCallback? onPressed,
|
||||
required Color color,
|
||||
}) {
|
||||
return InkWell(
|
||||
onTap: onPressed,
|
||||
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppTheme.spacingSm, vertical: AppTheme.spacingXs),
|
||||
child: Icon(icon,
|
||||
size: 18, color: isActive ? color : AppTheme.royalPurple),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOfficialBadge() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.info.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(AppTheme.radiusXs),
|
||||
),
|
||||
child: Text('sojorn',
|
||||
style: AppTheme.textTheme.labelSmall?.copyWith(
|
||||
fontSize: 8,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.info,
|
||||
letterSpacing: 0.4,
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTrustBadge() {
|
||||
final tier = widget.post.author!.trustState!.tier;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: _getTierColor(tier.value).withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(AppTheme.radiusXs),
|
||||
),
|
||||
child: Text(tier.displayName.toUpperCase(),
|
||||
style: AppTheme.textTheme.labelSmall?.copyWith(
|
||||
fontSize: 8,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _getTierColor(tier.value),
|
||||
letterSpacing: 0.4,
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
Color _getAvatarColor(String handle) {
|
||||
final hash = handle.hashCode;
|
||||
final hue = (hash % 360).toDouble();
|
||||
return HSLColor.fromAHSL(1.0, hue, 0.45, 0.55).toColor();
|
||||
}
|
||||
|
||||
Color _getTierColor(String tier) {
|
||||
switch (tier) {
|
||||
case 'established':
|
||||
return AppTheme.tierEstablished;
|
||||
case 'trusted':
|
||||
return AppTheme.tierTrusted;
|
||||
default:
|
||||
return AppTheme.tierNew;
|
||||
}
|
||||
}
|
||||
|
||||
Color _getCISColor(double cis) {
|
||||
if (cis >= 0.8) return AppTheme.success;
|
||||
if (cis >= 0.6) return AppTheme.warning;
|
||||
return AppTheme.error;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Custom app bar enforcing sojorn's visual system, leveraging AppTheme.
|
||||
class sojornAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
final String? title;
|
||||
final Widget? leading;
|
||||
final List<Widget>? actions;
|
||||
final bool showBackButton;
|
||||
final VoidCallback? onBackPressed;
|
||||
final PreferredSizeWidget? bottom;
|
||||
|
||||
const sojornAppBar({
|
||||
super.key,
|
||||
this.title,
|
||||
this.leading,
|
||||
this.actions,
|
||||
this.showBackButton = false,
|
||||
this.onBackPressed,
|
||||
this.bottom,
|
||||
});
|
||||
|
||||
@override
|
||||
Size get preferredSize => Size.fromHeight(
|
||||
kToolbarHeight + (bottom?.preferredSize.height ?? 0),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppBar(
|
||||
// Rely on AppTheme.appBarTheme for background, foreground, elevation,
|
||||
// scrolledUnderElevation, centerTitle, and titleTextStyle
|
||||
leading: leading ??
|
||||
(showBackButton
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.arrow_back, size: 22),
|
||||
onPressed: onBackPressed ?? () => Navigator.of(context).pop(),
|
||||
)
|
||||
: null),
|
||||
title: (title == null || title!.isEmpty)
|
||||
? Image.asset(
|
||||
'assets/images/toplogo.png',
|
||||
height: 44,
|
||||
)
|
||||
: Text(
|
||||
title!,
|
||||
),
|
||||
actions: actions,
|
||||
bottom: bottom,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// sojornMinimalAppBar has been removed as its functionality is now
|
||||
// better handled by a standard AppBar leveraging AppTheme.appBarTheme.
|
||||
|
|
@ -1,139 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
/// Custom card widget enforcing sojorn's visual system
|
||||
class sojornCard extends StatelessWidget {
|
||||
final Widget child;
|
||||
final EdgeInsets? padding;
|
||||
final EdgeInsets? margin;
|
||||
final VoidCallback? onTap;
|
||||
final Color? backgroundColor;
|
||||
final bool showBorder;
|
||||
// Removed final bool showShadow; to align with AppTheme.cardTheme
|
||||
|
||||
const sojornCard({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.padding,
|
||||
this.margin,
|
||||
this.onTap,
|
||||
this.backgroundColor,
|
||||
this.showBorder = true,
|
||||
// Removed this.showShadow = false;
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final card = Container(
|
||||
margin: margin ??
|
||||
const EdgeInsets.symmetric(
|
||||
horizontal: AppTheme.spacingMd,
|
||||
vertical: AppTheme.spacingSm,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor ??
|
||||
AppTheme.white, // Replaced AppTheme.surfaceElevated
|
||||
borderRadius: BorderRadius.circular(12.0), // Replaced AppTheme.radiusLg
|
||||
border: showBorder
|
||||
? Border.all(
|
||||
color: AppTheme.egyptianBlue,
|
||||
width: 0.5) // Replaced AppTheme.border
|
||||
: null,
|
||||
// Removed boxShadow: showShadow ? AppTheme.shadowMd : null,
|
||||
),
|
||||
child: Padding(
|
||||
padding: padding ?? const EdgeInsets.all(AppTheme.spacingLg),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
|
||||
if (onTap != null) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius:
|
||||
BorderRadius.circular(12.0), // Replaced AppTheme.radiusLg
|
||||
child: card,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
}
|
||||
|
||||
/// Section card with optional header
|
||||
class sojornSectionCard extends StatelessWidget {
|
||||
final String? title;
|
||||
final String? subtitle;
|
||||
final Widget? trailing;
|
||||
final Widget child;
|
||||
final EdgeInsets? padding;
|
||||
final EdgeInsets? margin;
|
||||
|
||||
const sojornSectionCard({
|
||||
super.key,
|
||||
this.title,
|
||||
this.subtitle,
|
||||
this.trailing,
|
||||
required this.child,
|
||||
this.padding,
|
||||
this.margin,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return sojornCard(
|
||||
padding: EdgeInsets.zero,
|
||||
margin: margin,
|
||||
// Removed showShadow property as it's no longer supported by sojornCard
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (title != null || subtitle != null || trailing != null) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(AppTheme.spacingLg),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (title != null)
|
||||
Text(
|
||||
title!,
|
||||
style: AppTheme.headlineSmall,
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: AppTheme.spacingXs),
|
||||
Text(
|
||||
subtitle!,
|
||||
style: AppTheme.textTheme.bodyMedium?.copyWith(
|
||||
// Replaced bodySmall
|
||||
color: AppTheme.navyText
|
||||
.withOpacity(0.8), // Replaced textSecondary
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (trailing != null) ...[
|
||||
const SizedBox(width: AppTheme.spacingMd),
|
||||
trailing!,
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
],
|
||||
Padding(
|
||||
padding: padding ?? const EdgeInsets.all(AppTheme.spacingLg),
|
||||
child: child,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,241 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import 'sojorn_button.dart';
|
||||
|
||||
/// Custom dialog enforcing sojorn's visual system
|
||||
class sojornDialog extends StatelessWidget {
|
||||
final String title;
|
||||
final String? message;
|
||||
final Widget? content;
|
||||
final String? primaryButtonLabel;
|
||||
final VoidCallback? onPrimaryPressed;
|
||||
final String? secondaryButtonLabel;
|
||||
final VoidCallback? onSecondaryPressed;
|
||||
final bool isDismissible;
|
||||
final bool isDestructive;
|
||||
|
||||
const sojornDialog({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.message,
|
||||
this.content,
|
||||
this.primaryButtonLabel,
|
||||
this.onPrimaryPressed,
|
||||
this.secondaryButtonLabel,
|
||||
this.onSecondaryPressed,
|
||||
this.isDismissible = true,
|
||||
this.isDestructive = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
backgroundColor: AppTheme.white, // Replaced AppTheme.surfaceElevated
|
||||
elevation: 8,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12.0), // Replaced AppTheme.radiusLg
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppTheme.spacingLg),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Title
|
||||
Text(
|
||||
title,
|
||||
style: AppTheme.headlineSmall.copyWith(
|
||||
color: AppTheme.navyBlue, // Replaced AppTheme.textPrimary
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingMd),
|
||||
|
||||
// Message or custom content
|
||||
if (message != null)
|
||||
Text(
|
||||
message!,
|
||||
style: AppTheme.bodyMedium.copyWith(
|
||||
color: AppTheme.navyText
|
||||
.withOpacity(0.9), // Replaced AppTheme.textSecondary
|
||||
height: 1.6,
|
||||
),
|
||||
),
|
||||
if (content != null) content!,
|
||||
|
||||
const SizedBox(height: AppTheme.spacingLg),
|
||||
|
||||
// Action buttons
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (secondaryButtonLabel != null) ...[
|
||||
sojornButton(
|
||||
label: secondaryButtonLabel!,
|
||||
onPressed:
|
||||
onSecondaryPressed ?? () => Navigator.of(context).pop(),
|
||||
variant: sojornButtonVariant.tertiary,
|
||||
size: sojornButtonSize.small,
|
||||
),
|
||||
const SizedBox(width: AppTheme.spacingSm),
|
||||
],
|
||||
if (primaryButtonLabel != null)
|
||||
sojornButton(
|
||||
label: primaryButtonLabel!,
|
||||
onPressed: onPrimaryPressed,
|
||||
variant: isDestructive
|
||||
? sojornButtonVariant.destructive
|
||||
: sojornButtonVariant.primary,
|
||||
size: sojornButtonSize.small,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Show a confirmation dialog
|
||||
static Future<bool?> showConfirmation({
|
||||
required BuildContext context,
|
||||
required String title,
|
||||
required String message,
|
||||
String confirmLabel = 'Confirm',
|
||||
String cancelLabel = 'Cancel',
|
||||
bool isDestructive = false,
|
||||
}) {
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => sojornDialog(
|
||||
title: title,
|
||||
message: message,
|
||||
primaryButtonLabel: confirmLabel,
|
||||
onPrimaryPressed: () => Navigator.of(context).pop(true),
|
||||
secondaryButtonLabel: cancelLabel,
|
||||
onSecondaryPressed: () => Navigator.of(context).pop(false),
|
||||
isDestructive: isDestructive,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Show an informational dialog
|
||||
static Future<void> showInfo({
|
||||
required BuildContext context,
|
||||
required String title,
|
||||
required String message,
|
||||
String buttonLabel = 'OK',
|
||||
}) {
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => sojornDialog(
|
||||
title: title,
|
||||
message: message,
|
||||
primaryButtonLabel: buttonLabel,
|
||||
onPrimaryPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Show an error dialog
|
||||
static Future<void> showError({
|
||||
required BuildContext context,
|
||||
required String title,
|
||||
required String message,
|
||||
String buttonLabel = 'OK',
|
||||
}) {
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => sojornDialog(
|
||||
title: title,
|
||||
message: message,
|
||||
primaryButtonLabel: buttonLabel,
|
||||
onPrimaryPressed: () => Navigator.of(context).pop(),
|
||||
isDestructive: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bottom sheet variant for mobile-friendly actions
|
||||
class sojornBottomSheet extends StatelessWidget {
|
||||
final String? title;
|
||||
final Widget child;
|
||||
|
||||
const sojornBottomSheet({
|
||||
super.key,
|
||||
this.title,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.white, // Replaced AppTheme.surfaceElevated
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(12.0), // Replaced AppTheme.radiusLg
|
||||
topRight: Radius.circular(12.0), // Replaced AppTheme.radiusLg
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Handle
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(top: AppTheme.spacingMd),
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
AppTheme.egyptianBlue, // Replaced AppTheme.borderStrong
|
||||
borderRadius: BorderRadius.circular(AppTheme.radiusFull),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingMd),
|
||||
|
||||
// Title
|
||||
if (title != null) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppTheme.spacingLg,
|
||||
),
|
||||
child: Text(
|
||||
title!,
|
||||
style: AppTheme.headlineSmall,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingMd),
|
||||
],
|
||||
|
||||
// Content
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(AppTheme.spacingLg),
|
||||
child: child,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Show a bottom sheet
|
||||
static Future<T?> show<T>({
|
||||
required BuildContext context,
|
||||
String? title,
|
||||
required Widget child,
|
||||
}) {
|
||||
return showModalBottomSheet<T>(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => sojornBottomSheet(
|
||||
title: title,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -90,6 +90,11 @@ class _sojornPostCardState extends ConsumerState<sojornPostCard> {
|
|||
horizontal: AppTheme.spacingMd,
|
||||
vertical: AppTheme.spacingSm,
|
||||
);
|
||||
case PostViewMode.thread:
|
||||
return const EdgeInsets.symmetric(
|
||||
horizontal: AppTheme.spacingSm,
|
||||
vertical: AppTheme.spacingXs,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -117,9 +122,14 @@ class _sojornPostCardState extends ConsumerState<sojornPostCard> {
|
|||
return 44.0;
|
||||
case PostViewMode.compact:
|
||||
return 28.0;
|
||||
case PostViewMode.thread:
|
||||
return 24.0;
|
||||
}
|
||||
}
|
||||
|
||||
bool get _isThread => mode == PostViewMode.thread;
|
||||
bool get _effectiveThreadView => isThreadView || _isThread;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Completely hide NSFW posts when user hasn't enabled NSFW
|
||||
|
|
@ -128,24 +138,25 @@ class _sojornPostCardState extends ConsumerState<sojornPostCard> {
|
|||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 16), // Add spacing between cards
|
||||
margin: EdgeInsets.only(bottom: _isThread ? 4 : 16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.cardSurface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: AppTheme.navyBlue.withValues(alpha: 0.3), // Lighter border
|
||||
width: 1.5, // Slightly thinner border
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppTheme.brightNavy.withValues(alpha: 0.12), // Lighter shadow
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
],
|
||||
borderRadius: BorderRadius.circular(_isThread ? 12 : 20),
|
||||
border: _isThread
|
||||
? Border.all(color: AppTheme.navyBlue.withValues(alpha: 0.08), width: 1)
|
||||
: Border.all(color: AppTheme.navyBlue.withValues(alpha: 0.3), width: 1.5),
|
||||
boxShadow: _isThread
|
||||
? []
|
||||
: [
|
||||
BoxShadow(
|
||||
color: AppTheme.brightNavy.withValues(alpha: 0.12),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
borderRadius: BorderRadius.circular(_isThread ? 12 : 20),
|
||||
child: Container(
|
||||
padding: _padding.copyWith(left: 0, right: 0),
|
||||
child: Column(
|
||||
|
|
@ -158,7 +169,7 @@ class _sojornPostCardState extends ConsumerState<sojornPostCard> {
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Chain Context (The Quote Box) - only show in thread view
|
||||
if (isThreadView && showChainContext && post.chainParent != null) ...[
|
||||
if (_effectiveThreadView && showChainContext && post.chainParent != null) ...[
|
||||
ChainQuoteWidget(
|
||||
parent: post.chainParent!,
|
||||
onTap: onChainParentTap,
|
||||
|
|
@ -167,7 +178,7 @@ class _sojornPostCardState extends ConsumerState<sojornPostCard> {
|
|||
],
|
||||
|
||||
// Feed chain hint — subtle "replying to" for non-thread views
|
||||
if (!isThreadView && post.chainParent != null) ...[
|
||||
if (!_effectiveThreadView && post.chainParent != null) ...[
|
||||
GestureDetector(
|
||||
onTap: onTap,
|
||||
child: _ChainReplyHint(parent: post.chainParent!),
|
||||
|
|
@ -270,7 +281,7 @@ class _sojornPostCardState extends ConsumerState<sojornPostCard> {
|
|||
],
|
||||
// Link preview card after post body
|
||||
if (post.hasLinkPreview) ...[
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 8),
|
||||
PostLinkPreview(
|
||||
post: post,
|
||||
mode: mode,
|
||||
|
|
@ -372,8 +383,8 @@ class _sojornPostCardState extends ConsumerState<sojornPostCard> {
|
|||
post: post,
|
||||
onChain: onChain,
|
||||
onPostChanged: onPostChanged,
|
||||
isThreadView: isThreadView,
|
||||
showReactions: isThreadView,
|
||||
isThreadView: _effectiveThreadView,
|
||||
showReactions: _effectiveThreadView,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
|
|
@ -384,6 +395,7 @@ class _sojornPostCardState extends ConsumerState<sojornPostCard> {
|
|||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Subtle single-line "replying to" hint shown on feed cards that are chains.
|
||||
/// Uses a thin left accent bar and muted text to stay unobtrusive.
|
||||
|
|
|
|||
|
|
@ -1,37 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class sojornTopBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
final String title;
|
||||
final Widget? leading;
|
||||
final List<Widget>? actions;
|
||||
final bool centerTitle;
|
||||
final Color? backgroundColor;
|
||||
final double? elevation;
|
||||
|
||||
const sojornTopBar({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.leading,
|
||||
this.actions,
|
||||
this.centerTitle = false,
|
||||
this.backgroundColor,
|
||||
this.elevation,
|
||||
});
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppBar(
|
||||
title: title.isEmpty ? null : Text(title),
|
||||
leading: leading,
|
||||
actions: actions,
|
||||
centerTitle: centerTitle,
|
||||
backgroundColor: backgroundColor,
|
||||
elevation: elevation,
|
||||
// The rest of the styling (background, border, titleTextStyle etc.)
|
||||
// will be automatically applied from AppTheme.appBarTheme.
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue