import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:intl/intl.dart'; class ConversionResult { ConversionResult({required this.quote, required this.result}); final double quote; final double result; } class FrankfurterApiService { FrankfurterApiService({required this.httpClient}); final http.Client httpClient; void _log(String message) { if (kDebugMode) { debugPrint('[ForexRateApiService] $message'); } } String _redactedUri(Uri uri) => uri.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 statusCode = error?['statusCode']?.toString(); return statusCode == '429'; } catch (_) { return false; } } DateTime _asUtcDate(DateTime value) { return DateTime.utc(value.year, value.month, value.day); } DateTime _todayUtcDate() { final nowUtc = DateTime.now().toUtc(); return DateTime.utc(nowUtc.year, nowUtc.month, nowUtc.day); } DateTime _normalizeHistoricalDate(DateTime requestedDate) { final requestedUtcDate = _asUtcDate(requestedDate); final latestAllowed = _todayUtcDate(); if (requestedUtcDate.isAfter(latestAllowed)) { _log( 'Requested date ${DateFormat('yyyy-MM-dd').format(requestedUtcDate)} is in the future; clamping to ${DateFormat('yyyy-MM-dd').format(latestAllowed)}.', ); return latestAllowed; } return requestedUtcDate; } 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 { final effectiveDate = _normalizeHistoricalDate(estDate); final date = DateFormat('yyyy-MM-dd').format(effectiveDate); final uri = Uri.https('api.frankfurter.dev', '/v1/$date', { 'base': 'USD', 'symbols': 'SEK', }); _log('Daily rate request: ${_redactedUri(uri)}'); final response = await _getWithRateLimitRetry(uri, operation: 'Daily rate'); if (response.statusCode != 200) { throw StateError('Frankfurter request failed (${response.statusCode}).'); } final json = jsonDecode(response.body) as Map; final rates = json['rates'] as Map?; final sek = rates?['SEK']; if (sek == null) { throw StateError('SEK rate was missing in Frankfurter response.'); } _log('Daily rate parsed SEK quote: $sek'); return (sek as num).toDouble(); } Future convertUsdToSek({ required DateTime estDate, required double amount, }) async { 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 ConversionResult(quote: quote, result: result); } /// Fetches USD->SEK cross-rates for [year] using Frankfurter's date-range /// endpoint. Returns a map of 'yyyy-MM-dd' -> rate. Future?> tryFetchYearRates(int year) async { final start = DateTime(year, 1, 1); final end = DateTime(year, 12, 31); final rangePath = '/v1/${DateFormat('yyyy-MM-dd').format(start)}..${DateFormat('yyyy-MM-dd').format(end)}'; final uri = Uri.https('api.frankfurter.dev', rangePath, { 'base': 'USD', 'symbols': 'SEK', }); _log('Range request: ${_redactedUri(uri)}'); final response = await _getWithRateLimitRetry(uri, operation: 'Range'); if (response.statusCode != 200) { _log( 'Range endpoint unavailable; falling back to per-day requests. status=${response.statusCode}', ); return null; } final json = jsonDecode(response.body) as Map; final rates = json['rates'] as Map?; if (rates == null) { _log('Range response missing rates; falling back to per-day requests.'); return null; } final results = {}; for (final entry in rates.entries) { final dayRates = entry.value as Map?; final sek = dayRates?['SEK']; if (sek is num) { results[entry.key] = sek.toDouble(); } } if (results.isEmpty) { _log('Range parsed no SEK entries; falling back to per-day requests.'); return null; } _log('Range parsed ${results.length} daily SEK quotes.'); return results; } }