Merge goSojorn into main: OG image R2 proxy, link preview fixes, spacing cleanup

This commit is contained in:
Patrick Britton 2026-02-10 13:03:19 -06:00
commit b8e070d121
33 changed files with 501 additions and 5641 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -385,7 +385,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) =>
ViewableProfileScreen(handle: user.username)),
UnifiedProfileScreen(handle: user.username)),
),
);
},

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -37,6 +37,8 @@ class PostMedia extends StatelessWidget {
return 600.0;
case PostViewMode.compact:
return 200.0;
case PostViewMode.thread:
return 150.0;
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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