1056 lines
36 KiB
Dart
1056 lines
36 KiB
Dart
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);
|
||
}
|
||
}
|