didvan-app/lib/views/radar/radar_details/radar_details_state.dart

472 lines
15 KiB
Dart

import 'dart:async';
import 'dart:math';
import 'package:didvan/config/auth_config.dart';
import 'package:didvan/models/category.dart';
import 'package:didvan/models/content.dart';
import 'package:didvan/models/enums.dart';
import 'package:didvan/models/overview_data.dart';
import 'package:didvan/models/radar_details_data.dart';
import 'package:didvan/models/tag.dart';
import 'package:didvan/models/requests/radar.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 RadarDetailsState extends CoreProvier {
final List<RadarDetailsData?> radars = [];
late Timer _trackingTimer;
int _trackingTimerCounter = 0;
late final int initialIndex;
late final RadarRequestArgs args;
String? initialDescription;
bool isFetchingNewItem = false;
final List<int> relatedQueue = [];
bool openComments = false;
bool _isRequestInProgress = false;
int _currentIndex = 0;
int get currentIndex => _currentIndex;
RadarDetailsData get currentRadar {
try {
return radars[_currentIndex]!;
} catch (e) {
return radars[_currentIndex + 1]!;
}
}
Future<void> getRadarDetails(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.radarDetails(id, args));
await service.httpGet();
_handleTracking(sendRequest: isForward != null);
if (service.isSuccess) {
final result = service.result;
final radar = RadarDetailsData.fromJson(result['radar']);
if (isForward == null && initialDescription != null) {
radar.description = initialDescription;
}
if (isForward == null && radar.description == null) {
await _fetchDescriptionFromList(radar.id, radar);
}
if (args.page == 0) {
radars.add(radar);
initialIndex = 0;
appState = AppState.idle;
isFetchingNewItem = false;
_isRequestInProgress = false;
return;
}
RadarDetailsData? prevRadar;
if (result['prevRadar'].isNotEmpty) {
prevRadar = RadarDetailsData.fromJson(result['prevRadar']);
}
RadarDetailsData? nextRadar;
if (result['nextRadar'].isNotEmpty) {
nextRadar = RadarDetailsData.fromJson(result['nextRadar']);
}
if (isForward == null) {
radars
.addAll(List.generate(max(radar.order - 2, 0), (index) => null));
if (prevRadar != null) {
radars.add(prevRadar);
}
radars.add(radar);
if (nextRadar != null) {
radars.add(nextRadar);
}
_currentIndex = initialIndex = radar.order - 1;
} else if (isForward) {
if (!exists(nextRadar) && nextRadar != null) {
radars.add(nextRadar);
}
_currentIndex++;
} else if (!isForward) {
if (!exists(prevRadar) && prevRadar != null) {
radars[_currentIndex - 2] = prevRadar;
}
_currentIndex--;
}
isFetchingNewItem = false;
// Always fetch related content — see news_details_state for the
// same fix. contents.length gate was hiding مطالب مرتبط.
getRelatedContents();
appState = AppState.idle;
} else {
isFetchingNewItem = false;
if (isForward == null) {
appState = AppState.failed;
} else {
notifyListeners();
}
}
} catch (e) {
// ignore: avoid_print
print('Error fetching radar details: $e');
isFetchingNewItem = false;
if (isForward == null) {
appState = AppState.failed;
}
notifyListeners();
} finally {
_isRequestInProgress = false;
}
}
Future<void> _fetchDescriptionFromList(
int radarId, RadarDetailsData radarItem) async {
try {
final service = RequestService(
RequestHelper.radarOverviews(
args: const RadarRequestArgs(page: 1),
),
);
await service.httpGet();
if (service.isSuccess) {
final radarList = service.result['radars'] as List?;
if (radarList != null) {
for (var item in radarList) {
if (item['id'] == radarId) {
final radarData = OverviewData.fromJson(item);
if (radarData.description.isNotEmpty) {
radarItem.description = radarData.description;
}
break;
}
}
}
}
} catch (e) {
// ignore: avoid_print
print('Error fetching description from list: $e');
}
}
Future<void> getRelatedContents() async {
if (currentRadar.relatedContents.isNotEmpty &&
currentRadar.relatedContentsIsEmpty) {
return;
}
relatedQueue.add(currentRadar.id);
if (!AuthConfig.useLegacyApi) {
final tagNames = currentRadar.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.
final tagSet = tagNames.toSet();
final scored = <MapEntry<int, dynamic>>[];
for (final c in res.data!.data) {
if (c.id.hashCode == currentRadar.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 = radars.firstWhere((e) => e?.id == currentRadar.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: currentRadar.tags.map((tag) => tag.id).toList(),
itemId: currentRadar.id,
type: 'radar',
));
await service.httpGet();
if (service.isSuccess) {
final relateds = service.result['contents'];
for (var i = 0; i < relateds.length; i++) {
radars
.where((element) => element != null)
.firstWhere((element) => element!.id == currentRadar.id)!
.relatedContents
.add(OverviewData.fromJson(relateds[i]));
}
if (relateds.isEmpty) {
radars
.where((element) => element != null)
.firstWhere((element) => element!.id == currentRadar.id)
?.relatedContentsIsEmpty = true;
}
} else {
radars
.where((element) => element != null)
.firstWhere((element) => element!.id == currentRadar.id)
?.relatedContentsIsEmpty = true;
}
notifyListeners();
}
/// واکشیِ جزئیات از content-service جدید (با uuid) و نمایش در همان
/// UI رادار. body فرمتِ Editor.js را به بلوک‌های `Content` ترجمه می‌کند.
Future<void> getContentDetail(String uuid) async {
appState = AppState.busy;
notifyListeners();
try {
_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);
String? podcastUrl;
for (final ct in contents) {
if (ct.audio != null && ct.audio!.isNotEmpty) {
podcastUrl = ct.audio;
break;
}
}
int? podcastDuration;
if (item.audioId != null) {
podcastDuration = S3MediaService.instance.durationFor(item.audioId);
}
podcastDuration ??= item.mediaDuration;
radars.clear();
radars.add(RadarDetailsData(
id: item.id.hashCode,
title: item.title,
image: item.coverImage ?? '',
timeToRead: 0,
createdAt:
item.publishedAt?.toIso8601String() ?? DateTime.now().toIso8601String(),
podcast: podcastUrl,
forManagers: false,
marked: item.isBookmarked,
comments: 0,
tags: item.tagNames
.where((n) => n.isNotEmpty)
.map((n) => Tag(id: n.hashCode, label: n))
.toList(),
contents: contents,
categories: const <CategoryData>[],
order: 1,
duration: podcastDuration,
description: item.summary,
relatedContentsIsEmpty: false,
keycloakId: item.id,
));
initialIndex = 0;
_currentIndex = 0;
appState = AppState.idle;
notifyListeners();
getRelatedContents();
} catch (e) {
// ignore: avoid_print
print('getContentDetail error: $e');
appState = AppState.failed;
notifyListeners();
}
}
/// همان مَپِّرِ Editor.js → List<Content> که در NewsDetailsState استفاده می‌شود.
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:
break;
}
}
return result;
}
bool exists(RadarDetailsData? radar) =>
radars.any((r) => radar != null && r != null && r.id == radar.id);
void onCommentsChanged(int count) {
radars.firstWhere((radar) => radar?.id == currentRadar.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(currentRadar.id, 'radar'),
body: {
'sec': _trackingTimerCounter,
},
);
service.put();
_trackingTimerCounter = 0;
_trackingTimer.cancel();
}
void _markRelatedEmpty() {
final target = radars.firstWhere(
(e) => e?.id == currentRadar.id,
orElse: () => null,
);
if (target != null) target.relatedContentsIsEmpty = true;
notifyListeners();
}
@override
void dispose() {
_handleTracking(sendRequest: true);
_trackingTimer.cancel();
super.dispose();
}
}