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 news = []; late final int initialIndex; late final NewsRequestArgs args; String? initialDescription; late Timer _trackingTimer; int _trackingTimerCounter = 0; bool isFetchingNewItem = false; final List relatedQueue = []; bool _isRequestInProgress = false; int _currentIndex = 0; int get currentIndex => _currentIndex; bool openComments = false; NewsDetailsData get currentNews => news[_currentIndex]!; Future 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 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`: /// - header / paragraph → text (HTML) /// - image → image با URL signed-شده‌ی S3 /// - بقیه‌ی بلوک‌ها (table, chart, ...) به‌شکل ساده به متن یا حذف Future> _bodyToContents(dynamic body) async { final result = []; 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: '$t', 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 : '

$t

', 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(''); for (var i = 0; i < rows.length; i++) { final row = rows[i]; if (row is! List) continue; sb.write(''); for (final cell in row) { final tag = i == 0 ? 'th' : 'td'; sb.write('<$tag>${cell ?? ''}'); } sb.write(''); } sb.write('
'); 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 _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 _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 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 = >[]; 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(); } }