didvan-app/lib/services/market/statista_market_service.dart

380 lines
13 KiB
Dart

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<CategoryList> 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<StatistaMarketResult> 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<String, dynamic>.from(decoded);
final rawDatasets = root['datasets'];
if (rawDatasets is! Map) {
return const StatistaMarketResult(
isSuccess: false,
errorMessage: 'دیتاستی در پاسخ سرویس بازار یافت نشد.',
);
}
final datasets = Map<String, dynamic>.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<CategoryList> _mapDatasets(Map<String, dynamic> data) {
final categories = <CategoryList>[
_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<String, dynamic>, int) mapper,
) {
final rows = raw is List ? raw : const [];
final contents = <Content>[];
for (var index = 0; index < rows.length; index++) {
if (rows[index] is! Map) continue;
final item = mapper(Map<String, dynamic>.from(rows[index]), index);
if (item != null) contents.add(item);
}
return CategoryList(category: id, header: title, contents: contents);
}
Content? _metal(Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<double>().toList(growable: false)
: const <double>[];
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<double> _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<dynamic> _last24Hours(dynamic raw) {
if (raw is! List) return const [];
// CoinGecko sparkline هفت‌روزه و ساعتی است. ۲۵ نقطه پایانی بازه
// ۲۴ساعته را می‌سازند و با pct_24h هم‌خوان هستند.
if (raw.length <= 25) return List<dynamic>.from(raw);
return List<dynamic>.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 = <String, String>{
// نام‌های اختصاصی باید قبل از «دلار» بررسی شوند.
'دلار کانادا': '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 = <String, String>{
'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]}' : ''}';
}
}