479 lines
16 KiB
Dart
479 lines
16 KiB
Dart
import 'dart:async';
|
|
import 'dart:math';
|
|
|
|
import 'package:didvan/config/auth_config.dart';
|
|
import 'package:didvan/models/content.dart';
|
|
import 'package:didvan/models/enums.dart';
|
|
import 'package:didvan/models/news_details_data.dart';
|
|
import 'package:didvan/models/overview_data.dart';
|
|
import 'package:didvan/models/tag.dart';
|
|
import 'package:didvan/models/requests/news.dart';
|
|
import 'package:didvan/providers/core.dart';
|
|
import 'package:didvan/services/content/content_service.dart';
|
|
import 'package:didvan/services/network/request.dart';
|
|
import 'package:didvan/services/network/request_helper.dart';
|
|
import 'package:didvan/services/s3/s3_media_service.dart';
|
|
|
|
class NewsDetailsState extends CoreProvier {
|
|
final List<NewsDetailsData?> news = [];
|
|
late final int initialIndex;
|
|
late final NewsRequestArgs args;
|
|
String? initialDescription;
|
|
late Timer _trackingTimer;
|
|
int _trackingTimerCounter = 0;
|
|
bool isFetchingNewItem = false;
|
|
final List<int> relatedQueue = [];
|
|
bool _isRequestInProgress = false;
|
|
|
|
int _currentIndex = 0;
|
|
int get currentIndex => _currentIndex;
|
|
bool openComments = false;
|
|
|
|
NewsDetailsData get currentNews => news[_currentIndex]!;
|
|
|
|
Future<void> getNewsDetails(int id, {bool? isForward}) async {
|
|
if (_isRequestInProgress) {
|
|
return;
|
|
}
|
|
_isRequestInProgress = true;
|
|
|
|
if (isForward == null) {
|
|
appState = AppState.busy;
|
|
} else {
|
|
isFetchingNewItem = true;
|
|
notifyListeners();
|
|
}
|
|
|
|
try {
|
|
if (RequestService.token == null || RequestService.token!.isEmpty) {
|
|
await Future.delayed(const Duration(milliseconds: 500));
|
|
if (RequestService.token == null || RequestService.token!.isEmpty) {
|
|
throw Exception('Token not available');
|
|
}
|
|
}
|
|
|
|
final service = RequestService(RequestHelper.newsDetails(id, args));
|
|
await service.httpGet();
|
|
_handleTracking(sendRequest: isForward != null);
|
|
|
|
if (service.isSuccess) {
|
|
final result = service.result;
|
|
final newsItem = NewsDetailsData.fromJson(result['news']);
|
|
|
|
if (isForward == null && initialDescription != null) {
|
|
newsItem.description = initialDescription;
|
|
}
|
|
|
|
if (isForward == null && newsItem.description == null) {
|
|
await _fetchDescriptionFromList(newsItem.id, newsItem);
|
|
}
|
|
|
|
if (args.page == 0) {
|
|
news.add(newsItem);
|
|
initialIndex = 0;
|
|
appState = AppState.idle;
|
|
isFetchingNewItem = false;
|
|
_isRequestInProgress = false;
|
|
return;
|
|
}
|
|
NewsDetailsData? prevNews;
|
|
if (result['prevNews'].isNotEmpty) {
|
|
prevNews = NewsDetailsData.fromJson(result['prevNews']);
|
|
}
|
|
NewsDetailsData? nextNews;
|
|
if (result['nextNews'].isNotEmpty) {
|
|
nextNews = NewsDetailsData.fromJson(result['nextNews']);
|
|
}
|
|
if (isForward == null) {
|
|
news.addAll(
|
|
List.generate(max(newsItem.order - 2, 0), (index) => null));
|
|
if (prevNews != null) {
|
|
news.add(prevNews);
|
|
}
|
|
news.add(newsItem);
|
|
if (nextNews != null) {
|
|
news.add(nextNews);
|
|
}
|
|
_currentIndex = initialIndex = newsItem.order - 1;
|
|
} else if (isForward) {
|
|
if (!exists(nextNews) && nextNews != null) {
|
|
news.add(nextNews);
|
|
}
|
|
_currentIndex++;
|
|
} else if (!isForward) {
|
|
if (!exists(prevNews) && prevNews != null) {
|
|
news[_currentIndex - 2] = prevNews;
|
|
}
|
|
_currentIndex--;
|
|
}
|
|
isFetchingNewItem = false;
|
|
// Always fetch related content — the previous
|
|
// `contents.length == 1` gate was a legacy quirk that hid the
|
|
// "مطالب مرتبط" section for almost every news item.
|
|
getRelatedContents();
|
|
appState = AppState.idle;
|
|
} else {
|
|
isFetchingNewItem = false;
|
|
if (isForward == null) {
|
|
appState = AppState.failed;
|
|
} else {
|
|
notifyListeners();
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// ignore: avoid_print
|
|
print('Error fetching news details: $e');
|
|
isFetchingNewItem = false;
|
|
if (isForward == null) {
|
|
appState = AppState.failed;
|
|
}
|
|
notifyListeners();
|
|
} finally {
|
|
_isRequestInProgress = false;
|
|
}
|
|
}
|
|
|
|
/// واکشیِ جزئیات از content-service جدید (با uuid) و نمایش در همان
|
|
/// UI خبر. body فرمتِ Editor.js را به بلوکهای `Content` ترجمه میکند تا
|
|
/// `DidvanPageView` بدون تغییر بتواند رندرش کند.
|
|
Future<void> getContentDetail(String uuid) async {
|
|
appState = AppState.busy;
|
|
notifyListeners();
|
|
try {
|
|
// تایمر tracking را dummy تنظیم میکنیم تا در dispose خطای
|
|
// LateInitializationError ندهد (timer قدیمی برای API legacy بود).
|
|
_trackingTimer = Timer(const Duration(seconds: 0), () {});
|
|
|
|
final res = await ContentService.instance.getContent(uuid);
|
|
if (!res.isSuccess || res.data == null) {
|
|
appState = AppState.failed;
|
|
notifyListeners();
|
|
return;
|
|
}
|
|
final item = res.data!;
|
|
final contents = await _bodyToContents(item.body);
|
|
|
|
// اولین audio Content را بهعنوان podcast صفحه میگیریم تا
|
|
// پلیر در بالای صفحه نمایش داده شود.
|
|
String? podcastUrl;
|
|
for (final ct in contents) {
|
|
if (ct.audio != null && ct.audio!.isNotEmpty) {
|
|
podcastUrl = ct.audio;
|
|
break;
|
|
}
|
|
}
|
|
// duration از S3MediaService با audioId/videoId.
|
|
int? podcastDuration;
|
|
if (item.audioId != null) {
|
|
podcastDuration = S3MediaService.instance.durationFor(item.audioId);
|
|
}
|
|
podcastDuration ??= item.mediaDuration;
|
|
|
|
news.clear();
|
|
news.add(NewsDetailsData(
|
|
id: item.id.hashCode,
|
|
title: item.title,
|
|
reference: item.author ?? '',
|
|
image: item.coverImage ?? '',
|
|
createdAt: (item.publishedAt ?? item.createdAt)?.toIso8601String() ??
|
|
DateTime.now().toIso8601String(),
|
|
marked: item.isBookmarked,
|
|
liked: item.isLiked,
|
|
likes: item.likeCount,
|
|
comments: 0,
|
|
order: 1,
|
|
tags: item.tagNames
|
|
.where((n) => n.isNotEmpty)
|
|
.map((n) => Tag(id: n.hashCode, label: n))
|
|
.toList(),
|
|
contents: contents,
|
|
description: item.summary,
|
|
// false تا UI placeholder loading نشان دهد. پس از fetch، اگر
|
|
// واقعا چیزی نیامد به true تغییر میکند.
|
|
relatedContentsIsEmpty: false,
|
|
podcast: podcastUrl,
|
|
duration: podcastDuration,
|
|
keycloakId: item.id,
|
|
));
|
|
initialIndex = 0;
|
|
_currentIndex = 0;
|
|
// args قبلاً در news_details.dart initState ست شده است؛ نباید
|
|
// دوباره assign شود (late final).
|
|
appState = AppState.idle;
|
|
notifyListeners();
|
|
// مطالب مرتبط بر اساس تگها (در پسزمینه).
|
|
getRelatedContents();
|
|
} catch (e) {
|
|
// ignore: avoid_print
|
|
print('getContentDetail error: $e');
|
|
appState = AppState.failed;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
/// ترجمهی body فرمتِ Editor.js به `List<Content>`:
|
|
/// - header / paragraph → text (HTML)
|
|
/// - image → image با URL signed-شدهی S3
|
|
/// - بقیهی بلوکها (table, chart, ...) بهشکل ساده به متن یا حذف
|
|
Future<List<Content>> _bodyToContents(dynamic body) async {
|
|
final result = <Content>[];
|
|
if (body is! Map) return result;
|
|
final blocks = body['blocks'];
|
|
if (blocks is! List) return result;
|
|
var order = 0;
|
|
for (final block in blocks) {
|
|
if (block is! Map) continue;
|
|
final type = block['type'];
|
|
final data = block['data'];
|
|
if (data is! Map) continue;
|
|
order++;
|
|
switch (type) {
|
|
case 'header':
|
|
final lvl = (data['level'] as num?)?.toInt() ?? 2;
|
|
final t = (data['text'] ?? '').toString();
|
|
result.add(Content(
|
|
order: order,
|
|
text: '<h$lvl>$t</h$lvl>',
|
|
audio: null,
|
|
video: null,
|
|
image: null,
|
|
largeImage: null,
|
|
caption: null));
|
|
break;
|
|
case 'paragraph':
|
|
final t = (data['text'] ?? '').toString();
|
|
if (t.trim().isEmpty) break;
|
|
result.add(Content(
|
|
order: order,
|
|
text: t.contains('<') ? t : '<p>$t</p>',
|
|
audio: null,
|
|
video: null,
|
|
image: null,
|
|
largeImage: null,
|
|
caption: null));
|
|
break;
|
|
case 'image':
|
|
final id = data['id'];
|
|
if (id is String && id.isNotEmpty) {
|
|
final url = await S3MediaService.instance.resolveUrl(id);
|
|
if (url != null) {
|
|
result.add(Content(
|
|
order: order,
|
|
text: null,
|
|
audio: null,
|
|
video: null,
|
|
image: url,
|
|
largeImage: url,
|
|
caption: data['caption']?.toString()));
|
|
}
|
|
}
|
|
break;
|
|
case 'mediaLibrary':
|
|
case 'medialibrary':
|
|
case 'media':
|
|
final id = data['id'];
|
|
if (id is String && id.isNotEmpty) {
|
|
final url = await S3MediaService.instance.resolveUrl(id);
|
|
if (url != null) {
|
|
final mime = S3MediaService.instance.mimeFor(id) ?? '';
|
|
final isAudio = mime.startsWith('audio/');
|
|
final isVideo = mime.startsWith('video/');
|
|
final isImage = mime.startsWith('image/');
|
|
result.add(Content(
|
|
order: order,
|
|
text: null,
|
|
audio: isAudio ? url : null,
|
|
video: isVideo ? url : null,
|
|
image: isImage ? url : null,
|
|
largeImage: isImage ? url : null,
|
|
caption: data['caption']?.toString()));
|
|
}
|
|
}
|
|
break;
|
|
case 'table':
|
|
final rows = data['content'];
|
|
if (rows is List) {
|
|
final sb = StringBuffer('<table border="1" cellpadding="6">');
|
|
for (var i = 0; i < rows.length; i++) {
|
|
final row = rows[i];
|
|
if (row is! List) continue;
|
|
sb.write('<tr>');
|
|
for (final cell in row) {
|
|
final tag = i == 0 ? 'th' : 'td';
|
|
sb.write('<$tag>${cell ?? ''}</$tag>');
|
|
}
|
|
sb.write('</tr>');
|
|
}
|
|
sb.write('</table>');
|
|
result.add(Content(
|
|
order: order,
|
|
text: sb.toString(),
|
|
audio: null,
|
|
video: null,
|
|
image: null,
|
|
largeImage: null,
|
|
caption: null));
|
|
}
|
|
break;
|
|
default:
|
|
// chart / towColumnTool / carouselTool و ... فعلا رد میشوند.
|
|
break;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
bool exists(NewsDetailsData? newsItem) =>
|
|
news.any((n) => newsItem != null && n != null && n.id == newsItem.id);
|
|
|
|
void onCommentsChanged(int count) {
|
|
news.firstWhere((item) => item?.id == currentNews.id)!.comments = count;
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> _handleTracking({bool sendRequest = true}) async {
|
|
if (!sendRequest) {
|
|
_trackingTimerCounter = 0;
|
|
_trackingTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
|
_trackingTimerCounter++;
|
|
});
|
|
return;
|
|
}
|
|
final service = RequestService(
|
|
RequestHelper.tracking(currentNews.id, 'news'),
|
|
body: {
|
|
'sec': _trackingTimerCounter,
|
|
},
|
|
);
|
|
service.put();
|
|
_trackingTimerCounter = 0;
|
|
_trackingTimer.cancel();
|
|
}
|
|
|
|
Future<void> _fetchDescriptionFromList(
|
|
int newsId, NewsDetailsData newsItem) async {
|
|
try {
|
|
final service = RequestService(
|
|
RequestHelper.newsOverviews(
|
|
args: const NewsRequestArgs(page: 1),
|
|
),
|
|
);
|
|
await service.httpGet();
|
|
|
|
if (service.isSuccess) {
|
|
final newsList = service.result['news'] as List?;
|
|
if (newsList != null) {
|
|
for (var item in newsList) {
|
|
if (item['id'] == newsId) {
|
|
final newsData = OverviewData.fromJson(item);
|
|
if (newsData.description.isNotEmpty) {
|
|
newsItem.description = newsData.description;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// ignore: avoid_print
|
|
print('Error fetching description from list: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> getRelatedContents() async {
|
|
if (currentNews.relatedContents.isNotEmpty &&
|
|
currentNews.relatedContentsIsEmpty) {
|
|
return;
|
|
}
|
|
relatedQueue.add(currentNews.id);
|
|
|
|
// مسیر جدید: بر اساس نامِ تگها از content-service.
|
|
if (!AuthConfig.useLegacyApi) {
|
|
final tagNames = currentNews.tags
|
|
.map((t) => t.label)
|
|
.where((l) => l.isNotEmpty)
|
|
.toList();
|
|
if (tagNames.isEmpty) {
|
|
_markRelatedEmpty();
|
|
return;
|
|
}
|
|
final res = await ContentService.instance.getContentsByTagNames(
|
|
tagNames: tagNames,
|
|
page: 1,
|
|
limit: 30,
|
|
);
|
|
if (!res.isSuccess || res.data == null) {
|
|
_markRelatedEmpty();
|
|
return;
|
|
}
|
|
// Backend already filtered by tag name — don't re-filter here.
|
|
// Persian text normalization (Arabic vs Persian YEH, ZWNJ) makes
|
|
// exact string equality unreliable and would drop legitimate matches.
|
|
// Rank by shared-tag count when we can, but keep everything else.
|
|
final tagSet = tagNames.toSet();
|
|
final scored = <MapEntry<int, dynamic>>[];
|
|
for (final c in res.data!.data) {
|
|
if (c.id.hashCode == currentNews.id) continue;
|
|
final shared = c.tagNames.where(tagSet.contains).length;
|
|
scored.add(MapEntry(shared, c));
|
|
}
|
|
scored.sort((a, b) => b.key.compareTo(a.key));
|
|
final target = news.firstWhere((e) => e?.id == currentNews.id);
|
|
if (target == null) return;
|
|
for (final e in scored.take(3)) {
|
|
target.relatedContents.add(OverviewData.fromContentItem(e.value));
|
|
}
|
|
if (target.relatedContents.isEmpty) {
|
|
target.relatedContentsIsEmpty = true;
|
|
}
|
|
notifyListeners();
|
|
return;
|
|
}
|
|
|
|
final service = RequestService(RequestHelper.tag(
|
|
ids: currentNews.tags.map((tag) => tag.id).toList(),
|
|
itemId: currentNews.id,
|
|
type: 'news',
|
|
));
|
|
await service.httpGet();
|
|
if (service.isSuccess) {
|
|
final relateds = service.result['contents'];
|
|
for (var i = 0; i < relateds.length; i++) {
|
|
news
|
|
.where((element) => element != null)
|
|
.firstWhere((element) => element!.id == currentNews.id)!
|
|
.relatedContents
|
|
.add(OverviewData.fromJson(relateds[i]));
|
|
}
|
|
if (relateds.isEmpty) {
|
|
news
|
|
.where((element) => element != null)
|
|
.firstWhere((element) => element!.id == currentNews.id)
|
|
?.relatedContentsIsEmpty = true;
|
|
}
|
|
} else {
|
|
news
|
|
.where((element) => element != null)
|
|
.firstWhere((element) => element!.id == currentNews.id)
|
|
?.relatedContentsIsEmpty = true;
|
|
}
|
|
notifyListeners();
|
|
}
|
|
|
|
void _markRelatedEmpty() {
|
|
final target = news.firstWhere(
|
|
(e) => e?.id == currentNews.id,
|
|
orElse: () => null,
|
|
);
|
|
if (target != null) target.relatedContentsIsEmpty = true;
|
|
notifyListeners();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_handleTracking(sendRequest: true);
|
|
_trackingTimer.cancel();
|
|
super.dispose();
|
|
}
|
|
}
|