import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:intl/intl.dart'; class ForexConversionResult { ForexConversionResult({required this.quote, required this.result}); final double quote; final double result; } class ForexRateApiService { ForexRateApiService({required this.httpClient, required this.apiKey}); final http.Client httpClient; final String apiKey; void _log(String message) { if (kDebugMode) { debugPrint('[ForexRateApiService] $message'); } } String _redactedUri(Uri uri) { final redactedQuery = {...uri.queryParameters}; if (redactedQuery.containsKey('access_key')) { redactedQuery['access_key'] = '***REDACTED***'; } return uri.replace(queryParameters: redactedQuery).toString(); } bool _isRateLimitedResponse(http.Response response) { if (response.statusCode == 429) { return true; } try { final parsed = jsonDecode(response.body) as Map; final error = parsed['error'] as Map?; final code = error?['code']?.toString().toLowerCase(); return code == 'rate_limit_reached'; } catch (_) { return false; } } Future _getWithRateLimitRetry( Uri uri, { required String operation, }) async { const maxAttempts = 3; for (var attempt = 1; attempt <= maxAttempts; attempt++) { final response = await httpClient.get(uri); _log('$operation HTTP status: ${response.statusCode}'); _log('$operation raw response: ${response.body}'); final shouldRetry = _isRateLimitedResponse(response); if (!shouldRetry || attempt == maxAttempts) { return response; } final delayMs = 1200 * attempt; _log( '$operation rate limited; retrying in ${delayMs}ms (attempt ${attempt + 1}/$maxAttempts)', ); await Future.delayed(Duration(milliseconds: delayMs)); } throw StateError('Unexpected rate-limit retry flow for $operation.'); } Future fetchUsdToSekRate({required DateTime estDate}) async { if (apiKey == 'REPLACE_WITH_YOUR_EXCHANGE_RATE_API_KEY') { throw StateError('ExchangeratesAPI key is not configured.'); } final date = DateFormat('yyyy-MM-dd').format(estDate); final uri = Uri.https( 'api.exchangeratesapi.io', '/v1/$date', // Free-tier plans can restrict custom base currencies. // We request EUR-based USD and SEK rates and derive USD->SEK via cross-rate. {'access_key': apiKey, 'symbols': 'USD,SEK'}, ); _log('Daily rate request: ${_redactedUri(uri)}'); final response = await _getWithRateLimitRetry(uri, operation: 'Daily rate'); if (response.statusCode != 200) { throw StateError( 'ExchangeratesAPI request failed (${response.statusCode}).', ); } final json = jsonDecode(response.body) as Map; final success = json['success'] as bool? ?? false; if (!success) { final error = json['error']; _log('Daily rate success=false. error=$error'); throw StateError('ExchangeratesAPI returned success=false. error=$error'); } final rates = json['rates'] as Map?; final usdPerEur = rates?['USD']; final sekPerEur = rates?['SEK']; if (usdPerEur == null || sekPerEur == null) { throw StateError('USD/SEK rates were missing in API response.'); } final usdPerEurValue = (usdPerEur as num).toDouble(); final sekPerEurValue = (sekPerEur as num).toDouble(); if (usdPerEurValue == 0) { throw StateError('USD rate was zero; cannot derive USD->SEK.'); } final usdToSek = sekPerEurValue / usdPerEurValue; _log( 'Daily rate parsed usdPerEur=$usdPerEurValue sekPerEur=$sekPerEurValue derivedUsdToSek=$usdToSek', ); return usdToSek; } Future convertUsdToSek({ required DateTime estDate, required double amount, }) async { if (apiKey == 'REPLACE_WITH_YOUR_EXCHANGE_RATE_API_KEY') { throw StateError('ExchangeratesAPI key is not configured.'); } final quote = await fetchUsdToSekRate(estDate: estDate); final result = quote * amount; _log( 'Computed conversion from daily rate quote=$quote result=$result amount=$amount date=${DateFormat('yyyy-MM-dd').format(estDate)}', ); return ForexConversionResult(quote: quote, result: result); } /// Fetches USD->SEK cross-rates for every day of [year] in a single timeseries /// API call. Returns a map of 'yyyy-MM-dd' -> rate, or null if the timeseries /// endpoint is not available on the current subscription plan. Future?> tryFetchYearRates(int year) async { if (apiKey == 'REPLACE_WITH_YOUR_EXCHANGE_RATE_API_KEY') { throw StateError('ExchangeratesAPI key is not configured.'); } final uri = Uri.https('api.exchangeratesapi.io', '/v1/timeseries', { 'access_key': apiKey, 'start_date': '$year-01-01', 'end_date': '$year-12-31', 'symbols': 'USD,SEK', }); _log('Timeseries request: ${_redactedUri(uri)}'); final response = await _getWithRateLimitRetry(uri, operation: 'Timeseries'); if (response.statusCode == 403) { _log( 'Timeseries endpoint not available on current plan; falling back to daily requests.', ); return null; } if (response.statusCode != 200) { throw StateError( 'ExchangeratesAPI timeseries failed (${response.statusCode}).', ); } final json = jsonDecode(response.body) as Map; final success = json['success'] as bool? ?? false; if (!success) { final error = json['error'] as Map?; final code = error?['code']?.toString() ?? ''; if (code == 'function_access_restricted' || code == 'subscription_plan_not_support_endpoint') { _log( 'Timeseries endpoint not available on current plan; falling back to daily requests.', ); return null; } _log('Timeseries success=false. error=$error'); throw StateError( 'ExchangeratesAPI timeseries returned success=false. error=$error', ); } final rates = json['rates'] as Map?; if (rates == null) { throw StateError('Timeseries response missing rates object.'); } final result = {}; for (final entry in rates.entries) { final date = entry.key; final dayRates = entry.value as Map?; final usdPerEur = (dayRates?['USD'] as num?)?.toDouble(); final sekPerEur = (dayRates?['SEK'] as num?)?.toDouble(); if (usdPerEur != null && sekPerEur != null && usdPerEur != 0) { result[date] = sekPerEur / usdPerEur; } } _log('Timeseries parsed ${result.length} rate entries for year $year'); return result; } }