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

783 lines
25 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/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<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'
),
];
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(),
];
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<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';
Future<void> _loadExploreLists() async {
final lists = <MainPageList>[];
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')],
);
final items =
(res.isSuccess ? res.data?.data : null) ?? const <ContentItem>[];
if (items.isEmpty) continue;
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<void> _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 <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';
default:
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] = ؛
// subtitles[1] = خلاصه.
subtitles = [
CountryLabels.label(c.metaHeadquarter),
c.summary ?? '',
];
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;
default:
subtitles = [c.summary ?? ''];
}
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),
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) {
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<void> _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<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;
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<void> _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 <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 (!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 (!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');
}
}
// TODO(backend-migration): `api.didvan.app/topBanner` فعلا در دسترس نیست.
// پس از پیاده‌سازی endpoint معادل در api2.didvan.com، این تابع را به آن
// متصل کنید.
Future<void> _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<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,
}) {
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);
}
}