import 'dart:async'; import 'package:didvan/config/auth_config.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/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 stories = []; List swotItems = []; List didvanPlusList = []; DidvanPlusModel? didvanPlus; DidvanVoiceModel? didvanVoice; List didvanVoiceList = []; TopBannerModel? topBanner; List contents = []; List exploreLists = []; List 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 = >[ _loadNextPage().catchError((e) { debugPrint('❌ Failed to load new contents page: $e'); }), _loadExploreLists(), _loadFooladInfoBanners(), _fetchStories(), _getSwotItems(), _getDidvanPlus(), _getDidvanVoice(), ]; if (AuthConfig.useLegacyApi) { tasks.addAll([ _getMainPageContent(), _getTopBanner(), ]); } 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 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 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 _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'; Future _loadExploreLists() async { final lists = []; for (final cat in _exploreCategories) { 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 []; 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'); } } exploreLists = lists; notifyListeners(); } Future _loadFooladInfoBanners() async { try { final res = await ContentService.instance.getContents( page: 1, limit: 15, filterStatus: _visibleStatusFilter, categoryId: _fooladInfoCategoryId, sortBy: const [MapEntry('createdAt', 'DESC')], ); final items = (res.isSuccess ? res.data?.data : null) ?? const []; 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 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.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 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 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 _getMainPageContent() async { try { final service = RequestService(RequestHelper.mainPageContent); await service.httpGet().timeout(_legacyTimeout); if (service.isSuccess) { content = MainPageContent.fromJson(service.result); 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 _fetchStories() async { if (!AuthConfig.useLegacyApi) { await _fetchStoriesFromContentService(); return; } try { stories = await StoryService.getStories().timeout(_legacyTimeout); notifyListeners(); } on TimeoutException { debugPrint('⏱️ stories legacy timeout'); stories = []; } catch (e) { debugPrint('❌ stories legacy failed: $e'); stories = []; } } Future _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> grouped = {}; for (final c in items) { final key = (c.metaStoryType ?? 'other'); grouped.putIfAbsent(key, () => []).add(c); } final list = []; var groupId = 1; grouped.forEach((typeKey, groupItems) { final storyItems = []; 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; list.add(UserStories( id: groupId++, user: User( name: _persianStoryType(typeKey), profileImageUrl: _iconForStoryType(typeKey), createdAt: DateTime.now().toIso8601String(), ), stories: storyItems, )); }); stories = list; notifyListeners(); } catch (e) { debugPrint('❌ stories (content-service) failed: $e'); stories = []; } } 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 _getSwotItems() async { 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 []; 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 _getDidvanPlus() async { 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.from(item))) .toList(); didvanPlus = didvanPlusList.first; notifyListeners(); } else if (rawData is Map) { didvanPlus = DidvanPlusModel.fromJson(Map.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 _getDidvanVoice() async { 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.from(e as Map))) .toList(); didvanVoice = _pickLatestVoice(List.from(didvanVoiceList)); notifyListeners(); } else if (rawData is Map) { didvanVoice = DidvanVoiceModel.fromJson(Map.from(rawData)); didvanVoiceList = [didvanVoice!]; notifyListeners(); } } } on TimeoutException { debugPrint('⏱️ didvanVoice legacy timeout'); } catch (e) { debugPrint('❌ didvanVoice legacy failed: $e'); } } // TODO(backend-migration): `api.didvan.app/topBanner` فعلا در دسترس نیست. // پس از پیاده‌سازی endpoint معادل در api2.didvan.com، این تابع را به آن // متصل کنید. Future _getTopBanner() async { try { final service = RequestService(RequestHelper.topBanner); await service.httpGet().timeout(_legacyTimeout); if (service.statusCode == 200) { final data = service.result['result'] ?? service.result; topBanner = TopBannerModel.fromJson(data); notifyListeners(); } } on TimeoutException { debugPrint('⏱️ topBanner legacy timeout'); } catch (e) { debugPrint('❌ topBanner legacy failed: $e'); } } DidvanVoiceModel? _pickLatestVoice(List 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 _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, }) { link = link ?? ''; final context = navigatorKey.currentContext; if (context == null) 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, '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, '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 '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); } }