562 lines
21 KiB
Dart
562 lines
21 KiB
Dart
// lib/models/content/content_item.dart
|
||
//
|
||
// مدل سادهی یک content از content-service بکاند جدید.
|
||
// با pagination معمول nestjs-paginate دریافت میشود.
|
||
|
||
// ignore_for_file: deprecated_member_use
|
||
import 'dart:convert' as _jsonc;
|
||
|
||
class ContentCategory {
|
||
final String? id;
|
||
final String? name;
|
||
|
||
const ContentCategory({this.id, this.name});
|
||
|
||
factory ContentCategory.fromJson(Map<String, dynamic> json) =>
|
||
ContentCategory(
|
||
id: json['id'] as String?,
|
||
// بکاند اسم دسته را در فیلد `title` میگذارد (نه `name`).
|
||
name: (json['title'] ?? json['name']) as String?,
|
||
);
|
||
}
|
||
|
||
class ContentItem {
|
||
final String id;
|
||
final String title;
|
||
final String? summary;
|
||
final String? status;
|
||
final String? author;
|
||
final DateTime? publishedAt;
|
||
final DateTime? createdAt;
|
||
/// تگهای محتوا (نام تگها از Tag entity در content-service).
|
||
final List<String> tagNames;
|
||
final List<String> keywords;
|
||
final ContentCategory? category;
|
||
final int likeCount;
|
||
final int readCount;
|
||
final bool isLiked;
|
||
final bool isBookmarked;
|
||
final bool isRead;
|
||
|
||
/// body خام بکاند (آرایهای از sections). فعلا برای main page فقط
|
||
/// از summary استفاده میکنیم اما کل body را نگه میداریم تا صفحهی
|
||
/// جزئیات بعدا بتواند نمایش بدهد.
|
||
final dynamic body;
|
||
|
||
/// آدرس تصویری که در body به عنوان media اول دیده میشود (اگر باشد).
|
||
/// در محتوای جدید معمولا خالی است؛ مقدارِ نهایی توسط ContentService از
|
||
/// `metadata.cover_image_id` به URL signed ـ شده resolve میگردد.
|
||
final String? coverImage;
|
||
|
||
/// شناسهی فایل کاور در s3-service (از `metadata.cover_image_id`).
|
||
/// با این id، URL signed-شدهی تصویر گرفته میشود.
|
||
final String? coverImageId;
|
||
|
||
/// «دستهی» انتخابیِ نویسنده در metadata (مثلا "auto-tech", "fintech").
|
||
/// مستقل از `category` که خود دستهی اصلی content-serviceاست.
|
||
final String? metaCategory;
|
||
|
||
/// محلِ مرکز سازمان از metadata (برای استارتآپها)، مثل "iran" یا "france".
|
||
final String? metaHeadquarter;
|
||
|
||
/// نوعِ آیتم در دستهی فرصتوتهدید: "opportunity" یا "threat".
|
||
final String? metaType;
|
||
|
||
/// نوعِ آیتم در دستهی «داستان» (story): مقدارهایی مثل
|
||
/// `industry-environment`, `macro-environment`, `definition`,
|
||
/// `macro-trends`, `investment`. اگر چند مقدار باشد، اولی برداشته میشود.
|
||
final String? metaStoryType;
|
||
|
||
/// منبعِ خبر در metadata (مثلا "مهر"، "ایسنا"). برای کارتهای اخبار.
|
||
final String? metaSource;
|
||
|
||
/// لیست دستهها در metadata.categories (پنل multi-select).
|
||
final List<String> metaCategories;
|
||
|
||
/// آدرس اولین فایل صوتی (mimeType: audio/*) در body — برای پادکست.
|
||
final String? audioUrl;
|
||
|
||
/// آدرس اولین فایل ویدیویی (mimeType: video/*) در body — برای ویدیوکست.
|
||
final String? videoUrl;
|
||
|
||
/// شناسهی S3 فایل صوتی (اگر در body به شکل UUID ذخیره شده باشد).
|
||
/// با این id باید از s3-service URL signed-شده گرفته شود.
|
||
final String? audioId;
|
||
|
||
/// شناسهی S3 فایل ویدیویی.
|
||
final String? videoId;
|
||
|
||
/// مدتزمان مدیا (ثانیه) اگر در بلوک media موجود باشد.
|
||
final int? mediaDuration;
|
||
|
||
/// لینک خارجی (در حال حاضر برای بنر/اینفوگرافی) که از `metadata.link`
|
||
/// پنل میآید. اگر null یا خالی باشد، اپ دکمهی «مشاهده منبع» را نمایش
|
||
/// نمیدهد.
|
||
final String? metaLink;
|
||
|
||
/// امتیاز نهایی محور X / Y برای فرصت یا تهدید (افق ۱ ساله).
|
||
/// از `metadata.x1` / `metadata.y1` خوانده میشود. در محتوای غیر-SWOT صفر.
|
||
final double metaScoreX1;
|
||
final double metaScoreY1;
|
||
final double metaScoreX3;
|
||
final double metaScoreY3;
|
||
final double metaScoreX7;
|
||
final double metaScoreY7;
|
||
|
||
const ContentItem({
|
||
required this.id,
|
||
required this.title,
|
||
this.summary,
|
||
this.status,
|
||
this.author,
|
||
this.publishedAt,
|
||
this.createdAt,
|
||
this.tagNames = const [],
|
||
this.keywords = const [],
|
||
this.category,
|
||
this.likeCount = 0,
|
||
this.readCount = 0,
|
||
this.isLiked = false,
|
||
this.isBookmarked = false,
|
||
this.isRead = false,
|
||
this.body,
|
||
this.coverImage,
|
||
this.coverImageId,
|
||
this.metaCategory,
|
||
this.metaHeadquarter,
|
||
this.metaType,
|
||
this.metaStoryType,
|
||
this.metaSource,
|
||
this.metaCategories = const [],
|
||
this.audioUrl,
|
||
this.videoUrl,
|
||
this.audioId,
|
||
this.videoId,
|
||
this.mediaDuration,
|
||
this.metaLink,
|
||
this.metaScoreX1 = 0,
|
||
this.metaScoreY1 = 0,
|
||
this.metaScoreX3 = 0,
|
||
this.metaScoreY3 = 0,
|
||
this.metaScoreX7 = 0,
|
||
this.metaScoreY7 = 0,
|
||
});
|
||
|
||
factory ContentItem.fromJson(Map<String, dynamic> json) {
|
||
DateTime? publishedAt;
|
||
final pa = json['publishedAt'];
|
||
if (pa is String && pa.isNotEmpty) {
|
||
publishedAt = DateTime.tryParse(pa);
|
||
}
|
||
DateTime? createdAt;
|
||
final ca = json['createdAt'];
|
||
if (ca is String && ca.isNotEmpty) {
|
||
createdAt = DateTime.tryParse(ca);
|
||
}
|
||
|
||
var body = json['body'];
|
||
// اگر body بهصورت رشتهی JSON آمد (مثلاً در fetchِ لیست از سرور)،
|
||
// آن را parse میکنیم تا parser بتواند blocks را بخواند.
|
||
if (body is String && body.isNotEmpty) {
|
||
try {
|
||
body = _jsonc.json.decode(body);
|
||
} catch (_) {}
|
||
}
|
||
String? cover;
|
||
String? audio;
|
||
String? video;
|
||
String? audioIdLocal;
|
||
String? videoIdLocal;
|
||
int? mediaDuration;
|
||
|
||
// اگر content-service خودش coverImage signed-url را در پاسخ گذاشته
|
||
// (resolveFileUrl برای metadata.cover_image_id)، آن را بهعنوان منبع
|
||
// اصلی بپذیر — این مسیر برای محتواهایی مثل «بنر فولادینفو» که body
|
||
// خالی است و تصویر فقط در metadata.cover_image_id ذخیره میشود لازم
|
||
// است. اسکن body در ادامه فقط در صورت خالیماندنِ cover کار میکند.
|
||
final rootCover = json['coverImage'];
|
||
if (rootCover is String && rootCover.isNotEmpty) {
|
||
cover = rootCover;
|
||
}
|
||
// helper: تشخیصِ شناسهی UUID از یک URL واقعی.
|
||
bool isHttpUrl(String s) => s.startsWith('http://') || s.startsWith('https://');
|
||
bool looksLikeUuid(String s) => RegExp(
|
||
r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')
|
||
.hasMatch(s);
|
||
|
||
// === فرمت جدید Editor.js: body = {time, blocks: [...], version} ===
|
||
// بلاکهای `mediaLibrary` با data.id حاوی UUID فایل S3 هستند. چون
|
||
// نوع mime را در body نداریم، همان id را در هر سه فیلد audio/video/cover
|
||
// ذخیره میکنیم تا هر consumer (پادکست، ویدیوکست، استوری) از خودش
|
||
// مناسب را برگزیند. حلِ نهاییِ URL از طریق s3-service انجام میشود.
|
||
if (body is Map && body['blocks'] is List) {
|
||
String? primaryMediaId;
|
||
for (final block in (body['blocks'] as List)) {
|
||
if (block is! Map) continue;
|
||
final type = block['type']?.toString().toLowerCase();
|
||
final data = block['data'];
|
||
if (data is! Map) continue;
|
||
if ((type == 'medialibrary' || type == 'media' || type == 'image') &&
|
||
data['id'] is String) {
|
||
final id = data['id'] as String;
|
||
if (id.isNotEmpty) {
|
||
primaryMediaId ??= id;
|
||
}
|
||
}
|
||
}
|
||
if (primaryMediaId != null) {
|
||
audioIdLocal = primaryMediaId;
|
||
videoIdLocal = primaryMediaId;
|
||
}
|
||
}
|
||
|
||
// اسکن body (آرایهای از sections) برای اولین تصویر/صوت/ویدیو.
|
||
if (body is List) {
|
||
for (final section in body) {
|
||
if (section is Map && section['blocks'] is List) {
|
||
for (final block in (section['blocks'] as List)) {
|
||
if (block is! Map) continue;
|
||
final value = block['value'];
|
||
// value میتواند Map با {url, mimeType} باشد یا فقط String.
|
||
String? rawUrl;
|
||
String? rawId;
|
||
String? mime;
|
||
int? duration;
|
||
if (value is Map) {
|
||
if (value['url'] is String) rawUrl = value['url'] as String;
|
||
if (value['id'] is String) rawId = value['id'] as String;
|
||
if (value['key'] is String) rawId ??= value['key'] as String;
|
||
if (value['mimeType'] is String) mime = value['mimeType'] as String;
|
||
if (value['mime'] is String) mime ??= value['mime'] as String;
|
||
final d = value['duration'];
|
||
if (d is num) duration = d.toInt();
|
||
} else if (value is String) {
|
||
rawUrl = value;
|
||
}
|
||
if (rawUrl == null && rawId == null) continue;
|
||
|
||
// اگر url خودش UUID بود، آن را به جای id بگیر.
|
||
String? directUrl;
|
||
String? mediaId;
|
||
if (rawUrl != null && isHttpUrl(rawUrl)) {
|
||
directUrl = rawUrl;
|
||
} else if (rawUrl != null && looksLikeUuid(rawUrl)) {
|
||
mediaId = rawUrl;
|
||
} else if (rawId != null) {
|
||
mediaId = rawId;
|
||
} else if (rawUrl != null) {
|
||
// مسیر نسبی یا چیز دیگر؛ همان را بهعنوان URL در نظر بگیر.
|
||
directUrl = rawUrl;
|
||
}
|
||
|
||
// تشخیصِ نوع مدیا از طریق mime یا blockId.
|
||
final blockId = block['blockId']?.toString().toLowerCase() ?? '';
|
||
final isAudio = (mime != null && mime.startsWith('audio/')) ||
|
||
blockId.contains('podcast') ||
|
||
blockId.contains('audio');
|
||
final isVideo = (mime != null && mime.startsWith('video/')) ||
|
||
blockId.contains('video');
|
||
final isImage = (mime != null && mime.startsWith('image/')) ||
|
||
blockId.contains('image') ||
|
||
blockId.contains('cover') ||
|
||
blockId.contains('story_media');
|
||
|
||
if (isAudio && audio == null && audioIdLocal == null) {
|
||
if (directUrl != null) audio = directUrl;
|
||
if (mediaId != null) audioIdLocal = mediaId;
|
||
if (duration != null) mediaDuration = duration;
|
||
} else if (isVideo && video == null && videoIdLocal == null) {
|
||
if (directUrl != null) video = directUrl;
|
||
if (mediaId != null) videoIdLocal = mediaId;
|
||
if (duration != null) mediaDuration = duration;
|
||
} else if (isImage && cover == null) {
|
||
if (directUrl != null) cover = directUrl;
|
||
// برای تصویر، id داخل metadata.cover_image_id خواندنی است،
|
||
// اینجا فقط URL مستقیم را نگه میداریم.
|
||
}
|
||
}
|
||
}
|
||
if (cover != null &&
|
||
(audio != null || audioIdLocal != null) &&
|
||
(video != null || videoIdLocal != null)) {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
final keywordsRaw = json['keywords'];
|
||
final keywords = keywordsRaw is List
|
||
? keywordsRaw.map((e) => e.toString()).toList()
|
||
: <String>[];
|
||
|
||
// tags از relation Tag در content-service: [{id, name, color, ...}, ...]
|
||
final tagsRaw = json['tags'];
|
||
final tagNames = <String>[];
|
||
if (tagsRaw is List) {
|
||
for (final t in tagsRaw) {
|
||
if (t is Map) {
|
||
final n = t['name'] ?? t['label'] ?? t['title'];
|
||
if (n is String && n.isNotEmpty) tagNames.add(n);
|
||
} else if (t is String && t.isNotEmpty) {
|
||
tagNames.add(t);
|
||
}
|
||
}
|
||
}
|
||
|
||
// متادیتای محتوا — تصویرِ کاور با شناسه (`cover_image_id`) میآید.
|
||
final metadataRaw = json['metadata'];
|
||
String? coverImageId;
|
||
String? metaCategory;
|
||
String? metaHeadquarter;
|
||
String? metaType;
|
||
String? metaStoryType;
|
||
String? metaSource;
|
||
String? metaLinkLocal;
|
||
final List<String> metaCategoriesList = [];
|
||
double metaScoreX1Local = 0;
|
||
double metaScoreY1Local = 0;
|
||
double metaScoreX3Local = 0;
|
||
double metaScoreY3Local = 0;
|
||
double metaScoreX7Local = 0;
|
||
double metaScoreY7Local = 0;
|
||
if (metadataRaw is Map) {
|
||
final cid = metadataRaw['cover_image_id'];
|
||
if (cid is String && cid.isNotEmpty) coverImageId = cid;
|
||
// `category` میتواند string (legacy) یا List<String> (multi-select از پنل)
|
||
// باشد. علاوه بر کلید `category`، با پیشوند schema هم چک میشود.
|
||
dynamic cat = metadataRaw['category'];
|
||
if (cat == null) {
|
||
for (final k in metadataRaw.keys) {
|
||
if (k is String && k.contains(':category:')) {
|
||
cat = metadataRaw[k];
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (cat is String && cat.isNotEmpty) {
|
||
metaCategory = cat;
|
||
} else if (cat is List) {
|
||
for (final v in cat) {
|
||
if (v is String && v.isNotEmpty) metaCategoriesList.add(v);
|
||
}
|
||
}
|
||
// headquarter میتواند کلید `headquarter` یا با پیشوند schema
|
||
// (`$single:headquarter:...`) ذخیره شده باشد.
|
||
dynamic hq = metadataRaw['headquarter'];
|
||
if (hq == null) {
|
||
for (final k in metadataRaw.keys) {
|
||
if (k is String && k.contains(':headquarter:')) {
|
||
hq = metadataRaw[k];
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (hq is String && hq.isNotEmpty) metaHeadquarter = hq;
|
||
final t = metadataRaw['type'];
|
||
if (t is String && t.isNotEmpty) metaType = t;
|
||
final src = metadataRaw['source'] ??
|
||
metadataRaw['reference'] ??
|
||
metadataRaw['publisher'];
|
||
if (src is String && src.isNotEmpty) metaSource = src;
|
||
// لینک خارجی (برای بنر/اینفوگرافی). از کلید ساده 'link' یا کلید
|
||
// schema-prefixed مثل '$input:link:' خوانده میشود.
|
||
dynamic lnk = metadataRaw['link'];
|
||
if (lnk == null) {
|
||
for (final k in metadataRaw.keys) {
|
||
if (k is String && k.contains(':link:')) {
|
||
lnk = metadataRaw[k];
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (lnk is String && lnk.trim().isNotEmpty) metaLinkLocal = lnk.trim();
|
||
// multi-select categories از پنل (آرایه)
|
||
final catsRaw = metadataRaw['categories'];
|
||
if (catsRaw is List) {
|
||
for (final v in catsRaw) {
|
||
if (v is String && v.isNotEmpty) metaCategoriesList.add(v);
|
||
}
|
||
}
|
||
// اگر duration در metadata باشد (بر حسب ثانیه).
|
||
final dur = metadataRaw['duration'] ??
|
||
metadataRaw['mediaDuration'] ??
|
||
metadataRaw['audio_duration'];
|
||
if (dur is num) {
|
||
mediaDuration ??= dur.toInt();
|
||
} else if (dur is String) {
|
||
final parsed = int.tryParse(dur);
|
||
if (parsed != null) mediaDuration ??= parsed;
|
||
}
|
||
// story-type ممکن است string یا array باشد ($multi).
|
||
final st = metadataRaw['story-type'] ?? metadataRaw['story_type'];
|
||
if (st is String && st.isNotEmpty) {
|
||
metaStoryType = st;
|
||
} else if (st is List && st.isNotEmpty) {
|
||
final first = st.first;
|
||
if (first is String && first.isNotEmpty) metaStoryType = first;
|
||
}
|
||
|
||
// امتیازهای SWOT (محاسبهشدهی وزندار). در دستهی غیر-SWOT وجود ندارند.
|
||
double parseNum(dynamic v) {
|
||
if (v is num) return v.toDouble();
|
||
if (v is String) return double.tryParse(v) ?? 0;
|
||
return 0;
|
||
}
|
||
metaScoreX1Local = parseNum(metadataRaw['x1']);
|
||
metaScoreY1Local = parseNum(metadataRaw['y1']);
|
||
metaScoreX3Local = parseNum(metadataRaw['x3']);
|
||
metaScoreY3Local = parseNum(metadataRaw['y3']);
|
||
metaScoreX7Local = parseNum(metadataRaw['x7']);
|
||
metaScoreY7Local = parseNum(metadataRaw['y7']);
|
||
// fallback به ساختار تو در تو `axisScores.oneYear.x` و غیره
|
||
final axisScoresRaw = metadataRaw['axisScores'];
|
||
if (axisScoresRaw is Map) {
|
||
final oneYear = axisScoresRaw['oneYear'];
|
||
if (oneYear is Map) {
|
||
if (metaScoreX1Local == 0) metaScoreX1Local = parseNum(oneYear['x']);
|
||
if (metaScoreY1Local == 0) metaScoreY1Local = parseNum(oneYear['y']);
|
||
}
|
||
final threeYear = axisScoresRaw['threeYear'];
|
||
if (threeYear is Map) {
|
||
if (metaScoreX3Local == 0) metaScoreX3Local = parseNum(threeYear['x']);
|
||
if (metaScoreY3Local == 0) metaScoreY3Local = parseNum(threeYear['y']);
|
||
}
|
||
final sevenYear = axisScoresRaw['sevenYear'];
|
||
if (sevenYear is Map) {
|
||
if (metaScoreX7Local == 0) metaScoreX7Local = parseNum(sevenYear['x']);
|
||
if (metaScoreY7Local == 0) metaScoreY7Local = parseNum(sevenYear['y']);
|
||
}
|
||
}
|
||
}
|
||
|
||
return ContentItem(
|
||
id: json['id'] as String,
|
||
title: (json['title'] ?? '') as String,
|
||
summary: json['summary'] as String?,
|
||
status: json['status'] as String?,
|
||
author: json['author'] as String?,
|
||
publishedAt: publishedAt,
|
||
createdAt: createdAt,
|
||
keywords: keywords,
|
||
tagNames: tagNames,
|
||
category: json['category'] is Map
|
||
? ContentCategory.fromJson(
|
||
Map<String, dynamic>.from(json['category'] as Map))
|
||
: null,
|
||
likeCount: (json['likeCount'] as num?)?.toInt() ?? 0,
|
||
readCount: (json['readCount'] as num?)?.toInt() ?? 0,
|
||
isLiked: json['isLiked'] == true,
|
||
isBookmarked: json['isBookmarked'] == true,
|
||
isRead: json['isRead'] == true,
|
||
body: body,
|
||
coverImage: cover,
|
||
coverImageId: coverImageId,
|
||
metaCategory: metaCategory,
|
||
metaHeadquarter: metaHeadquarter,
|
||
metaType: metaType,
|
||
metaStoryType: metaStoryType,
|
||
metaSource: metaSource,
|
||
metaCategories: metaCategoriesList,
|
||
audioUrl: audio,
|
||
videoUrl: video,
|
||
audioId: audioIdLocal,
|
||
videoId: videoIdLocal,
|
||
mediaDuration: mediaDuration,
|
||
metaLink: metaLinkLocal,
|
||
metaScoreX1: metaScoreX1Local,
|
||
metaScoreY1: metaScoreY1Local,
|
||
metaScoreX3: metaScoreX3Local,
|
||
metaScoreY3: metaScoreY3Local,
|
||
metaScoreX7: metaScoreX7Local,
|
||
metaScoreY7: metaScoreY7Local,
|
||
);
|
||
}
|
||
|
||
ContentItem copyWith({
|
||
bool? isLiked,
|
||
bool? isBookmarked,
|
||
bool? isRead,
|
||
int? likeCount,
|
||
int? readCount,
|
||
String? coverImage,
|
||
String? audioUrl,
|
||
String? videoUrl,
|
||
int? mediaDuration,
|
||
}) {
|
||
return ContentItem(
|
||
id: id,
|
||
title: title,
|
||
summary: summary,
|
||
status: status,
|
||
author: author,
|
||
publishedAt: publishedAt,
|
||
createdAt: createdAt,
|
||
keywords: keywords,
|
||
tagNames: tagNames,
|
||
category: category,
|
||
likeCount: likeCount ?? this.likeCount,
|
||
readCount: readCount ?? this.readCount,
|
||
isLiked: isLiked ?? this.isLiked,
|
||
isBookmarked: isBookmarked ?? this.isBookmarked,
|
||
isRead: isRead ?? this.isRead,
|
||
body: body,
|
||
coverImage: coverImage ?? this.coverImage,
|
||
coverImageId: coverImageId,
|
||
metaCategory: metaCategory,
|
||
metaHeadquarter: metaHeadquarter,
|
||
metaType: metaType,
|
||
metaStoryType: metaStoryType,
|
||
metaSource: metaSource,
|
||
metaCategories: metaCategories,
|
||
audioUrl: audioUrl ?? this.audioUrl,
|
||
videoUrl: videoUrl ?? this.videoUrl,
|
||
audioId: audioId,
|
||
videoId: videoId,
|
||
mediaDuration: mediaDuration ?? this.mediaDuration,
|
||
metaLink: metaLink,
|
||
metaScoreX1: metaScoreX1,
|
||
metaScoreY1: metaScoreY1,
|
||
metaScoreX3: metaScoreX3,
|
||
metaScoreY3: metaScoreY3,
|
||
metaScoreX7: metaScoreX7,
|
||
metaScoreY7: metaScoreY7,
|
||
);
|
||
}
|
||
}
|
||
|
||
/// نتیجهی paginated از nestjs-paginate.
|
||
class ContentPage {
|
||
final List<ContentItem> data;
|
||
final int currentPage;
|
||
final int totalPages;
|
||
final int totalItems;
|
||
final int itemsPerPage;
|
||
|
||
const ContentPage({
|
||
required this.data,
|
||
required this.currentPage,
|
||
required this.totalPages,
|
||
required this.totalItems,
|
||
required this.itemsPerPage,
|
||
});
|
||
|
||
factory ContentPage.fromJson(Map<String, dynamic> json) {
|
||
final dataRaw = json['data'];
|
||
final items = dataRaw is List
|
||
? dataRaw
|
||
.map((e) => ContentItem.fromJson(Map<String, dynamic>.from(e)))
|
||
.toList()
|
||
: <ContentItem>[];
|
||
final meta = (json['meta'] as Map?) ?? const {};
|
||
return ContentPage(
|
||
data: items,
|
||
currentPage: (meta['currentPage'] as num?)?.toInt() ?? 1,
|
||
totalPages: (meta['totalPages'] as num?)?.toInt() ?? 1,
|
||
totalItems: (meta['totalItems'] as num?)?.toInt() ?? items.length,
|
||
itemsPerPage: (meta['itemsPerPage'] as num?)?.toInt() ?? items.length,
|
||
);
|
||
}
|
||
|
||
bool get hasMore => currentPage < totalPages;
|
||
}
|