diff --git a/.env.example b/.env.example deleted file mode 100644 index 3fbca7a..0000000 --- a/.env.example +++ /dev/null @@ -1 +0,0 @@ -FOREX_RATE_API_KEY=REPLACE_WITH_YOUR_FOREXRATE_API_KEY \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index 6a7f085..0fd33d5 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -7,28 +7,19 @@ { "name": "rental_income_tracker", "request": "launch", - "type": "dart", - "toolArgs": [ - "--dart-define-from-file=.env" - ] + "type": "dart" }, { "name": "rental_income_tracker (profile mode)", "request": "launch", "type": "dart", - "flutterMode": "profile", - "toolArgs": [ - "--dart-define-from-file=.env" - ] + "flutterMode": "profile" }, { "name": "rental_income_tracker (release mode)", "request": "launch", "type": "dart", - "flutterMode": "release", - "toolArgs": [ - "--dart-define-from-file=.env" - ] + "flutterMode": "release" } ] } \ No newline at end of file diff --git a/Makefile b/Makefile index 3004346..a9a0cc5 100644 --- a/Makefile +++ b/Makefile @@ -3,4 +3,4 @@ build: release release: - flutter build apk --release --dart-define-from-file=.env + flutter build apk --release diff --git a/README.md b/README.md index 89ca007..9f69e44 100644 --- a/README.md +++ b/README.md @@ -23,12 +23,6 @@ Simple Flutter app to track US rental income and convert to SEK for Swedish tax ## Setup -1. Create `.env` in the project root with your FX API key: - -```env -FOREX_RATE_API_KEY=REPLACE_WITH_YOUR_FOREXRATE_API_KEY -``` - 1. Install dependencies: ```bash @@ -38,7 +32,7 @@ flutter pub get 1. Run: ```bash -flutter run --dart-define-from-file=.env +flutter run ``` 1. Build release APK: diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index d77d610..615490f 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -198,7 +198,7 @@ class _SettingsScreenState extends State { ), const SizedBox(height: 16), const Text( - 'ForexRateAPI key is loaded from FOREX_RATE_API_KEY via --dart-define-from-file=.env.', + 'Using Frankfurter exchange rates (no API key required).', ), ], ), diff --git a/lib/services/open_exchange_service.dart b/lib/services/open_exchange_service.dart index 281f535..3e28fdb 100644 --- a/lib/services/open_exchange_service.dart +++ b/lib/services/open_exchange_service.dart @@ -5,18 +5,17 @@ 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}); +class ConversionResult { + ConversionResult({required this.quote, required this.result}); final double quote; final double result; } -class ForexRateApiService { - ForexRateApiService({required this.httpClient, required this.apiKey}); +class FrankfurterApiService { + FrankfurterApiService({required this.httpClient}); final http.Client httpClient; - final String apiKey; void _log(String message) { if (kDebugMode) { @@ -24,13 +23,7 @@ class ForexRateApiService { } } - String _redactedUri(Uri uri) { - final redactedQuery = {...uri.queryParameters}; - if (redactedQuery.containsKey('api_key')) { - redactedQuery['api_key'] = '***REDACTED***'; - } - return uri.replace(queryParameters: redactedQuery).toString(); - } + String _redactedUri(Uri uri) => uri.toString(); bool _isRateLimitedResponse(http.Response response) { if (response.statusCode == 429) { @@ -40,8 +33,8 @@ class ForexRateApiService { try { final parsed = jsonDecode(response.body) as Map; final error = parsed['error'] as Map?; - final code = error?['code']?.toString().toLowerCase(); - return code == '104' || code == 'rate_limit_reached'; + final statusCode = error?['statusCode']?.toString(); + return statusCode == '429'; } catch (_) { return false; } @@ -51,18 +44,17 @@ class ForexRateApiService { return DateTime.utc(value.year, value.month, value.day); } - DateTime _latestHistoricalDateUtc() { + DateTime _todayUtcDate() { final nowUtc = DateTime.now().toUtc(); - final todayUtcDate = DateTime.utc(nowUtc.year, nowUtc.month, nowUtc.day); - return todayUtcDate.subtract(const Duration(days: 1)); + return DateTime.utc(nowUtc.year, nowUtc.month, nowUtc.day); } DateTime _normalizeHistoricalDate(DateTime requestedDate) { final requestedUtcDate = _asUtcDate(requestedDate); - final latestAllowed = _latestHistoricalDateUtc(); + final latestAllowed = _todayUtcDate(); if (requestedUtcDate.isAfter(latestAllowed)) { _log( - 'Historical date ${DateFormat('yyyy-MM-dd').format(requestedUtcDate)} is not available yet; clamping to ${DateFormat('yyyy-MM-dd').format(latestAllowed)}.', + 'Requested date ${DateFormat('yyyy-MM-dd').format(requestedUtcDate)} is in the future; clamping to ${DateFormat('yyyy-MM-dd').format(latestAllowed)}.', ); return latestAllowed; } @@ -96,37 +88,25 @@ class ForexRateApiService { } Future fetchUsdToSekRate({required DateTime estDate}) async { - if (apiKey == 'REPLACE_WITH_YOUR_FOREXRATE_API_KEY') { - throw StateError('ForexRateAPI key is not configured.'); - } - final effectiveDate = _normalizeHistoricalDate(estDate); final date = DateFormat('yyyy-MM-dd').format(effectiveDate); - final uri = Uri.https('api.forexrateapi.com', '/v1/$date', { - 'api_key': apiKey, + final uri = Uri.https('api.frankfurter.dev', '/v1/$date', { 'base': 'USD', - 'currencies': 'SEK', + 'symbols': 'SEK', }); _log('Daily rate request: ${_redactedUri(uri)}'); final response = await _getWithRateLimitRetry(uri, operation: 'Daily rate'); if (response.statusCode != 200) { - throw StateError('ForexRateAPI request failed (${response.statusCode}).'); + throw StateError('Frankfurter 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('ForexRateAPI returned success=false. error=$error'); - } - final rates = json['rates'] as Map?; final sek = rates?['SEK']; if (sek == null) { - throw StateError('SEK rate was missing in API response.'); + throw StateError('SEK rate was missing in Frankfurter response.'); } _log('Daily rate parsed SEK quote: $sek'); @@ -134,14 +114,10 @@ class ForexRateApiService { return (sek as num).toDouble(); } - Future convertUsdToSek({ + Future convertUsdToSek({ required DateTime estDate, required double amount, }) async { - if (apiKey == 'REPLACE_WITH_YOUR_FOREXRATE_API_KEY') { - throw StateError('ForexRateAPI key is not configured.'); - } - final quote = await fetchUsdToSekRate(estDate: estDate); final result = quote * amount; @@ -149,52 +125,35 @@ class ForexRateApiService { 'Computed conversion from daily rate quote=$quote result=$result amount=$amount date=${DateFormat('yyyy-MM-dd').format(estDate)}', ); - return ForexConversionResult(quote: quote, result: result); + return ConversionResult(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. + /// 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 { - if (apiKey == 'REPLACE_WITH_YOUR_FOREXRATE_API_KEY') { - throw StateError('ForexRateAPI key is not configured.'); - } - final start = DateTime(year, 1, 1); final end = DateTime(year, 12, 31); - final uri = - Uri.https('api.forexrateapi.com', '/v1/timeframe', { - 'api_key': apiKey, - 'base': 'USD', - 'currencies': 'SEK', - 'start_date': DateFormat('yyyy-MM-dd').format(start), - 'end_date': DateFormat('yyyy-MM-dd').format(end), - }); + 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('Timeframe request: ${_redactedUri(uri)}'); + _log('Range request: ${_redactedUri(uri)}'); - final response = await _getWithRateLimitRetry(uri, operation: 'Timeframe'); + final response = await _getWithRateLimitRetry(uri, operation: 'Range'); if (response.statusCode != 200) { _log( - 'Timeframe endpoint unavailable for this account or request; falling back to per-day requests. status=${response.statusCode}', + 'Range endpoint unavailable; falling back to per-day requests. status=${response.statusCode}', ); return null; } final json = jsonDecode(response.body) as Map; - final success = json['success'] as bool? ?? false; - if (!success) { - _log( - 'Timeframe success=false; falling back to per-day requests. error=${json['error']}', - ); - return null; - } - final rates = json['rates'] as Map?; if (rates == null) { - _log( - 'Timeframe response missing rates; falling back to per-day requests.', - ); + _log('Range response missing rates; falling back to per-day requests.'); return null; } @@ -208,13 +167,11 @@ class ForexRateApiService { } if (results.isEmpty) { - _log( - 'Timeframe parsed no SEK entries; falling back to per-day requests.', - ); + _log('Range parsed no SEK entries; falling back to per-day requests.'); return null; } - _log('Timeframe parsed ${results.length} daily SEK quotes.'); + _log('Range parsed ${results.length} daily SEK quotes.'); return results; } } diff --git a/lib/state/rent_controller.dart b/lib/state/rent_controller.dart index c44432f..f483685 100644 --- a/lib/state/rent_controller.dart +++ b/lib/state/rent_controller.dart @@ -41,35 +41,17 @@ class RentController extends ChangeNotifier { RentController({ StorageService? storageService, - ForexRateApiService? exchangeService, + FrankfurterApiService? exchangeService, NotificationService? notificationService, }) : _storageService = storageService ?? StorageService(), _exchangeService = - exchangeService ?? - ForexRateApiService( - httpClient: http.Client(), - apiKey: _exchangeRateApiKeyFromEnv(), - ) { + exchangeService ?? FrankfurterApiService(httpClient: http.Client()) { _notificationService = notificationService ?? NotificationService(_onNotificationAction); } - static String _exchangeRateApiKeyFromEnv() { - const key = String.fromEnvironment('FOREX_RATE_API_KEY'); - final trimmed = key.trim(); - if (trimmed.isEmpty) { - if (kDebugMode) { - debugPrint( - '[RentController] FOREX_RATE_API_KEY missing from dart defines. Pass --dart-define-from-file=.env (or --dart-define) when building/running.', - ); - } - return 'REPLACE_WITH_YOUR_FOREXRATE_API_KEY'; - } - return trimmed; - } - final StorageService _storageService; - final ForexRateApiService _exchangeService; + final FrankfurterApiService _exchangeService; late NotificationService _notificationService; AppSettings _settings = AppSettings.defaults(); @@ -79,6 +61,33 @@ class RentController extends ChangeNotifier { String? _errorMessage; int _selectedYear = DateTime.now().year; + double? _lookupRateOnOrBefore( + Map rates, + DateTime targetDate, + ) { + final target = DateTime(targetDate.year, targetDate.month, targetDate.day); + DateTime? nearestDate; + double? nearestRate; + + for (final entry in rates.entries) { + final parsed = DateTime.tryParse(entry.key); + if (parsed == null) { + continue; + } + final day = DateTime(parsed.year, parsed.month, parsed.day); + if (day.isAfter(target)) { + continue; + } + + if (nearestDate == null || day.isAfter(nearestDate)) { + nearestDate = day; + nearestRate = entry.value; + } + } + + return nearestRate; + } + AppSettings get settings => _settings; bool get isLoading => _isLoading; String? get errorMessage => _errorMessage; @@ -334,27 +343,25 @@ class RentController extends ChangeNotifier { final usd = _settings.rentUsd; final estDate = DateTime(year, month, 1); - final ForexConversionResult conversion; + final ConversionResult conversion; if (batchRates != null) { - final dateStr = DateFormat('yyyy-MM-dd').format(estDate); - final quote = batchRates[dateStr]; + final quote = _lookupRateOnOrBefore(batchRates, estDate); if (quote == null) { - throw StateError('Batch rates missing entry for $dateStr.'); + throw StateError( + 'Batch rates missing entry on or before ${DateFormat('yyyy-MM-dd').format(estDate)}.', + ); } - conversion = ForexConversionResult( - quote: quote, - result: quote * usd, - ); + conversion = ConversionResult(quote: quote, result: quote * usd); } else { // Pace individual requests to avoid triggering the rate limit. if (!isFirstIndividualFetch) { await Future.delayed(const Duration(milliseconds: 700)); } isFirstIndividualFetch = false; - conversion = await _exchangeService.convertUsdToSek( + final quote = await _exchangeService.fetchUsdToSekRate( estDate: estDate, - amount: usd, ); + conversion = ConversionResult(quote: quote, result: quote * usd); } if (kDebugMode) { @@ -385,9 +392,7 @@ class RentController extends ChangeNotifier { savedMonths++; } catch (err) { final errorText = err.toString(); - if (errorText.contains('429') || - errorText.contains('104') || - errorText.toLowerCase().contains('rate_limit_reached')) { + if (errorText.contains('429')) { pausedByRateLimit = true; _errorMessage = 'Backfill paused by API rate limits after $savedMonths new month(s). Run backfill again in a minute to continue.'; diff --git a/test/backup_restore_compatibility_test.dart b/test/backup_restore_compatibility_test.dart index 2c49dfb..71e6fcc 100644 --- a/test/backup_restore_compatibility_test.dart +++ b/test/backup_restore_compatibility_test.dart @@ -31,18 +31,18 @@ class _FakeStorageService extends StorageService { } } -class _FakeExchangeService extends ForexRateApiService { +class _FakeExchangeService extends FrankfurterApiService { _FakeExchangeService({required this.quote}) : super(httpClient: http.Client(), apiKey: 'test-key'); final double quote; @override - Future convertUsdToSek({ + Future convertUsdToSek({ required DateTime estDate, required double amount, }) async { - return ForexConversionResult(quote: quote, result: quote * amount); + return ConversionResult(quote: quote, result: quote * amount); } } diff --git a/test/notification_scheduling_test.dart b/test/notification_scheduling_test.dart index ae68875..3e5d0a6 100644 --- a/test/notification_scheduling_test.dart +++ b/test/notification_scheduling_test.dart @@ -29,15 +29,15 @@ class _FakeStorageService extends StorageService { } } -class _FakeExchangeService extends ForexRateApiService { +class _FakeExchangeService extends FrankfurterApiService { _FakeExchangeService() : super(httpClient: http.Client(), apiKey: 'test-key'); @override - Future convertUsdToSek({ + Future convertUsdToSek({ required DateTime estDate, required double amount, }) async { - return ForexConversionResult(quote: 10, result: amount * 10); + return ConversionResult(quote: 10, result: amount * 10); } }