sojorn/sojorn_app/lib/models/post.dart
2026-02-15 00:33:24 -06:00

548 lines
17 KiB
Dart

import 'profile.dart';
import 'beacon.dart';
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,
);
}
}
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,
);
}
}
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;
final Profile? author;
final int? likeCount;
final int? saveCount;
final int? commentCount;
final int? viewCount;
final bool? isLiked;
final bool? isSaved;
final String? imageUrl;
final String? videoUrl;
final String? thumbnailUrl;
final int? durationMs;
final bool? hasVideoContent;
final String? bodyFormat;
final String? backgroundId;
final List<String>? tags;
final Map<String, int>? reactions;
final List<String>? myReactions;
final Map<String, List<String>>? reactionUsers;
final bool? isBeacon;
final BeaconType? beaconType;
final double? confidenceScore;
final bool? isActiveBeacon;
final String? beaconStatusColor;
final String? severity;
final String? incidentStatus;
final int? radius;
final int? verificationCount;
final int? vouchCount;
final int? reportCount;
final double? latitude;
final double? longitude;
final double? distanceMeters;
// Group / Neighborhood context
final String? groupId;
final String? groupName;
final bool isSponsored;
final String? advertiserName;
final String? ctaLink;
final String? ctaText;
final bool isNsfw;
final String? nsfwReason;
// Link preview (OG metadata)
final String? linkPreviewUrl;
final String? linkPreviewTitle;
final String? linkPreviewDescription;
final String? linkPreviewImageUrl;
final String? linkPreviewSiteName;
bool get hasLinkPreview => linkPreviewUrl != null && linkPreviewUrl!.isNotEmpty;
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.reactions,
this.myReactions,
this.reactionUsers,
this.isBeacon = false,
this.beaconType,
this.confidenceScore,
this.isActiveBeacon,
this.beaconStatusColor,
this.severity,
this.incidentStatus,
this.radius,
this.verificationCount,
this.vouchCount,
this.reportCount,
this.latitude,
this.longitude,
this.distanceMeters,
this.groupId,
this.groupName,
this.isSponsored = false,
this.advertiserName,
this.ctaLink,
this.ctaText,
this.isNsfw = false,
this.nsfwReason,
this.linkPreviewUrl,
this.linkPreviewTitle,
this.linkPreviewDescription,
this.linkPreviewImageUrl,
this.linkPreviewSiteName,
});
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 Map<String, int>? _parseReactions(dynamic value) {
if (value == null) return null;
if (value is Map<String, dynamic>) {
return value.map((key, val) => MapEntry(key, _parseInt(val) ?? 0));
}
return null;
}
static List<String>? _parseReactionsList(dynamic value) {
if (value == null) return null;
if (value is List) {
return value.map((item) => item.toString()).toList();
}
return null;
}
static Map<String, List<String>>? _parseReactionUsers(dynamic value) {
if (value == null) return null;
if (value is Map<String, dynamic>) {
return value.map((key, val) {
if (val is List) {
return MapEntry(key, val.map((item) => item.toString()).toList());
}
return MapEntry(key, <String>[]);
});
}
return null;
}
static double _defaultCis(String tone) {
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? ??
((json['video_url'] as String?)?.isNotEmpty == true ||
(json['image_url'] as String?)?.toLowerCase().endsWith('.mp4') == true),
bodyFormat: json['body_format'] as String?,
backgroundId: json['background_id'] as String?,
tags: _parseTags(json['tags']),
reactions: _parseReactions(
json['reactions'] ?? json['reaction_counts'] ?? json['reaction_map']),
myReactions: _parseReactionsList(
json['my_reactions'] ?? json['myReactions']),
reactionUsers: _parseReactionUsers(
json['reaction_users'] ?? json['reaction_users_preview']),
isBeacon: json['is_beacon'] as bool?,
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?,
severity: json['severity'] as String?,
incidentStatus: json['incident_status'] as String?,
radius: _parseInt(json['radius']),
verificationCount: _parseInt(json['verification_count']),
vouchCount: _parseInt(json['vouch_count']),
reportCount: _parseInt(json['report_count']),
latitude: _parseLatitude(json),
longitude: _parseLongitude(json),
distanceMeters: _parseDouble(json['distance_meters']),
groupId: json['group_id'] as String?,
groupName: json['group_name'] as String?,
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?,
isNsfw: json['is_nsfw'] as bool? ?? false,
nsfwReason: json['nsfw_reason'] as String?,
linkPreviewUrl: json['link_preview_url'] as String?,
linkPreviewTitle: json['link_preview_title'] as String?,
linkPreviewDescription: json['link_preview_description'] as String?,
linkPreviewImageUrl: json['link_preview_image_url'] as String?,
linkPreviewSiteName: json['link_preview_site_name'] as String?,
);
}
static double? _parseLatitude(Map<String, dynamic> json) {
final value = json['latitude'] ?? json['lat'] ?? json['beacon_lat'];
if (value == null) return null;
return _parseDouble(value);
}
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,
'reactions': reactions,
'my_reactions': myReactions,
'reaction_users': reactionUsers,
'is_nsfw': isNsfw,
'nsfw_reason': nsfwReason,
'link_preview_url': linkPreviewUrl,
'link_preview_title': linkPreviewTitle,
'link_preview_description': linkPreviewDescription,
'link_preview_image_url': linkPreviewImageUrl,
'link_preview_site_name': linkPreviewSiteName,
};
}
}
class PostPreview {
final String id;
final String body;
final DateTime createdAt;
final Profile? author;
final Map<String, int>? reactions;
final List<String>? myReactions;
const PostPreview({
required this.id,
required this.body,
required this.createdAt,
this.author,
this.reactions,
this.myReactions,
});
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,
reactions: Post._parseReactions(json['reactions'] ?? json['reaction_counts']),
myReactions: Post._parseReactionsList(json['my_reactions'] ?? json['myReactions']),
);
}
factory PostPreview.fromPost(Post post) {
return PostPreview(
id: post.id,
body: post.body,
createdAt: post.createdAt,
author: post.author,
reactions: post.reactions,
myReactions: post.myReactions,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'body': body,
'created_at': createdAt.toIso8601String(),
'author': author?.toJson(),
'reactions': reactions,
'my_reactions': myReactions,
};
}
}
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 PostBeaconExtension on Post {
bool get isBeaconPost => isBeacon == true && beaconType != null;
dynamic get beaconColor => beaconType?.color;
dynamic get beaconIcon => beaconType?.icon;
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,
vouchCount: vouchCount,
reportCount: reportCount,
userVote: null,
groupId: groupId,
severity: BeaconSeverity.fromString(severity ?? 'medium'),
incidentStatus: BeaconIncidentStatus.fromString(incidentStatus ?? 'active'),
radius: radius ?? 500,
verificationCount: verificationCount ?? 0,
);
}
}
/// FocusContext represents the minimal data needed for the Focus-Context view
class FocusContext {
final Post targetPost;
final Post? parentPost;
final List<Post> children;
final List<Post> parentChildren;
const FocusContext({
required this.targetPost,
this.parentPost,
required this.children,
this.parentChildren = const [],
});
factory FocusContext.fromJson(Map<String, dynamic> json) {
return FocusContext(
targetPost: Post.fromJson(json['target_post']),
parentPost: json['parent_post'] != null ? Post.fromJson(json['parent_post']) : null,
children: (json['children'] as List?)
?.map((child) => Post.fromJson(child))
.toList() ?? [],
parentChildren: (json['parent_children'] as List?)
?.map((child) => Post.fromJson(child))
.toList() ?? [],
);
}
Map<String, dynamic> toJson() {
return {
'target_post': targetPost.toJson(),
'parent_post': parentPost?.toJson(),
'children': children.map((child) => child.toJson()).toList(),
'parent_children': parentChildren.map((child) => child.toJson()).toList(),
};
}
}