sojorn/sojorn_app/lib/models/post.dart
Patrick Britton 3c4680bdd7 Initial commit: Complete threaded conversation system with inline replies
**Major Features Added:**
- **Inline Reply System**: Replace compose screen with inline reply boxes
- **Thread Navigation**: Parent/child navigation with jump functionality
- **Chain Flow UI**: Reply counts, expand/collapse animations, visual hierarchy
- **Enhanced Animations**: Smooth transitions, hover effects, micro-interactions

 **Frontend Changes:**
- **ThreadedCommentWidget**: Complete rewrite with animations and navigation
- **ThreadNode Model**: Added parent references and descendant counting
- **ThreadedConversationScreen**: Integrated navigation handlers
- **PostDetailScreen**: Replaced with threaded conversation view
- **ComposeScreen**: Added reply indicators and context
- **PostActions**: Fixed visibility checks for chain buttons

 **Backend Changes:**
- **API Route**: Added /posts/:id/thread endpoint
- **Post Repository**: Include allow_chain and visibility fields in feed
- **Thread Handler**: Support for fetching post chains

 **UI/UX Improvements:**
- **Reply Context**: Clear indication when replying to specific posts
- **Character Counting**: 500 character limit with live counter
- **Visual Hierarchy**: Depth-based indentation and styling
- **Smooth Animations**: SizeTransition, FadeTransition, hover states
- **Chain Navigation**: Parent/child buttons with visual feedback

 **Technical Enhancements:**
- **Animation Controllers**: Proper lifecycle management
- **State Management**: Clean separation of concerns
- **Navigation Callbacks**: Reusable navigation system
- **Error Handling**: Graceful fallbacks and user feedback

This creates a Reddit-style threaded conversation experience with smooth
animations, inline replies, and intuitive navigation between posts in a chain.
2026-01-30 07:40:19 -06:00

409 lines
12 KiB
Dart

import 'profile.dart';
import 'beacon.dart';
/// Post status enum
enum PostStatus {
active('active'),
flagged('flagged'),
removed('removed');
final String value;
const PostStatus(this.value);
static PostStatus fromString(String value) {
return PostStatus.values.firstWhere(
(status) => status.value == value,
orElse: () => PostStatus.active,
);
}
}
/// Tone label enum
enum ToneLabel {
positive('positive'),
neutral('neutral'),
mixed('mixed'),
negative('negative'),
hostile('hostile');
final String value;
const ToneLabel(this.value);
static ToneLabel fromString(String value) {
return ToneLabel.values.firstWhere(
(label) => label.value == value,
orElse: () => ToneLabel.neutral,
);
}
}
/// Post model matching backend posts table
class Post {
final String id;
final String authorId;
final String? categoryId;
final String body;
final PostStatus status;
final ToneLabel detectedTone;
final double contentIntegrityScore;
final DateTime createdAt;
final DateTime? editedAt;
final DateTime? expiresAt;
final bool isEdited;
final bool allowChain;
final String? chainParentId;
final PostPreview? chainParent;
final String visibility;
final DateTime? pinnedAt;
// Relations
final Profile? author;
final int? likeCount;
final int? saveCount;
final int? commentCount;
final int? viewCount;
// User-specific flags
final bool? isLiked;
final bool? isSaved;
final String? imageUrl;
final String? videoUrl;
final String? thumbnailUrl;
final int? durationMs;
final bool? hasVideoContent; // For regular posts that have video thumbnails
final String? bodyFormat; // 'plain' or 'markdown'
final String? backgroundId; // 'white', 'grey', 'blue', 'green', 'yellow', 'orange', 'red', 'purple', 'pink'
final List<String>? tags; // Hashtags extracted from post body
// Beacon-specific fields (null for regular posts)
final bool? isBeacon;
final BeaconType? beaconType;
final double? confidenceScore;
final bool? isActiveBeacon;
final String? beaconStatusColor;
// Location fields (for beacons and any location-tagged posts)
final double? latitude;
final double? longitude;
final double? distanceMeters;
// Sponsored ad fields (First-Party Contextual Ads)
final bool isSponsored;
final String? advertiserName;
final String? ctaLink;
final String? ctaText;
Post({
required this.id,
required this.authorId,
this.categoryId,
required this.body,
required this.status,
required this.detectedTone,
required this.contentIntegrityScore,
required this.createdAt,
this.editedAt,
this.expiresAt,
this.isEdited = false,
this.allowChain = true,
this.chainParentId,
this.chainParent,
this.visibility = 'public',
this.pinnedAt,
this.author,
this.likeCount,
this.saveCount,
this.commentCount,
this.viewCount,
this.isLiked,
this.isSaved,
this.imageUrl,
this.videoUrl,
this.thumbnailUrl,
this.durationMs,
this.hasVideoContent,
this.bodyFormat,
this.backgroundId,
this.tags,
this.isBeacon = false,
this.beaconType,
this.confidenceScore,
this.isActiveBeacon,
this.beaconStatusColor,
this.latitude,
this.longitude,
this.distanceMeters,
this.isSponsored = false,
this.advertiserName,
this.ctaLink,
this.ctaText,
});
static int? _parseInt(dynamic value) {
if (value is int) return value;
if (value is num) return value.toInt();
return null;
}
static double _parseDouble(dynamic value, {double fallback = 0.0}) {
if (value is double) return value;
if (value is int) return value.toDouble();
if (value is num) return value.toDouble();
return fallback;
}
static List<String>? _parseTags(dynamic value) {
if (value == null) return null;
if (value is List<dynamic>) {
return value.map((e) => e.toString()).toList();
}
return null;
}
static double _defaultCis(String tone) {
switch (tone) {
case 'positive':
return 0.9;
case 'neutral':
return 0.8;
case 'mixed':
return 0.7;
case 'negative':
return 0.5;
default:
return 0.8;
}
}
factory Post.fromJson(Map<String, dynamic> json) {
final authorJson = json['author'] as Map<String, dynamic>?;
final categoryJson = json['category'] as Map<String, dynamic>?;
final metricsJson = json['metrics'] as Map<String, dynamic>?;
final chainParentJson = json['chain_parent'] == null || json['chain_parent'] is! Map<String, dynamic>
? null
: json['chain_parent'] as Map<String, dynamic>;
final statusValue = json['status'] as String? ?? 'active';
final toneValue =
json['detected_tone'] as String? ?? json['tone_label'] as String? ?? 'neutral';
final cisValue =
json['content_integrity_score'] ?? json['cis_score'] ?? _defaultCis(toneValue);
final editedAtValue = json['edited_at'] ?? json['updated_at'];
return Post(
id: json['id'] as String,
authorId: json['author_id'] as String? ?? authorJson?['id'] as String? ?? '',
categoryId: json['category_id'] as String? ?? categoryJson?['id'] as String?,
body: json['body'] as String,
status: PostStatus.fromString(statusValue),
detectedTone: ToneLabel.fromString(toneValue),
contentIntegrityScore: _parseDouble(cisValue, fallback: _defaultCis(toneValue)),
createdAt: DateTime.parse(json['created_at'] as String),
editedAt: editedAtValue != null
? DateTime.parse(editedAtValue as String)
: null,
expiresAt: json['expires_at'] != null
? DateTime.parse(json['expires_at'] as String)
: null,
isEdited: json['is_edited'] as bool? ?? false,
allowChain: json['allow_chain'] as bool? ?? true,
chainParentId: json['chain_parent_id'] as String?,
chainParent:
chainParentJson != null ? PostPreview.fromJson(chainParentJson) : null,
visibility: json['visibility'] as String? ?? 'public',
pinnedAt: json['pinned_at'] != null
? DateTime.parse(json['pinned_at'] as String)
: null,
author: authorJson != null ? Profile.fromJson(authorJson) : null,
likeCount: _parseInt(metricsJson?['like_count'] ?? json['like_count']),
saveCount: _parseInt(metricsJson?['save_count'] ?? json['save_count']),
commentCount: _parseInt(json['comment_count']),
viewCount: _parseInt(metricsJson?['view_count'] ?? json['view_count']),
isLiked: json['is_liked'] as bool? ?? json['user_liked'] as bool?,
isSaved: json['is_saved'] as bool? ?? json['user_saved'] as bool?,
imageUrl: json['image_url'] as String?,
videoUrl: json['video_url'] as String?,
thumbnailUrl: json['thumbnail_url'] as String?,
durationMs: _parseInt(json['duration_ms']),
hasVideoContent: json['has_video_content'] as bool?,
bodyFormat: json['body_format'] as String?,
backgroundId: json['background_id'] as String?,
tags: _parseTags(json['tags']),
isBeacon: json['is_beacon'] as bool?,
beaconType: json['beacon_type'] != null ? BeaconType.fromString(json['beacon_type'] as String) : null,
confidenceScore: _parseDouble(json['confidence_score']),
isActiveBeacon: json['is_active_beacon'] as bool?,
beaconStatusColor: json['status_color'] as String?,
latitude: _parseLatitude(json),
longitude: _parseLongitude(json),
distanceMeters: _parseDouble(json['distance_meters']),
// Sponsored ad fields
isSponsored: json['is_sponsored'] as bool? ?? false,
advertiserName: json['advertiser_name'] as String?,
ctaLink: json['advertiser_cta_link'] as String?,
ctaText: json['advertiser_cta_text'] as String?,
);
}
/// Parse latitude from various key formats
static double? _parseLatitude(Map<String, dynamic> json) {
final value = json['latitude'] ?? json['lat'] ?? json['beacon_lat'];
if (value == null) return null;
return _parseDouble(value);
}
/// Parse longitude from various key formats
static double? _parseLongitude(Map<String, dynamic> json) {
final value = json['longitude'] ?? json['long'] ?? json['beacon_long'];
if (value == null) return null;
return _parseDouble(value);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'author_id': authorId,
'category_id': categoryId,
'body': body,
'status': status.value,
'detected_tone': detectedTone.value,
'content_integrity_score': contentIntegrityScore,
'created_at': createdAt.toIso8601String(),
'edited_at': editedAt?.toIso8601String(),
'expires_at': expiresAt?.toIso8601String(),
'allow_chain': allowChain,
'chain_parent_id': chainParentId,
'chain_parent': chainParent?.toJson(),
'visibility': visibility,
'pinned_at': pinnedAt?.toIso8601String(),
'author': author?.toJson(),
'like_count': likeCount,
'save_count': saveCount,
'comment_count': commentCount,
'view_count': viewCount,
'is_liked': isLiked,
'is_saved': isSaved,
'image_url': imageUrl,
'video_url': videoUrl,
'thumbnail_url': thumbnailUrl,
'duration_ms': durationMs,
'has_video_content': hasVideoContent,
'tags': tags,
};
}
}
class PostPreview {
final String id;
final String body;
final DateTime createdAt;
final Profile? author;
const PostPreview({
required this.id,
required this.body,
required this.createdAt,
this.author,
});
factory PostPreview.fromJson(Map<String, dynamic> json) {
final authorJson = json['author'] as Map<String, dynamic>?;
return PostPreview(
id: json['id'] as String,
body: json['body'] as String? ?? '',
createdAt: DateTime.parse(json['created_at'] as String),
author: authorJson != null ? Profile.fromJson(authorJson) : null,
);
}
factory PostPreview.fromPost(Post post) {
return PostPreview(
id: post.id,
body: post.body,
createdAt: post.createdAt,
author: post.author,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'body': body,
'created_at': createdAt.toIso8601String(),
'author': author?.toJson(),
};
}
}
/// Tone analysis response from publish-post
class ToneAnalysis {
final ToneLabel tone;
final double cis;
final bool shouldReject;
final String? rejectReason;
final List<String> flags;
ToneAnalysis({
required this.tone,
required this.cis,
required this.shouldReject,
this.rejectReason,
required this.flags,
});
factory ToneAnalysis.fromJson(Map<String, dynamic> json) {
return ToneAnalysis(
tone: ToneLabel.fromString(json['tone'] as String),
cis: (json['cis'] as num).toDouble(),
shouldReject: json['should_reject'] as bool,
rejectReason: json['reject_reason'] as String?,
flags: (json['flags'] as List<dynamic>).cast<String>(),
);
}
}
/// Extension methods for Post
extension PostBeaconExtension on Post {
/// Check if this post is a beacon
bool get isBeaconPost => isBeacon == true && beaconType != null;
/// Get the beacon color based on type
dynamic get beaconColor => beaconType?.color;
/// Get the beacon icon based on type
dynamic get beaconIcon => beaconType?.icon;
/// Convert a Post to a Beacon object (for backward compatibility)
/// This is a temporary helper during the migration to unified Post model
Beacon toBeacon() {
if (!isBeaconPost) {
throw Exception('Cannot convert non-beacon Post to Beacon');
}
final status = confidenceScore != null
? BeaconStatus.fromConfidence(confidenceScore!)
: BeaconStatus.yellow;
return Beacon(
id: id,
body: body,
authorId: authorId,
beaconType: beaconType!,
confidenceScore: confidenceScore ?? 0.5,
isActiveBeacon: isActiveBeacon ?? true,
status: status,
createdAt: createdAt,
distanceMeters: distanceMeters ?? 0,
imageUrl: imageUrl,
beaconLat: latitude,
beaconLong: longitude,
authorHandle: author?.handle,
authorDisplayName: author?.displayName,
authorAvatarUrl: author?.avatarUrl,
// Note: vote counts would need to be fetched separately
vouchCount: null,
reportCount: null,
userVote: null,
);
}
}