didvan-app/lib/views/home/main/main_page_state.dart

1056 lines
36 KiB
Dart
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:async';
import 'package:didvan/config/auth_config.dart';
import 'package:didvan/services/auth/jwt_utils.dart';
import 'package:didvan/services/auth/token_storage.dart';
import 'package:didvan/utils/category_labels.dart';
import 'package:didvan/utils/country_labels.dart';
import 'package:didvan/main.dart';
import 'package:didvan/models/content/content_item.dart';
import 'package:didvan/models/didvan_plus_model.dart';
import 'package:didvan/models/didvan_voice_model.dart';
import 'package:didvan/models/enums.dart';
import 'package:didvan/models/home_page_content/home_page_content.dart';
import 'package:didvan/models/home_page_content/home_page_list.dart';
import 'package:didvan/models/home_page_content/content.dart';
import 'package:didvan/models/home_page_content/banner.dart';
import 'package:didvan/models/home_page_content/swot.dart';
import 'package:didvan/models/requests/infography.dart';
import 'package:didvan/models/requests/news.dart';
import 'package:didvan/models/requests/radar.dart';
import 'package:didvan/models/story_model.dart';
import 'package:didvan/models/top_banner_model.dart';
import 'package:didvan/providers/core.dart';
import 'package:didvan/routes/routes.dart';
import 'package:didvan/services/app_initalizer.dart';
import 'package:didvan/services/content/content_service.dart';
import 'package:didvan/services/saha/saha_service.dart';
import 'package:didvan/services/network/request.dart';
import 'package:didvan/services/network/request_helper.dart';
import 'package:didvan/services/story_service.dart';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher_string.dart';
const _legacyTimeout = Duration(seconds: 15);
class MainPageState extends CoreProvier {
MainPageContent? content;
int unread = 0;
List<UserStories> stories = [];
List<SwotItem> swotItems = [];
List<DidvanPlusModel> didvanPlusList = [];
DidvanPlusModel? didvanPlus;
DidvanVoiceModel? didvanVoice;
List<DidvanVoiceModel> didvanVoiceList = [];
TopBannerModel? topBanner;
List<ContentItem> contents = [];
List<MainPageList> exploreLists = [];
List<MainPageBannerType> fooladInfoBanners = [];
static const String _fooladInfoCategoryId =
'b84d4921-2ae5-4201-874c-48f646f8f57b';
static const List<({String type, String header, String categoryId})>
_exploreCategories = [
(
type: 'news',
header: 'دنیای فولاد',
categoryId: '442c2b9a-2002-43a9-8567-79900ed37534'
),
(
type: 'radar',
header: 'پویش افق',
categoryId: 'f97fbe3f-5712-4e27-81d4-b309cf58e91c'
),
(
type: 'trend',
header: 'رادار روند',
categoryId: 'ce2cdb6e-0268-4986-81a8-8a6e7bc06fdf'
),
(
type: 'technology',
header: 'رادار تکنولوژی',
categoryId: 'a026cf50-aead-47b9-bec5-1544e8bf1e92'
),
(
type: 'startup',
header: 'رادار استارت‌آپ',
categoryId: '29c27474-6bdb-4de0-8633-60cb8230faf4'
),
(
type: 'monthly',
header: 'ماهنامه تحلیلی دیدوان',
categoryId: 'd5e8b2c4-7f31-4a92-bc6f-8e2a5d1c3b47'
),
];
int _currentPage = 0;
int _totalPages = 1;
bool _isLoadingMore = false;
String? _searchQuery;
bool get hasMore => _currentPage < _totalPages;
bool get isLoadingMore => _isLoadingMore;
void init() {
debugPrint('🏠 MainPageState init called');
Future.delayed(Duration.zero, () async {
appState = AppState.busy;
contents = [];
_currentPage = 0;
_totalPages = 1;
try {
final tasks = <Future<void>>[
_loadNextPage().catchError((e) {
debugPrint('❌ Failed to load new contents page: $e');
}),
_loadExploreLists(),
_loadFooladInfoBanners(),
_fetchStories(),
_getSwotItems(),
_getDidvanPlus(),
_getDidvanVoice(),
// Top banner از category «بنر فولادینفو» در content-service خوانده
// می‌شود (مستقل از legacy api) — قبلاً پشت useLegacyApi پنهان بود.
_getTopBanner(),
];
if (AuthConfig.useLegacyApi) {
tasks.addAll([
_getMainPageContent(),
]);
} else {
debugPrint(
' Legacy api.didvan.app sections skipped (useLegacyApi=false)');
}
await Future.wait(tasks);
appState = AppState.idle;
} catch (e) {
debugPrint('❌ Main page init failed: $e');
appState = AppState.failed;
}
});
}
Future<void> loadMore() async {
if (_isLoadingMore || !hasMore) return;
_isLoadingMore = true;
notifyListeners();
try {
await _loadNextPage();
} catch (e) {
debugPrint('❌ Main page loadMore failed: $e');
} finally {
_isLoadingMore = false;
notifyListeners();
}
}
Future<void> search(String? query) async {
_searchQuery =
(query == null || query.trim().isEmpty) ? null : query.trim();
contents = [];
_currentPage = 0;
_totalPages = 1;
appState = AppState.busy;
try {
await _loadNextPage();
appState = AppState.idle;
} catch (e) {
debugPrint('❌ Main page search failed: $e');
appState = AppState.failed;
}
}
Future<void> _loadNextPage() async {
final result = await ContentService.instance.getContents(
page: _currentPage + 1,
limit: 20,
search: _searchQuery,
filterStatus: _visibleStatusFilter,
sortBy: const [MapEntry('createdAt', 'DESC')],
);
if (!result.isSuccess || result.data == null) {
throw Exception(result.errorMessage ?? 'خطا در دریافت محتوا');
}
final page = result.data!;
contents.addAll(page.data);
_currentPage = page.currentPage;
_totalPages = page.totalPages;
notifyListeners();
}
static const String _visibleStatusFilter = '\$in:accepted,published';
bool _canView(String type) {
final token = TokenStorage.accessToken ?? '';
if (token.isEmpty) return true;
return JwtUtils.canView(token, type);
}
String _accessTypeForContentType(String type) {
switch (type) {
case 'video':
return 'videocast';
default:
return type;
}
}
Future<void> _loadExploreLists() async {
final lists = <MainPageList>[];
for (final cat in _exploreCategories) {
if (!_canView(cat.type)) continue;
try {
final res = await ContentService.instance.getContents(
page: 1,
limit: 10,
filterStatus: _visibleStatusFilter,
categoryId: cat.categoryId,
sortBy: const [MapEntry('createdAt', 'DESC')],
);
var items =
(res.isSuccess ? res.data?.data : null) ?? const <ContentItem>[];
if (items.isEmpty) continue;
// برای ماهنامه نیاز به body داریم تا fileUrl resolve شود. detail
// هر آیتم را به‌صورت موازی می‌گیریم.
if (cat.type == 'monthly') {
final fetches = items.map((c) async {
final detail = await ContentService.instance.getContent(c.id);
return detail.isSuccess && detail.data != null ? detail.data! : c;
}).toList();
items = await Future.wait(fetches);
}
lists.add(MainPageList(
type: cat.type,
header: cat.header,
more: 'مشاهده همه',
link: _seeAllLinkFor(cat.type),
contents: items.map((c) => _contentToMainItem(c, cat.type)).toList(),
));
} catch (e) {
debugPrint('❌ explore list (${cat.type}) failed: $e');
}
}
// «سها» — بخش هم‌اندیشی. مثل قبل همیشه یک ورودی نمایش داده می‌شود که با
// تپ، صفحه‌ی /saha را در WebView (با کوکی احراز هویت) باز می‌کند؛ آن صفحه
// خودش لیست پنل‌های کاربر و ورود به پاسخگویی را مدیریت می‌کند. تعدادِ
// پنل‌ها را best-effort از /delphi/my-forms می‌گیریم فقط برای زیرنویس؛
// نمایشِ بخش به این فراخوانی وابسته نیست (شبکه ممکن است کند/قطع باشد).
if (_canView('survey')) {
const sahaFallback = 'https://app.didvan.com/saha';
List<SahaPanel> panels = const [];
try {
panels = await SahaService.instance.fetchMyPanels();
debugPrint('🗳️ SAHA: my-forms returned ${panels.length} panels');
} catch (e) {
debugPrint('🗳️ SAHA: my-forms failed (section still shown): $e');
}
// One card per panel — tap generates a magic-link and opens THAT panel
// directly in the WebView. Panel UUID is carried via the `link` field
// with a `saha-panel://<uuid>` scheme, resolved in navigationHandler.
// When the user has no panels, fall back to the aggregate /saha page.
final panelCards = panels.isEmpty
? [
MainPageContentType(
id: 0,
title: 'ورود به سامانه هم‌اندیشی',
image: '',
link: sahaFallback,
marked: false,
isViewed: false,
subtitles: const [],
),
]
: panels
.asMap()
.entries
.map((e) => MainPageContentType(
id: e.key,
title: e.value.title,
image: '',
link: 'saha-panel://${e.value.id}',
marked: false,
isViewed: false,
subtitles: e.value.description == null || e.value.description!.isEmpty
? const []
: [e.value.description!],
description: e.value.description,
))
.toList();
lists.add(MainPageList(
type: 'survey',
header: 'سها',
more: 'مشاهده همه',
link: sahaFallback,
contents: panelCards,
));
debugPrint('🗳️ SAHA: section added to exploreLists (${panelCards.length} cards)');
} else {
debugPrint('🗳️ SAHA: hidden (deny:view:survey role present)');
}
// Multi-tenant: بخش‌هایی که کاربر permission ندارد از state حذف می‌شوند.
// deny:view:* حتی برای super tenant هم اولویت دارد.
exploreLists = _filterByPermissions(lists);
notifyListeners();
}
List<MainPageList> _filterByPermissions(List<MainPageList> lists) {
final token = TokenStorage.accessToken ?? '';
if (token.isEmpty) return lists;
return lists.where((l) => JwtUtils.canView(token, l.type)).toList();
}
Future<void> _loadFooladInfoBanners() async {
if (!_canView('infography')) {
fooladInfoBanners = [];
notifyListeners();
return;
}
try {
// فقط آخرین (تازه‌ترین) بنر فولادینفو نشان داده می‌شود — یعنی هیچ
// carousel چرخشی نباشد. limit=1 + DESC = جدیدترین.
final res = await ContentService.instance.getContents(
page: 1,
limit: 1,
filterStatus: _visibleStatusFilter,
categoryId: _fooladInfoCategoryId,
sortBy: const [MapEntry('createdAt', 'DESC')],
);
final items =
(res.isSuccess ? res.data?.data : null) ?? const <ContentItem>[];
fooladInfoBanners = items
.where((c) => (c.coverImage ?? '').isNotEmpty)
.map((c) => MainPageBannerType(image: c.coverImage!, link: null))
.toList();
notifyListeners();
} catch (e) {
debugPrint('❌ fooladInfo banners failed: $e');
}
}
String _webLinkFor(String type, ContentItem c) {
String? slug;
switch (type) {
case 'trend':
slug = 'trend';
break;
case 'technology':
slug = 'technology';
break;
case 'startup':
slug = 'startup';
break;
}
if (slug == null) return '';
return 'https://app.didvan.com/$slug/${c.id}';
}
String _seeAllLinkFor(String type) {
switch (type) {
case 'news':
return Routes.news;
case 'radar':
return Routes.radars;
case 'trend':
return 'https://app.didvan.com/trend';
case 'technology':
return 'https://app.didvan.com/technology';
case 'startup':
return 'https://app.didvan.com/startup';
case 'monthly':
return Routes.monthlyList;
default:
return '';
}
}
/// اولین پاراگراف غیر خالی body را به‌عنوان تعداد صفحات برمی‌گرداند.
/// در صورت نبودن، رشته‌ی خالی.
String _extractMonthlyPageCount(ContentItem c) {
final body = c.body;
if (body is Map && body['blocks'] is List) {
for (final block in body['blocks'] as List) {
if (block is Map &&
(block['type'] == 'paragraph' || block['type'] == 'header') &&
block['data'] is Map) {
final text = (block['data']['text'] ?? '').toString().trim();
if (text.isNotEmpty) {
// فقط ارقام را نگه می‌داریم تا کاراکترهای اضافی حذف شوند.
final digits = text.replaceAll(RegExp(r'[^0-9۰-۹]'), '');
if (digits.isNotEmpty) return digits;
}
}
}
}
// fallback: اولین keyword عددی.
for (final k in c.keywords) {
final digits = k.replaceAll(RegExp(r'[^0-9۰-۹]'), '');
if (digits.isNotEmpty) return digits;
}
return '';
}
MainPageContentType _contentToMainItem(ContentItem c, String type) {
final dateIso =
c.publishedAt?.toIso8601String() ?? DateTime.now().toIso8601String();
List<String> subtitles;
switch (type) {
case 'news':
case 'radar':
subtitles = [dateIso];
break;
case 'startup':
// subtitles[0] = location (مقر اصلی، فارسی)
// subtitles[1] = دسته‌بندی‌ها (انگلیسی، بدون ترجمه)
final loc = CountryLabels.label(c.metaHeadquarter);
final cats = List<String>.from(c.metaCategories);
if (cats.isEmpty &&
c.metaCategory != null &&
c.metaCategory!.isNotEmpty) {
cats.add(c.metaCategory!);
}
subtitles = [
loc.isEmpty ? (c.metaHeadquarter ?? '') : loc,
cats.isEmpty ? (c.summary ?? '') : cats.join(', '),
];
break;
case 'trend':
final translated = CategoryLabels.label(c.metaCategory);
subtitles = [translated.isNotEmpty ? translated : (c.summary ?? '')];
break;
case 'technology':
final translated = CategoryLabels.label(c.metaCategory);
subtitles = [translated.isNotEmpty ? translated : (c.summary ?? '')];
break;
case 'monthly':
// اولین پاراگراف خالی-نشده از body به‌عنوان «تعداد صفحات»
// (پنل توضیح می‌دهد که در پاراگراف زیر PDF فقط عدد وارد شود).
final pageCount = _extractMonthlyPageCount(c);
subtitles = [pageCount, dateIso];
break;
default:
subtitles = [c.summary ?? ''];
}
// برای ماهنامه، فایل PDF را در `file` می‌گذاریم تا با tap مستقیما
// pdf-viewer باز شود (navigationHandler.case 'monthly').
final fileUrl = type == 'monthly' ? (c.videoUrl ?? c.audioUrl) : null;
return MainPageContentType(
id: c.id.hashCode,
contentId: c.id,
title: c.title,
image: (c.coverImage != null && c.coverImage!.isNotEmpty)
? c.coverImage!
: '',
link: _webLinkFor(type, c),
file: fileUrl,
marked: c.isBookmarked,
isViewed: c.isRead,
subtitles: subtitles,
description: c.summary,
);
}
Future<void> toggleBookmark(ContentItem item) async {
final originalState = item.isBookmarked;
_updateContentItem(item.copyWith(isBookmarked: !originalState));
final result = await ContentService.instance.toggleBookmark(item.id);
if (!result.isSuccess) {
_updateContentItem(item.copyWith(isBookmarked: originalState));
}
}
Future<void> toggleLike(ContentItem item) async {
final originalLiked = item.isLiked;
final originalCount = item.likeCount;
_updateContentItem(item.copyWith(
isLiked: !originalLiked,
likeCount: originalLiked ? originalCount - 1 : originalCount + 1,
));
final result = await ContentService.instance.toggleLike(item.id);
if (!result.isSuccess) {
_updateContentItem(
item.copyWith(isLiked: originalLiked, likeCount: originalCount));
}
}
void _updateContentItem(ContentItem updated) {
final idx = contents.indexWhere((c) => c.id == updated.id);
if (idx == -1) return;
contents[idx] = updated;
notifyListeners();
}
Future<void> _getMainPageContent() async {
try {
final service = RequestService(RequestHelper.mainPageContent);
await service.httpGet().timeout(_legacyTimeout);
if (service.isSuccess) {
final parsed = MainPageContent.fromJson(service.result);
content = MainPageContent(
banners: parsed.banners,
lists: _filterByPermissions(parsed.lists),
);
unread = (service.result['unread'] as int?) ?? 0;
notifyListeners();
} else {
debugPrint('⚠️ mainPageContent legacy: status=${service.statusCode}');
}
} on TimeoutException {
debugPrint('⏱️ mainPageContent legacy timeout');
} catch (e) {
debugPrint('❌ mainPageContent legacy failed: $e');
}
}
static const String _storyCategoryId = '114366e1-48d0-43ad-9184-c2c67a7dded8';
Future<void> _fetchStories() async {
if (!_canView('story')) {
stories = [];
notifyListeners();
return;
}
if (!AuthConfig.useLegacyApi) {
await _fetchStoriesFromContentService();
return;
}
try {
stories = await StoryService.getStories().timeout(_legacyTimeout);
_sortStoryCategoriesByLatestPublish();
notifyListeners();
} on TimeoutException {
debugPrint('⏱️ stories legacy timeout');
stories = [];
} catch (e) {
debugPrint('❌ stories legacy failed: $e');
stories = [];
}
}
Future<void> _fetchStoriesFromContentService() async {
try {
final res = await ContentService.instance.getContents(
page: 1,
limit: 50,
filterStatus: _visibleStatusFilter,
categoryId: _storyCategoryId,
sortBy: const [MapEntry('createdAt', 'DESC')],
);
if (!res.isSuccess || res.data == null) {
stories = [];
return;
}
final items = res.data!.data;
final Map<String, List<ContentItem>> grouped = {};
for (final c in items) {
final key = (c.metaStoryType ?? 'other');
grouped.putIfAbsent(key, () => <ContentItem>[]).add(c);
}
final list = <UserStories>[];
var groupId = 1;
grouped.forEach((typeKey, groupItems) {
final storyItems = <StoryItem>[];
var itemId = 1;
for (final c in groupItems) {
final url = (c.videoUrl != null && c.videoUrl!.isNotEmpty)
? c.videoUrl!
: (c.coverImage ?? '');
if (url.isEmpty) continue;
storyItems.add(StoryItem(
id: itemId++,
url: url,
media: (c.videoUrl != null && c.videoUrl!.isNotEmpty)
? MediaType.video
: MediaType.image,
duration: const Duration(seconds: 10),
viewed: c.isRead,
contentUuid: c.id,
));
}
if (storyItems.isEmpty) return;
final latestPublishDate = groupItems
.map((item) => item.publishedAt ?? item.createdAt)
.whereType<DateTime>()
.fold<DateTime?>(
null,
(latest, date) =>
latest == null || date.isAfter(latest) ? date : latest,
);
list.add(UserStories(
id: groupId++,
user: User(
name: _persianStoryType(typeKey),
profileImageUrl: _iconForStoryType(typeKey),
createdAt: latestPublishDate?.toIso8601String() ?? '',
),
stories: storyItems,
));
});
stories = list;
_sortStoryCategoriesByLatestPublish();
notifyListeners();
} catch (e) {
debugPrint('❌ stories (content-service) failed: $e');
stories = [];
}
}
void _sortStoryCategoriesByLatestPublish() {
stories.sort((a, b) {
final aDate = DateTime.tryParse(a.user.createdAt);
final bDate = DateTime.tryParse(b.user.createdAt);
if (aDate == null && bDate == null) return 0;
if (aDate == null) return 1;
if (bDate == null) return -1;
return bDate.compareTo(aDate);
});
}
String _iconForStoryType(String type) {
switch (type) {
case 'macro-trends':
return 'lib/assets/icons/MACRO_TRENDS_N.png';
case 'macro-environment':
return 'lib/assets/icons/MACRO_ENVIRONMENT_N.png';
case 'industry-environment':
return 'lib/assets/icons/INDUSTRY_ENVIRONMENT_N.png';
case 'definition':
return 'lib/assets/icons/CONCEPTS_N.png';
case 'investment':
return 'lib/assets/icons/MODERN_INVESTMENTS_N.png';
default:
return 'lib/assets/icons/CONCEPTS_N.png';
}
}
String _persianStoryType(String type) {
switch (type) {
case 'macro-trends':
return 'روندهای کلان';
case 'macro-environment':
return 'محیط کلان';
case 'industry-environment':
return 'محیط صنعت';
case 'definition':
return 'مفاهیم';
case 'investment':
return 'سرمایه‌گذاری';
default:
return 'سایر';
}
}
static const String _swotCategoryId = 'f0092865-e004-4031-91c3-eca1fb0c6def';
Future<void> _getSwotItems() async {
if (!_canView('swot')) {
swotItems = [];
notifyListeners();
return;
}
try {
final res = await ContentService.instance.getContents(
page: 1,
limit: 10,
filterStatus: _visibleStatusFilter,
categoryId: _swotCategoryId,
sortBy: const [MapEntry('createdAt', 'DESC')],
);
final items =
(res.isSuccess ? res.data?.data : null) ?? const <ContentItem>[];
swotItems = items.map(SwotItem.fromContentItem).toList();
notifyListeners();
} catch (e) {
debugPrint('❌ swot (content-service) failed: $e');
}
}
static const String _didvanPlusCategoryId =
'e4c8a3b1-9d27-4e6f-bf3d-2a1c8e5f4d12';
Future<void> _getDidvanPlus() async {
if (!_canView('didvan-plus')) {
didvanPlus = null;
didvanPlusList = [];
notifyListeners();
return;
}
if (!AuthConfig.useLegacyApi) {
try {
final res = await ContentService.instance.getContents(
page: 1,
limit: 20,
filterStatus: _visibleStatusFilter,
categoryId: _didvanPlusCategoryId,
sortBy: const [MapEntry('createdAt', 'DESC')],
);
if (!res.isSuccess || res.data == null) return;
final fetches = res.data!.data.map((c) async {
final detail = await ContentService.instance.getContent(c.id);
if (detail.isSuccess && detail.data != null) {
return detail.data!;
}
return c;
}).toList();
final fullItems = await Future.wait(fetches);
didvanPlusList = fullItems
.map((c) => DidvanPlusModel(
id: c.id.hashCode,
title: c.title,
description: c.summary ?? '',
image: c.coverImage ?? '',
file: c.videoUrl ?? c.audioUrl ?? '',
publishedAt:
(c.publishedAt ?? c.createdAt)?.toIso8601String() ??
DateTime.now().toIso8601String(),
duration: c.mediaDuration,
))
.toList();
if (didvanPlusList.isNotEmpty) {
didvanPlus = didvanPlusList.first;
}
notifyListeners();
} catch (e) {
debugPrint('❌ didvanPlus (content-service) failed: $e');
}
return;
}
// مسیر legacy.
try {
final service = RequestService(RequestHelper.didvanPlus);
await service.httpGet().timeout(_legacyTimeout);
if (service.statusCode == 200) {
final rawData = service.data('result');
if (rawData is List && rawData.isNotEmpty) {
didvanPlusList = rawData
.map((item) =>
DidvanPlusModel.fromJson(Map<String, dynamic>.from(item)))
.toList();
didvanPlus = didvanPlusList.first;
notifyListeners();
} else if (rawData is Map) {
didvanPlus =
DidvanPlusModel.fromJson(Map<String, dynamic>.from(rawData));
didvanPlusList = [didvanPlus!];
notifyListeners();
}
}
} on TimeoutException {
debugPrint('⏱️ didvanPlus legacy timeout');
} catch (e) {
debugPrint('❌ didvanPlus legacy failed: $e');
}
}
static const String _didvanVoiceCategoryId =
'a7f3c9e2-5b18-4d6a-9c7e-3f8b1d5e2a91';
Future<void> _getDidvanVoice() async {
if (!_canView('strategy-bite')) {
didvanVoice = null;
didvanVoiceList = [];
notifyListeners();
return;
}
if (!AuthConfig.useLegacyApi) {
try {
final res = await ContentService.instance.getContents(
page: 1,
limit: 20,
filterStatus: _visibleStatusFilter,
categoryId: _didvanVoiceCategoryId,
sortBy: const [MapEntry('createdAt', 'DESC')],
);
if (!res.isSuccess || res.data == null) return;
// endpoint لیست body نمی‌فرستد، پس برای هر آیتم detail کامل را
// می‌گیریم تا audioUrl پر شود.
final fetches = res.data!.data.map((c) async {
final detail = await ContentService.instance.getContent(c.id);
if (detail.isSuccess && detail.data != null) {
return detail.data!;
}
return c;
}).toList();
final fullItems = await Future.wait(fetches);
didvanVoiceList = fullItems
.map((c) => DidvanVoiceModel(
id: c.id.hashCode,
title: c.title,
file: c.audioUrl ?? '',
image: c.coverImage ?? '',
publishedAt:
(c.publishedAt ?? c.createdAt)?.toIso8601String() ??
DateTime.now().toIso8601String(),
))
.toList();
if (didvanVoiceList.isNotEmpty) {
didvanVoice = _pickLatestVoice(List.from(didvanVoiceList));
}
notifyListeners();
} catch (e) {
debugPrint('❌ didvanVoice (content-service) failed: $e');
}
return;
}
try {
final service = RequestService(RequestHelper.didvanVoice);
await service.httpGet().timeout(_legacyTimeout);
if (service.statusCode == 200) {
final rawData = service.data('result');
if (rawData is List && rawData.isNotEmpty) {
didvanVoiceList = rawData
.map((e) => DidvanVoiceModel.fromJson(
Map<String, dynamic>.from(e as Map)))
.toList();
didvanVoice = _pickLatestVoice(List.from(didvanVoiceList));
notifyListeners();
} else if (rawData is Map) {
didvanVoice =
DidvanVoiceModel.fromJson(Map<String, dynamic>.from(rawData));
didvanVoiceList = [didvanVoice!];
notifyListeners();
}
}
} on TimeoutException {
debugPrint('⏱️ didvanVoice legacy timeout');
} catch (e) {
debugPrint('❌ didvanVoice legacy failed: $e');
}
}
/// شناسه‌ی دسته‌ی «بنر فولادینفو» در content-service. آخرین آیتم منتشرشده
/// در این دسته به‌عنوان پوستر بالای صفحه‌ی فولادینفو استفاده می‌شود.
static const String _foladinfoBannerCategoryId =
'd1a5e9b3-6f47-4c82-9e1d-3b8f2c5a4d76';
/// تاپ‌بنر فولادینفو از content-service خوانده می‌شود (آخرین content
/// منتشرشده در دسته‌ی «بنر فولادینفو»). `coverImage` این آیتم سرور-ساید
/// از `metadata.cover_image_id` به signed S3 URL تبدیل می‌شود.
Future<void> _getTopBanner() async {
if (!_canView('infography')) {
topBanner = null;
notifyListeners();
return;
}
debugPrint('[FOLADINFO_TOPBANNER] start');
try {
final res = await ContentService.instance.getContents(
page: 1,
limit: 1,
filterStatus: r'$in:accepted,published',
categoryId: _foladinfoBannerCategoryId,
sortBy: const [MapEntry('createdAt', 'DESC')],
);
debugPrint(
'[FOLADINFO_TOPBANNER] success=${res.isSuccess} len=${res.data?.data.length}');
if (!res.isSuccess || res.data == null || res.data!.data.isEmpty) {
topBanner = null;
notifyListeners();
return;
}
final first = res.data!.data.first;
String? cover = first.coverImage;
if (cover == null || cover.isEmpty) {
// fallback: backend ممکن است coverImage را در list نگذارد؛ detail
// را می‌گیریم چون findOne به‌طور حتم signed URL را پر می‌کند.
final detail = await ContentService.instance.getContent(first.id);
if (detail.isSuccess && detail.data != null) {
cover = detail.data!.coverImage;
}
}
debugPrint('[FOLADINFO_TOPBANNER] cover=$cover');
if (cover != null && cover.isNotEmpty) {
topBanner = TopBannerModel(id: 0, image: cover);
} else {
topBanner = null;
}
notifyListeners();
} catch (e, st) {
debugPrint('[FOLADINFO_TOPBANNER] ERROR: $e\n$st');
}
}
DidvanVoiceModel? _pickLatestVoice(List<DidvanVoiceModel> items) {
if (items.isEmpty) return null;
items.sort((a, b) {
try {
return DateTime.parse(b.publishedAt)
.compareTo(DateTime.parse(a.publishedAt));
} catch (_) {
return b.id.compareTo(a.id);
}
});
return items.first;
}
void markChangeHandler(String type, int id, bool value) {
content?.lists
.firstWhere((element) => element.type == type)
.contents
.firstWhere((element) => element.id == id)
.marked = value;
notifyListeners();
}
Future<void> _fetchMonthlyDetailsAndNavigate(int id, String? title) async {
final context = navigatorKey.currentContext!;
debugPrint('🔄 Fetching monthly details for ID: $id');
try {
final url = '${RequestHelper.baseUrl}/monthly/$id';
final service = RequestService(url);
await service.httpGet().timeout(_legacyTimeout);
if (service.isSuccess) {
final data = service.result.containsKey('result')
? service.result['result']
: service.result;
final fetchedFile = data['file'];
final fetchedLink = data['link'];
if (fetchedLink != null && fetchedLink.toString().isNotEmpty) {
Navigator.of(context).pushNamed(
Routes.web,
arguments: fetchedLink.toString(),
);
return;
}
if (fetchedFile != null && fetchedFile.toString().isNotEmpty) {
Navigator.of(context).pushNamed(
Routes.pdfViewer,
arguments: {
'pdfUrl': fetchedFile.toString(),
'title': title ?? '',
},
);
return;
}
}
} catch (e) {
debugPrint('❌ Error fetching monthly details: $e');
}
debugPrint('⚠️ No file/link found, going to list');
Navigator.of(context).pushNamed(Routes.monthlyList);
}
void navigationHandler(
String type,
dynamic id,
String? link, {
String? description,
String? file,
String? contentUuid,
}) {
link = link ?? '';
final context = navigatorKey.currentContext;
if (context == null) return;
if (!_canView(_accessTypeForContentType(type))) return;
dynamic args;
switch (type) {
case 'infography':
Navigator.of(context).pushNamed(Routes.infography, arguments: {
'onMarkChanged': (id, value) => markChangeHandler(type, id, value),
'id': id is int ? id : 0,
'args': const InfographyRequestArgs(page: 0),
'hasUnmarkConfirmation': false,
});
return;
case 'news':
Navigator.of(context).pushNamed(Routes.newsDetails, arguments: {
'onMarkChanged': (id, value) => markChangeHandler(type, id, value),
'id': id is int ? id : 0,
// New-API UUID: forces getContentDetail path in details state.
// Without this, details tries the legacy endpoint with the
// hashCode-derived id, gets a network/404 failure, and the page
// shows "اتصال اینترنت برقرار نیست".
if (contentUuid != null && contentUuid.isNotEmpty)
'contentUuid': contentUuid,
'args': const NewsRequestArgs(page: 0),
'hasUnmarkConfirmation': false,
'description': description,
});
return;
case 'radar':
Navigator.of(context).pushNamed(Routes.radarDetails, arguments: {
'onMarkChanged': (id, value) => markChangeHandler(type, id, value),
'id': id is int ? id : 0,
if (contentUuid != null && contentUuid.isNotEmpty)
'contentUuid': contentUuid,
'args': const RadarRequestArgs(page: 0),
'hasUnmarkConfirmation': false,
'description': description,
});
return;
case 'video':
args = {'type': 'podcast', 'id': id};
Navigator.of(context).pushNamed(Routes.studioDetails, arguments: args);
return;
case 'survey':
// Per-panel cards carry `saha-panel://<uuid>`. Cookies alone aren't
// enough — the /panel route redirects non-admin users, so we mint a
// magic-link token via /delphi/forms/:id/my-link and open the
// respondent page with it. The fallback aggregate `/saha` URL (used
// when the user has no panels) opens as-is.
if (link.startsWith('saha-panel://')) {
final panelId = link.substring('saha-panel://'.length);
() async {
final url = await SahaService.instance.buildMyPanelUrl(panelId);
final ctx = navigatorKey.currentContext;
if (ctx == null) return;
if (url == null) {
ScaffoldMessenger.of(ctx).showSnackBar(
const SnackBar(content: Text('باز کردن پنل ممکن نشد. لطفاً دوباره تلاش کنید.')),
);
return;
}
Navigator.of(ctx).pushNamed(Routes.web, arguments: url);
}();
} else if (link.isNotEmpty) {
Navigator.of(context).pushNamed(Routes.web, arguments: link);
}
return;
case 'monthly':
if (link.isNotEmpty) {
Navigator.of(context).pushNamed(Routes.web, arguments: link);
return;
}
if (file != null && file.isNotEmpty && file != 'null') {
Navigator.of(context).pushNamed(Routes.pdfViewer, arguments: {
'pdfUrl': file,
'title': description ?? '',
});
return;
}
if (id is int) {
_fetchMonthlyDetailsAndNavigate(id, description);
}
return;
}
if (link.isEmpty) return;
if (link.startsWith('http')) {
AppInitializer.openWebLink(
context,
'$link?accessToken=${RequestService.token}',
mode: LaunchMode.inAppWebView,
);
return;
}
Navigator.of(context).pushNamed(link, arguments: args);
}
}