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

270 lines
8.7 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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.

// lib/services/market/market_service.dart
//
// سرویس برای endpoint بازار (market-service) در API gateway جدید:
// - GET /market (paginated) → لیست symbolها با داده‌ی OHLCV.
//
// داده‌ی خام Hlocvt را به مدل قدیمی NewStatisticModel (CategoryList/Content/
// Data) map می‌کنیم تا UI نبض صنعت بدون تغییر کار کند.
import 'dart:async';
import 'dart:convert';
import 'dart:developer' as dev;
import 'dart:io' show SocketException;
// ignore: depend_on_referenced_packages
import 'package:http/http.dart' as http;
import 'package:didvan/config/auth_config.dart';
import 'package:didvan/models/new_statistic/new_statistics_model.dart';
import 'package:didvan/services/auth/auth_interceptor.dart';
import 'package:didvan/services/auth/token_storage.dart';
class MarketServiceResult {
final bool isSuccess;
final int? statusCode;
final List<CategoryList>? lists;
final String? errorMessage;
const MarketServiceResult({
required this.isSuccess,
this.statusCode,
this.lists,
this.errorMessage,
});
}
/// نتیجه‌ی دریافت آیتم‌های خام بازار (برای صفحه‌ی «همه»).
class MarketItemsResult {
final bool isSuccess;
final int? statusCode;
final List<Map<String, dynamic>>? items;
final String? errorMessage;
const MarketItemsResult({
required this.isSuccess,
this.statusCode,
this.items,
this.errorMessage,
});
}
class MarketService {
MarketService._();
static final MarketService instance = MarketService._();
String get _base => AuthConfig.apiBaseUrl;
static const _timeout = Duration(seconds: 30);
/// دریافت داده‌ی بازار و map به CategoryList ها.
Future<MarketServiceResult> getMarketData({int limit = 50}) async {
await AuthInterceptor.ensureFreshToken();
try {
final uri = Uri.parse('$_base/market').replace(queryParameters: {
'limit': limit.toString(),
'page': '1',
});
final response = await _withRetry(
() => http.get(uri, headers: _headers()).timeout(_timeout),
);
dev.log('GET /market -> status=${response.statusCode}, '
'bodyLen=${response.body.length}', name: 'MarketService');
if (response.statusCode == 200) {
final body = json.decode(response.body);
final List items = body is Map && body['data'] is List
? body['data'] as List
: (body is List ? body : const []);
dev.log('GET /market -> parsed ${items.length} items',
name: 'MarketService');
final contents = <Content>[];
for (final raw in items) {
if (raw is! Map) continue;
final c = _mapToContent(Map<String, dynamic>.from(raw));
if (c != null) contents.add(c);
}
// همه را در یک گروه "بازارها" قرار می‌دهیم. (بک‌اند جدید
// دسته‌بندی ندارد؛ در صورت افزوده شدن، اینجا گروه‌بندی می‌شود.)
final lists = <CategoryList>[
CategoryList(category: 1, header: 'بازارها', contents: contents),
];
return MarketServiceResult(
isSuccess: true,
statusCode: 200,
lists: lists,
);
}
return MarketServiceResult(
isSuccess: false,
statusCode: response.statusCode,
errorMessage:
_extractError(response.body) ?? 'خطا در دریافت داده‌ی بازار.',
);
} catch (e) {
dev.log('getMarketData error: $e', name: 'MarketService');
return const MarketServiceResult(
isSuccess: false,
errorMessage: 'خطا در ارتباط با سرور.',
);
}
}
/// دریافت آیتم‌های خام بازار (List از Map). برای صفحه‌ی «همه» که خودش
/// هر آیتم را به مدل GeneralItem map می‌کند.
Future<MarketItemsResult> getMarketItems({
int page = 1,
int limit = 100,
}) async {
await AuthInterceptor.ensureFreshToken();
try {
final uri = Uri.parse('$_base/market').replace(queryParameters: {
'limit': limit.toString(),
'page': page.toString(),
});
final response = await _withRetry(
() => http.get(uri, headers: _headers()).timeout(_timeout),
);
dev.log('GET /market (items) -> status=${response.statusCode}',
name: 'MarketService');
if (response.statusCode == 200) {
final body = json.decode(response.body);
final List items = body is Map && body['data'] is List
? body['data'] as List
: (body is List ? body : const []);
final mapped = items
.whereType<Map>()
.map((e) => Map<String, dynamic>.from(e))
.toList();
return MarketItemsResult(
isSuccess: true,
statusCode: 200,
items: mapped,
);
}
return MarketItemsResult(
isSuccess: false,
statusCode: response.statusCode,
errorMessage:
_extractError(response.body) ?? 'خطا در دریافت داده‌ی بازار.',
);
} catch (e) {
dev.log('getMarketItems error: $e', name: 'MarketService');
return const MarketItemsResult(
isSuccess: false,
errorMessage: 'خطا در ارتباط با سرور.',
);
}
}
/// تبدیل یک Hlocvt به Content مدل قدیمی.
/// آخرین قیمت = close.last، درصد تغییر از دو close آخر محاسبه می‌شود.
Content? _mapToContent(Map<String, dynamic> json) {
try {
final closeRaw = json['close'];
final closes = (closeRaw is List)
? closeRaw.map((e) => (e as num).toDouble()).toList()
: <double>[];
String price = '-';
num changePercent = 0;
String direction = 'none';
if (closes.isNotEmpty) {
final last = closes.last;
price = _formatNumber(last);
if (closes.length >= 2) {
final prev = closes[closes.length - 2];
if (prev != 0) {
final change = ((last - prev) / prev) * 100;
changePercent = double.parse(change.toStringAsFixed(2));
direction = change > 0 ? 'high' : (change < 0 ? 'low' : 'none');
}
}
}
final symbolId = (json['symbolId'] ?? json['id'] ?? '').toString();
// آخرین 10 قیمت برای نمودار کوچک.
final chartPrices =
closes.length > 10 ? closes.sublist(closes.length - 10) : closes;
return Content(
id: symbolId.hashCode,
label: symbolId,
title: (json['title'] ?? symbolId).toString(),
marked: false,
data: Data(
p: price,
dp: changePercent,
dt: direction,
prices: chartPrices,
),
);
} catch (e) {
dev.log('mapToContent error: $e', name: 'MarketService');
return null;
}
}
String _formatNumber(double v) {
// جداسازی سه‌رقمی ساده.
final s = v.toStringAsFixed(v.truncateToDouble() == v ? 0 : 2);
final parts = s.split('.');
final intPart = parts[0];
final buf = StringBuffer();
for (var i = 0; i < intPart.length; i++) {
if (i > 0 && (intPart.length - i) % 3 == 0) buf.write(',');
buf.write(intPart[i]);
}
return parts.length > 1 ? '${buf.toString()}.${parts[1]}' : buf.toString();
}
// ----------------- Helpers -----------------
Map<String, String> _headers() {
final headers = <String, String>{
'Accept': 'application/json',
'Content-Type': 'application/json',
};
final t = TokenStorage.accessToken;
if (t != null && t.isNotEmpty) headers['Authorization'] = 'Bearer $t';
return headers;
}
Future<T> _withRetry<T>(Future<T> Function() body) async {
for (var attempt = 0; attempt < 3; attempt++) {
try {
return await body();
} on SocketException {
if (attempt == 2) rethrow;
await Future.delayed(Duration(milliseconds: 250 * (attempt + 1)));
} on http.ClientException {
if (attempt == 2) rethrow;
await Future.delayed(Duration(milliseconds: 250 * (attempt + 1)));
} on TimeoutException {
if (attempt == 2) rethrow;
}
}
throw StateError('retry loop exited without return');
}
String? _extractError(String body) {
if (body.isEmpty) return null;
try {
final data = json.decode(body);
if (data is Map) {
final msg = data['message'] ?? data['error'] ?? data['msg'];
if (msg is String && msg.isNotEmpty) return msg;
if (msg is List && msg.isNotEmpty) return msg.first.toString();
}
} catch (_) {}
return null;
}
}