import 'dart:async'; import 'dart:convert'; import 'dart:io' show SocketException; import 'package:didvan/config/statista_config.dart'; import 'package:didvan/models/new_statistic/new_statistics_model.dart'; import 'package:http/http.dart' as http; class StatistaMarketResult { final bool isSuccess; final List categories; final DateTime? generatedAt; final String? errorMessage; const StatistaMarketResult({ required this.isSuccess, this.categories = const [], this.generatedAt, this.errorMessage, }); } class StatistaMarketService { StatistaMarketService._(); static final instance = StatistaMarketService._(); static const _timeout = Duration(seconds: 30); Future getLatestPrices() async { if (StatistaConfig.apiToken.isEmpty) { return const StatistaMarketResult( isSuccess: false, errorMessage: 'توکن سرویس بازار تنظیم نشده است.', ); } try { final response = await http.get( Uri.parse('${StatistaConfig.baseUrl}/api/v1/prices/latest'), headers: { 'Accept': 'application/json', 'Authorization': 'Bearer ${StatistaConfig.apiToken}', }, ).timeout(_timeout); if (response.statusCode != 200) { return StatistaMarketResult( isSuccess: false, errorMessage: response.statusCode == 401 ? 'دسترسی به سرویس بازار نامعتبر است.' : 'دریافت داده‌های بازار با خطا مواجه شد (${response.statusCode}).', ); } final decoded = jsonDecode(utf8.decode(response.bodyBytes)); if (decoded is! Map) { return const StatistaMarketResult( isSuccess: false, errorMessage: 'ساختار پاسخ سرویس بازار معتبر نیست.', ); } final root = Map.from(decoded); final rawDatasets = root['datasets']; if (rawDatasets is! Map) { return const StatistaMarketResult( isSuccess: false, errorMessage: 'دیتاستی در پاسخ سرویس بازار یافت نشد.', ); } final datasets = Map.from(rawDatasets); return StatistaMarketResult( isSuccess: true, generatedAt: DateTime.tryParse(root['generated_at']?.toString() ?? ''), categories: _mapDatasets(datasets), ); } on TimeoutException { return const StatistaMarketResult( isSuccess: false, errorMessage: 'زمان دریافت داده‌های بازار به پایان رسید.', ); } on SocketException { return const StatistaMarketResult( isSuccess: false, errorMessage: 'اتصال اینترنت را بررسی کنید.', ); } on FormatException { return const StatistaMarketResult( isSuccess: false, errorMessage: 'پاسخ سرویس بازار قابل پردازش نیست.', ); } catch (_) { return const StatistaMarketResult( isSuccess: false, errorMessage: 'خطایی در دریافت نبض صنعت رخ داد.', ); } } List _mapDatasets(Map data) { final categories = [ _category(1, 'فلزات و فولاد', data['metals'], _metal), _category(2, 'کامودیتی زنده', data['live_commodities'], _commodity), _category(3, 'ارز', data['currency'], _tgju), _category(4, 'طلا و نقره', data['gold'], _tgju), _category(5, 'سکه', data['coin'], _tgju), _category(6, 'کامودیتی جهانی', data['commodity'], _commodity), _category(7, 'سهام فولادی جهان', data['steel_stocks'], _steelStock), _category(8, 'ارز دیجیتال', _cryptoCoins(data['crypto']), _crypto), _category(9, 'اقتصاد جهان', data['world_economy'], _economy), ]; return categories.where((item) => item.contents.isNotEmpty).toList(); } dynamic _cryptoCoins(dynamic raw) => raw is Map ? raw['coins'] : null; CategoryList _category( int id, String title, dynamic raw, Content? Function(Map, int) mapper, ) { final rows = raw is List ? raw : const []; final contents = []; for (var index = 0; index < rows.length; index++) { if (rows[index] is! Map) continue; final item = mapper(Map.from(rows[index]), index); if (item != null) contents.add(item); } return CategoryList(category: id, header: title, contents: contents); } Content? _metal(Map row, int index) => _content( id: _id(row['title'], index), label: row['category'], title: row['title'], value: row['mid'], percent: row['pct'], subtitle: _range(row['low'], row['high']), imageUrl: _steelForesightLogo, ); Content? _tgju(Map row, int index) => _content( id: _id(row['name'], index), label: row['dir'], title: row['name'], value: row['price'], percent: row['pct'], subtitle: row['change_val']?.toString(), imageUrl: _currencyLogo(row['name']?.toString()), ); Content? _commodity(Map row, int index) => _content( id: _id(row['symbol'] ?? row['name'], index), label: row['symbol'], title: row['name'] ?? row['title'] ?? row['symbol'], value: row['price'] ?? row['mid'] ?? row['close'], percent: row['pct'] ?? row['change_percent'], subtitle: row['currency']?.toString(), imageUrl: _yahooLogo, ); Content? _steelStock(Map row, int index) => _content( id: _id(row['code'], index), label: row['code'], title: row['name'], value: row['price'], percent: row['today_pct'], subtitle: row['country']?.toString(), imageUrl: _companyLogo(row['code']?.toString()), ); Content? _crypto(Map row, int index) => _content( id: _id(row['id'] ?? row['symbol'], index), label: row['symbol'], title: row['name'], value: row['price'], percent: row['pct_24h'], subtitle: row['rank'] == null ? null : 'رتبه ${row['rank']}', prices: _last24Hours(row['sparkline']), imageUrl: row['image']?.toString(), ); Content? _economy(Map row, int index) => _content( id: _id(row['country'], index), label: 'GDP', title: row['country'], value: row['gdp'], percent: row['gdp_growth'], subtitle: row['inflation_rate'] == null ? null : 'تورم ${_format(row['inflation_rate'])}٪', imageUrl: _countryFlag(row['country']?.toString()), ); Content? _content({ required int id, required dynamic title, dynamic label, dynamic value, dynamic percent, String? subtitle, dynamic prices, String? imageUrl, }) { final safeTitle = title?.toString().trim() ?? ''; if (safeTitle.isEmpty) return null; final pct = _number(percent); final suppliedChart = prices is List ? prices.map(_number).whereType().toList(growable: false) : const []; final chart = suppliedChart.length > 1 ? suppliedChart : _changeSeries(value, pct); return Content( id: id, label: label?.toString() ?? '', title: safeTitle, marked: false, subtitle: subtitle, imageUrl: imageUrl, data: Data( p: _format(value), dp: pct ?? 0, dt: (pct ?? 0) > 0 ? 'high' : ((pct ?? 0) < 0 ? 'low' : 'none'), prices: chart, ), ); } int _id(dynamic value, int index) => '${value ?? ''}-$index'.hashCode; String? _range(dynamic low, dynamic high) { if (low == null || high == null) return null; return '${_format(low)} تا ${_format(high)}'; } double? _number(dynamic value) { if (value is num) return value.toDouble(); return double.tryParse(value?.toString().replaceAll(',', '') ?? ''); } List _changeSeries(dynamic value, double? percent) { final current = _number(value); if (current == null) return const []; if (percent == null || percent == 0 || percent == -100) { return [current, current]; } final previous = current / (1 + percent / 100); return [previous, current]; } List _last24Hours(dynamic raw) { if (raw is! List) return const []; // CoinGecko sparkline هفت‌روزه و ساعتی است. ۲۵ نقطه پایانی بازه // ۲۴ساعته را می‌سازند و با pct_24h هم‌خوان هستند. if (raw.length <= 25) return List.from(raw); return List.from(raw.skip(raw.length - 25)); } static const _sourceLogo = 'https://www.tgju.org/favicon.ico'; static const _steelForesightLogo = 'https://statista.steelforesight.ir/favicon.ico'; static const _yahooLogo = 'https://s.yimg.com/cv/apiv2/default/20211027/logo_yahoo_finance_en-US_h_p_119x37.png'; String? _companyLogo(String? code) { if (code == null || code.isEmpty) return null; return 'https://companiesmarketcap.com/img/company-logos/64/$code.png'; } String? _currencyLogo(String? name) { if (name == null) return _sourceLogo; const flags = { // نام‌های اختصاصی باید قبل از «دلار» بررسی شوند. 'دلار کانادا': 'ca', 'دلار استرالیا': 'au', 'دلار نیوزلند': 'nz', 'دلار نیوزیلند': 'nz', 'دلار سنگاپور': 'sg', 'دلار هنگ کنگ': 'hk', 'کرون دانمارک': 'dk', 'کرون نروژ': 'no', 'کرون سوئد': 'se', 'بات تایلند': 'th', 'منات آذربایجان': 'az', 'درام ارمنستان': 'am', 'سوم قرقیزستان': 'kg', 'سامانی تاجیکستان': 'tj', 'منات ترکمنستان': 'tm', 'فرانک سوئیس': 'ch', 'فرانک سوییس': 'ch', 'فرانک سویس': 'ch', 'وون کره جنوبی': 'kr', 'لیر سوریه': 'sy', 'دینار کویت': 'kw', 'دینار بحرین': 'bh', 'رینگیت مالزی': 'my', 'لاری گرجستان': 'ge', 'سوئیس': 'ch', 'سوییس': 'ch', 'سویس': 'ch', 'کره جنوبی': 'kr', 'سوریه': 'sy', 'کویت': 'kw', 'بحرین': 'bh', 'مالزی': 'my', 'گرجستان': 'ge', 'دانمارک': 'dk', 'نروژ': 'no', 'سوئد': 'se', 'تایلند': 'th', 'آذربایجان': 'az', 'ارمنستان': 'am', 'قرقیزستان': 'kg', 'تاجیکستان': 'tj', 'ترکمنستان': 'tm', 'یورو': 'eu', 'پوند': 'gb', 'درهم': 'ae', 'لیر': 'tr', 'یوان': 'cn', 'ین': 'jp', 'روبل': 'ru', 'افغانی': 'af', 'روپیه هند': 'in', 'روپیه پاکستان': 'pk', 'ریال عربستان': 'sa', 'ریال قطر': 'qa', 'ریال عمان': 'om', 'دینار عراق': 'iq', 'دلار': 'us', }; for (final entry in flags.entries) { if (name.contains(entry.key)) { return 'https://flagcdn.com/w80/${entry.value}.png'; } } return _sourceLogo; } String? _countryFlag(String? country) { if (country == null) return null; const codes = { 'United States': 'us', 'China': 'cn', 'Japan': 'jp', 'Germany': 'de', 'India': 'in', 'United Kingdom': 'gb', 'France': 'fr', 'Italy': 'it', 'Canada': 'ca', 'South Korea': 'kr', 'Brazil': 'br', 'Australia': 'au', 'Russia': 'ru', 'Turkey': 'tr', 'Saudi Arabia': 'sa', 'Iran': 'ir', }; final code = codes[country]; return code == null ? null : 'https://flagcdn.com/w80/$code.png'; } String _format(dynamic value) { final number = _number(value); if (number == null) return value?.toString() ?? '—'; final decimals = number == number.truncateToDouble() ? 0 : 2; final parts = number.toStringAsFixed(decimals).split('.'); final negative = parts.first.startsWith('-'); final digits = negative ? parts.first.substring(1) : parts.first; final formatted = digits.replaceAllMapped( RegExp(r'\B(?=(\d{3})+(?!\d))'), (_) => ',', ); return '${negative ? '-' : ''}$formatted${parts.length > 1 ? '.${parts[1]}' : ''}'; } }