**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.
409 lines
12 KiB
Dart
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,
|
|
);
|
|
}
|
|
}
|