380 lines
13 KiB
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]}' : ''}';
|
|
}
|
|
}
|