From 6a3b98e8fc10165a77783e6f420ecb2f39318dc2 Mon Sep 17 00:00:00 2001 From: etlami Date: Sat, 1 Aug 2026 10:05:06 +0200 Subject: [PATCH 1/4] feat(equipment): currency presets, a default currency, and correct symbols MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Equipment purchase price was hardcoded to a "$" prefix icon, the currency was free text with no presets, and the displayed price always used "$" regardless of the stored currency. - Add a shared currency util (core/utils/currency.dart): a curated list of common codes, code->symbol, and money formatting (via intl). - Add a diver-level default currency setting (Settings > Units > Default Currency), persisted in diver_settings via a v138 migration. - Equipment edit: the currency field is now an editable dropdown of common currencies (custom codes still allowed); new items default to the diver's currency; the price field's prefix shows the selected currency's symbol (EUR -> €, GBP -> £, ...) instead of a fixed "$". - Display: the detail page and the summary total now use the currency symbol, and the configurable-column price formatter uses the diver's default currency rather than a hardcoded "$". Adds tests: currency util, the v138 migration, and an equipment-edit test that the stored currency and its symbol show on the price field. --- lib/core/database/database.dart | 29 ++++++++- lib/core/utils/currency.dart | 48 +++++++++++++++ .../domain/constants/equipment_field.dart | 10 +-- .../pages/equipment_detail_page.dart | 6 +- .../pages/equipment_edit_page.dart | 56 +++++++++++++---- .../widgets/equipment_summary_widget.dart | 5 +- .../diver_settings_repository.dart | 3 + .../presentation/pages/settings_page.dart | 61 +++++++++++++++++++ .../providers/settings_providers.dart | 17 ++++++ lib/l10n/arb/app_en.arb | 2 + lib/l10n/arb/app_localizations.dart | 12 ++++ lib/l10n/arb/app_localizations_ar.dart | 6 ++ lib/l10n/arb/app_localizations_de.dart | 6 ++ lib/l10n/arb/app_localizations_en.dart | 6 ++ lib/l10n/arb/app_localizations_es.dart | 6 ++ lib/l10n/arb/app_localizations_fr.dart | 6 ++ lib/l10n/arb/app_localizations_he.dart | 6 ++ lib/l10n/arb/app_localizations_hu.dart | 6 ++ lib/l10n/arb/app_localizations_it.dart | 6 ++ lib/l10n/arb/app_localizations_nl.dart | 6 ++ lib/l10n/arb/app_localizations_pt.dart | 6 ++ lib/l10n/arb/app_localizations_zh.dart | 6 ++ .../migration_v138_default_currency_test.dart | 45 ++++++++++++++ test/core/utils/currency_test.dart | 42 +++++++++++++ .../equipment_edit_advanced_test.dart | 28 +++++++++ .../pages/settings_page_test.dart | 4 ++ .../presentation/pages/records_page_test.dart | 4 ++ test/helpers/mock_providers.dart | 4 ++ 28 files changed, 422 insertions(+), 20 deletions(-) create mode 100644 lib/core/utils/currency.dart create mode 100644 test/core/database/migration_v138_default_currency_test.dart create mode 100644 test/core/utils/currency_test.dart diff --git a/lib/core/database/database.dart b/lib/core/database/database.dart index f0362f094e..800fe5882d 100644 --- a/lib/core/database/database.dart +++ b/lib/core/database/database.dart @@ -1465,6 +1465,7 @@ class DiverSettings extends Table { TextColumn get altitudeUnit => text().withDefault(const Constant('meters'))(); TextColumn get sacUnit => text().withDefault(const Constant('litersPerMin'))(); + TextColumn get defaultCurrency => text().withDefault(const Constant('USD'))(); // Time/Date format settings TextColumn get timeFormat => text().withDefault(const Constant('twelveHour'))(); @@ -2854,7 +2855,7 @@ class AppDatabase extends _$AppDatabase { /// The current schema version as a static constant so that pre-open checks /// (e.g. version-mismatch guard) can reference it without an instance. - static const int currentSchemaVersion = 137; + static const int currentSchemaVersion = 138; /// Every schema version that has a migration block in onUpgrade. /// Used to calculate progress step counts. When adding a new migration, @@ -3021,6 +3022,8 @@ class AppDatabase extends _$AppDatabase { // v137: dives.weather_code, plus a one-time clear of the English weather // prose this app generated itself so it can be re-rendered localized. 137, + // v138: diver_settings.default_currency (default currency for priced items). + 138, ]; /// Idempotent DDL for the v106 connector-suggestion columns (Lightroom @@ -3910,6 +3913,22 @@ class AppDatabase extends _$AppDatabase { } } + /// Idempotent DDL for the v138 diver_settings.default_currency column. + /// Called from the v138 onUpgrade step and the beforeOpen backstop, and + /// self-guarding when the table is absent (minimal migration-test fixtures). + Future _assertDefaultCurrencyColumn() async { + final cols = await customSelect( + "PRAGMA table_info('diver_settings')", + ).get(); + if (cols.isEmpty) return; + final names = cols.map((c) => c.read('name')).toSet(); + if (!names.contains('default_currency')) { + await customStatement( + "ALTER TABLE diver_settings ADD COLUMN default_currency TEXT NOT NULL DEFAULT 'USD'", + ); + } + } + /// One-time clear of weather descriptions this app generated itself. /// /// Only rows whose weather_source is 'openMeteo' are touched -- those are @@ -7123,6 +7142,11 @@ class AppDatabase extends _$AppDatabase { await _clearGeneratedWeatherDescriptions(); } if (from < 137) await reportProgress(); + // v138: default currency for priced items (e.g. equipment). + if (from < 138) { + await _assertDefaultCurrencyColumn(); + } + if (from < 138) await reportProgress(); }, beforeOpen: (details) async { // Enable foreign keys @@ -7144,6 +7168,9 @@ class AppDatabase extends _$AppDatabase { // v137 backstop: re-assert dives.weather_code. await _assertWeatherCodeColumn(); + // v138 backstop: re-assert diver_settings.default_currency. + await _assertDefaultCurrencyColumn(); + // v106 backstop: re-assert connector-suggestion columns (the helper // is self-guarding when the suggestions table is absent). await _assertConnectorSuggestionColumns(); diff --git a/lib/core/utils/currency.dart b/lib/core/utils/currency.dart new file mode 100644 index 0000000000..1dbda1c7ab --- /dev/null +++ b/lib/core/utils/currency.dart @@ -0,0 +1,48 @@ +import 'package:intl/intl.dart'; + +/// Common currency codes offered as presets in pickers. Free-text entry still +/// allows any other ISO 4217 code. +const List kCommonCurrencyCodes = [ + 'USD', + 'EUR', + 'GBP', + 'CHF', + 'AUD', + 'CAD', + 'NZD', + 'JPY', + 'SEK', + 'NOK', + 'DKK', + 'THB', + 'EGP', + 'MXN', + 'IDR', + 'PHP', + 'ZAR', +]; + +/// The symbol for [currencyCode] (e.g. 'EUR' -> '€'), falling back to the +/// upper-cased code itself for anything intl doesn't recognise (or an empty +/// string for a blank code). +String currencySymbol(String currencyCode) { + final code = currencyCode.trim().toUpperCase(); + if (code.isEmpty) return ''; + try { + return NumberFormat.simpleCurrency(name: code).currencySymbol; + } catch (_) { + return code; + } +} + +/// Formats [amount] in [currencyCode] using the currency's symbol, falling back +/// to "CODE 12.34" for unrecognised codes. +String formatMoney(double amount, String currencyCode) { + final code = currencyCode.trim().toUpperCase(); + try { + return NumberFormat.simpleCurrency(name: code).format(amount); + } catch (_) { + final prefix = code.isEmpty ? '' : '$code '; + return '$prefix${amount.toStringAsFixed(2)}'; + } +} diff --git a/lib/features/equipment/domain/constants/equipment_field.dart b/lib/features/equipment/domain/constants/equipment_field.dart index 8a5d1e524e..b8d9e43a7a 100644 --- a/lib/features/equipment/domain/constants/equipment_field.dart +++ b/lib/features/equipment/domain/constants/equipment_field.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/utils/currency.dart'; import 'package:submersion/core/utils/unit_formatter.dart'; import 'package:submersion/features/equipment/domain/entities/equipment_item.dart'; import 'package:submersion/features/equipment/domain/entities/service_clock_status.dart'; @@ -257,7 +257,7 @@ class EquipmentFieldAdapter EquipmentField.status => (value as EquipmentStatus).displayName, EquipmentField.isActive => (value as bool) ? 'Yes' : 'No', EquipmentField.purchaseDate => units.formatDate(value as DateTime), - EquipmentField.purchasePrice => _formatPrice(value as double), + EquipmentField.purchasePrice => _formatPrice(value as double, units), EquipmentField.lastServiceDate => units.formatDate(value as DateTime), EquipmentField.nextServiceDue => units.formatDate(value as DateTime), EquipmentField.daysUntilService => _formatDaysUntilService(value as int), @@ -266,8 +266,10 @@ class EquipmentFieldAdapter }; } - String _formatPrice(double price) { - return NumberFormat.currency(symbol: r'$', decimalDigits: 2).format(price); + String _formatPrice(double price, UnitFormatter units) { + // No per-item currency in this configurable-column context; use the + // diver's default currency instead of a hardcoded '$'. + return formatMoney(price, units.settings.defaultCurrency); } String _formatDaysUntilService(int days) { diff --git a/lib/features/equipment/presentation/pages/equipment_detail_page.dart b/lib/features/equipment/presentation/pages/equipment_detail_page.dart index 9c2994c6aa..2b1c870b6e 100644 --- a/lib/features/equipment/presentation/pages/equipment_detail_page.dart +++ b/lib/features/equipment/presentation/pages/equipment_detail_page.dart @@ -4,6 +4,7 @@ import 'package:submersion/core/providers/provider.dart'; import 'package:go_router/go_router.dart'; import 'package:submersion/core/constants/list_view_mode.dart'; +import 'package:submersion/core/utils/currency.dart'; import 'package:submersion/core/utils/unit_formatter.dart'; import 'package:submersion/l10n/l10n_extension.dart'; import 'package:submersion/shared/widgets/master_detail/detail_scroll_retainer.dart'; @@ -604,7 +605,10 @@ class _EquipmentDetailContent extends ConsumerWidget { _buildDetailRow( context, context.l10n.equipment_detail_purchasePriceLabel, - '${equipment.purchasePrice!.toStringAsFixed(2)} ${equipment.purchaseCurrency}', + formatMoney( + equipment.purchasePrice!, + equipment.purchaseCurrency, + ), ), if (equipment.ownershipDuration != null) _buildDetailRow( diff --git a/lib/features/equipment/presentation/pages/equipment_edit_page.dart b/lib/features/equipment/presentation/pages/equipment_edit_page.dart index d180a80bb6..46aad3d489 100644 --- a/lib/features/equipment/presentation/pages/equipment_edit_page.dart +++ b/lib/features/equipment/presentation/pages/equipment_edit_page.dart @@ -3,6 +3,7 @@ import 'package:submersion/core/providers/provider.dart'; import 'package:go_router/go_router.dart'; import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/utils/currency.dart'; import 'package:submersion/core/utils/unit_formatter.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; import 'package:submersion/l10n/l10n_extension.dart'; @@ -56,6 +57,11 @@ class _EquipmentEditPageState extends ConsumerState { @override void initState() { super.initState(); + // New items start in the diver's default currency; existing items get + // their stored currency from _loadEquipment. + if (widget.equipmentId == null) { + _purchaseCurrencyController.text = ref.read(defaultCurrencyProvider); + } _nameController.addListener(_onFieldChanged); _brandController.addListener(_onFieldChanged); _modelController.addListener(_onFieldChanged); @@ -557,27 +563,51 @@ class _EquipmentEditPageState extends ConsumerState { ), const SizedBox(height: 16), Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( flex: 2, - child: TextFormField( - controller: _purchasePriceController, - decoration: InputDecoration( - labelText: context.l10n.equipment_edit_purchasePriceLabel, - prefixIcon: const Icon(Icons.attach_money), - ), - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - ), + // Rebuild the price field when the currency changes so its + // prefix shows the right symbol (€, $, £ ...). + child: ValueListenableBuilder( + valueListenable: _purchaseCurrencyController, + builder: (context, value, _) { + final symbol = currencySymbol(value.text); + return TextFormField( + controller: _purchasePriceController, + decoration: InputDecoration( + labelText: + context.l10n.equipment_edit_purchasePriceLabel, + prefixText: symbol.isEmpty ? null : '$symbol ', + ), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + ); + }, ), ), const SizedBox(width: 16), Expanded( - child: TextFormField( + // Editable dropdown: common currencies as presets, but any + // ISO code can still be typed. + child: DropdownMenu( controller: _purchaseCurrencyController, - decoration: InputDecoration( - labelText: context.l10n.equipment_edit_currencyLabel, - ), + expandedInsets: EdgeInsets.zero, + requestFocusOnTap: true, + enableFilter: true, + label: Text(context.l10n.equipment_edit_currencyLabel), + dropdownMenuEntries: [ + for (final code in kCommonCurrencyCodes) + DropdownMenuEntry( + value: code, + label: code, + leadingIcon: SizedBox( + width: 28, + child: Center(child: Text(currencySymbol(code))), + ), + ), + ], ), ), ], diff --git a/lib/features/equipment/presentation/widgets/equipment_summary_widget.dart b/lib/features/equipment/presentation/widgets/equipment_summary_widget.dart index fa9e9cfca1..5bf6e299a2 100644 --- a/lib/features/equipment/presentation/widgets/equipment_summary_widget.dart +++ b/lib/features/equipment/presentation/widgets/equipment_summary_widget.dart @@ -3,7 +3,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:submersion/core/accessibility/semantic_helpers.dart'; +import 'package:submersion/core/utils/currency.dart'; import 'package:submersion/features/equipment/presentation/providers/equipment_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; import 'package:submersion/l10n/l10n_extension.dart'; /// Summary widget shown when no equipment is selected. @@ -133,7 +135,8 @@ class EquipmentSummaryWidget extends ConsumerWidget { _buildStatCard( context, icon: Icons.attach_money, - value: '\$${totalValue.toStringAsFixed(0)}', + value: + '${currencySymbol(ref.watch(defaultCurrencyProvider))}${totalValue.toStringAsFixed(0)}', label: context.l10n.equipment_summary_totalValue, color: Colors.orange, ), diff --git a/lib/features/settings/data/repositories/diver_settings_repository.dart b/lib/features/settings/data/repositories/diver_settings_repository.dart index 7b28ebe4ed..ee9cbfa8f8 100644 --- a/lib/features/settings/data/repositories/diver_settings_repository.dart +++ b/lib/features/settings/data/repositories/diver_settings_repository.dart @@ -68,6 +68,7 @@ class DiverSettingsRepository { weightUnit: Value(s.weightUnit.name), altitudeUnit: Value(s.altitudeUnit.name), sacUnit: Value(s.sacUnit.name), + defaultCurrency: Value(s.defaultCurrency), timeFormat: Value(s.timeFormat.name), dateFormat: Value(s.dateFormat.name), themeMode: Value(_themeModeToString(s.themeMode)), @@ -216,6 +217,7 @@ class DiverSettingsRepository { weightUnit: Value(settings.weightUnit.name), altitudeUnit: Value(settings.altitudeUnit.name), sacUnit: Value(settings.sacUnit.name), + defaultCurrency: Value(settings.defaultCurrency), timeFormat: Value(settings.timeFormat.name), dateFormat: Value(settings.dateFormat.name), themeMode: Value(_themeModeToString(settings.themeMode)), @@ -406,6 +408,7 @@ class DiverSettingsRepository { weightUnit: _parseWeightUnit(row.weightUnit), altitudeUnit: _parseAltitudeUnit(row.altitudeUnit), sacUnit: _parseSacUnit(row.sacUnit), + defaultCurrency: row.defaultCurrency, timeFormat: _parseTimeFormat(row.timeFormat), dateFormat: _parseDateFormat(row.dateFormat), themeMode: _parseThemeMode(row.themeMode), diff --git a/lib/features/settings/presentation/pages/settings_page.dart b/lib/features/settings/presentation/pages/settings_page.dart index ff407543d1..7090c4134b 100644 --- a/lib/features/settings/presentation/pages/settings_page.dart +++ b/lib/features/settings/presentation/pages/settings_page.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:submersion/core/icons/mdi_icons.dart'; +import 'package:submersion/core/utils/currency.dart'; import 'package:submersion/core/constants/map_style.dart'; import 'package:submersion/core/deco/entities/cns_calculation_method.dart'; import 'package:submersion/core/providers/provider.dart'; @@ -463,6 +464,17 @@ class _UnitsSectionContent extends ConsumerWidget { onTap: () => _showSacUnitPicker(context, ref, settings.sacUnit), ), + const Divider(height: 1), + _buildUnitTile( + context, + title: context.l10n.settings_units_defaultCurrency, + value: settings.defaultCurrency, + onTap: () => _showCurrencyPicker( + context, + ref, + settings.defaultCurrency, + ), + ), ], ), ), @@ -808,6 +820,55 @@ class _UnitsSectionContent extends ConsumerWidget { ); } + void _showCurrencyPicker( + BuildContext context, + WidgetRef ref, + String currentCode, + ) { + final current = currentCode.trim().toUpperCase(); + final codes = [ + if (current.isNotEmpty && !kCommonCurrencyCodes.contains(current)) + current, + ...kCommonCurrencyCodes, + ]; + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(context.l10n.settings_units_dialog_defaultCurrency), + content: SizedBox( + width: double.maxFinite, + child: ListView( + shrinkWrap: true, + children: [ + for (final code in codes) + ListTile( + title: Text('$code ${currencySymbol(code)}'), + trailing: code == current + ? Icon( + Icons.check, + color: Theme.of(context).colorScheme.primary, + ) + : null, + onTap: () { + ref + .read(settingsProvider.notifier) + .setDefaultCurrency(code); + Navigator.of(dialogContext).pop(); + }, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(context.l10n.common_action_cancel), + ), + ], + ), + ); + } + void _showTimeFormatPicker( BuildContext context, WidgetRef ref, diff --git a/lib/features/settings/presentation/providers/settings_providers.dart b/lib/features/settings/presentation/providers/settings_providers.dart index bd6ed74bdf..2d8e527e59 100644 --- a/lib/features/settings/presentation/providers/settings_providers.dart +++ b/lib/features/settings/presentation/providers/settings_providers.dart @@ -50,6 +50,7 @@ class SettingsKeys { static const String volumeUnit = 'volume_unit'; static const String weightUnit = 'weight_unit'; static const String sacUnit = 'sac_unit'; + static const String defaultCurrency = 'default_currency'; static const String unitPreset = 'unit_preset'; static const String themeMode = 'theme_mode'; static const String displayZoom = 'display_zoom'; @@ -101,6 +102,10 @@ class AppSettings { final WeightUnit weightUnit; final AltitudeUnit altitudeUnit; final SacUnit sacUnit; + + /// ISO 4217 code used as the default currency for new priced items + /// (e.g. equipment purchase price). + final String defaultCurrency; final TimeFormat timeFormat; final DateFormatPreference dateFormat; final ThemeMode themeMode; @@ -384,6 +389,7 @@ class AppSettings { this.weightUnit = WeightUnit.kilograms, this.altitudeUnit = AltitudeUnit.meters, this.sacUnit = SacUnit.pressurePerMin, + this.defaultCurrency = 'USD', this.timeFormat = TimeFormat.twelveHour, this.dateFormat = DateFormatPreference.mmmDYYYY, this.themeMode = ThemeMode.system, @@ -531,6 +537,7 @@ class AppSettings { WeightUnit? weightUnit, AltitudeUnit? altitudeUnit, SacUnit? sacUnit, + String? defaultCurrency, TimeFormat? timeFormat, DateFormatPreference? dateFormat, ThemeMode? themeMode, @@ -644,6 +651,7 @@ class AppSettings { weightUnit: weightUnit ?? this.weightUnit, altitudeUnit: altitudeUnit ?? this.altitudeUnit, sacUnit: sacUnit ?? this.sacUnit, + defaultCurrency: defaultCurrency ?? this.defaultCurrency, timeFormat: timeFormat ?? this.timeFormat, dateFormat: dateFormat ?? this.dateFormat, themeMode: themeMode ?? this.themeMode, @@ -1040,6 +1048,11 @@ class SettingsNotifier extends StateNotifier { await _saveSettings(); } + Future setDefaultCurrency(String currencyCode) async { + state = state.copyWith(defaultCurrency: currencyCode.trim().toUpperCase()); + await _saveSettings(); + } + Future setAltitudeUnit(AltitudeUnit unit) async { state = state.copyWith(altitudeUnit: unit); await _saveSettings(); @@ -1652,6 +1665,10 @@ final sacUnitProvider = Provider((ref) { return ref.watch(settingsProvider.select((s) => s.sacUnit)); }); +final defaultCurrencyProvider = Provider((ref) { + return ref.watch(settingsProvider.select((s) => s.defaultCurrency)); +}); + final altitudeUnitProvider = Provider((ref) { return ref.watch(settingsProvider.select((s) => s.altitudeUnit)); }); diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 97db756c13..40175fb5ec 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -8329,6 +8329,8 @@ "settings_units_pressure_psi": "PSI", "settings_units_quickSelect": "Quick Select", "settings_units_sacRate": "SAC Rate", + "settings_units_defaultCurrency": "Default Currency", + "settings_units_dialog_defaultCurrency": "Default Currency", "settings_units_sac_pressurePerMinute": "Pressure per minute", "settings_units_sac_pressurePerMinute_subtitle": "No tank volume needed (bar/min or psi/min)", "settings_units_sac_volumePerMinute": "Volume per minute", diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index 1c073b7728..99926c15c7 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -25436,6 +25436,18 @@ abstract class AppLocalizations { /// **'SAC Rate'** String get settings_units_sacRate; + /// No description provided for @settings_units_defaultCurrency. + /// + /// In en, this message translates to: + /// **'Default Currency'** + String get settings_units_defaultCurrency; + + /// No description provided for @settings_units_dialog_defaultCurrency. + /// + /// In en, this message translates to: + /// **'Default Currency'** + String get settings_units_dialog_defaultCurrency; + /// No description provided for @settings_units_sac_pressurePerMinute. /// /// In en, this message translates to: diff --git a/lib/l10n/arb/app_localizations_ar.dart b/lib/l10n/arb/app_localizations_ar.dart index 2a1f3f822d..ee9575a9e4 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -14802,6 +14802,12 @@ class AppLocalizationsAr extends AppLocalizations { @override String get settings_units_sacRate => 'معدل SAC'; + @override + String get settings_units_defaultCurrency => 'Default Currency'; + + @override + String get settings_units_dialog_defaultCurrency => 'Default Currency'; + @override String get settings_units_sac_pressurePerMinute => 'الضغط في الدقيقة'; diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index afa9415148..1e0ae1088d 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -15056,6 +15056,12 @@ class AppLocalizationsDe extends AppLocalizations { @override String get settings_units_sacRate => 'AMV'; + @override + String get settings_units_defaultCurrency => 'Default Currency'; + + @override + String get settings_units_dialog_defaultCurrency => 'Default Currency'; + @override String get settings_units_sac_pressurePerMinute => 'Druck pro Minute'; diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index 47189a5e5f..4bae5b014e 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -14820,6 +14820,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settings_units_sacRate => 'SAC Rate'; + @override + String get settings_units_defaultCurrency => 'Default Currency'; + + @override + String get settings_units_dialog_defaultCurrency => 'Default Currency'; + @override String get settings_units_sac_pressurePerMinute => 'Pressure per minute'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index 64e65d0d90..55940cf74f 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -15075,6 +15075,12 @@ class AppLocalizationsEs extends AppLocalizations { @override String get settings_units_sacRate => 'SAC Rate'; + @override + String get settings_units_defaultCurrency => 'Default Currency'; + + @override + String get settings_units_dialog_defaultCurrency => 'Default Currency'; + @override String get settings_units_sac_pressurePerMinute => 'Presion por minuto'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index c69641ff99..74d55e0a78 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -15130,6 +15130,12 @@ class AppLocalizationsFr extends AppLocalizations { @override String get settings_units_sacRate => 'SAC Rate'; + @override + String get settings_units_defaultCurrency => 'Default Currency'; + + @override + String get settings_units_dialog_defaultCurrency => 'Default Currency'; + @override String get settings_units_sac_pressurePerMinute => 'Pression par minute'; diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index 55e3c2f4df..652a049819 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -14694,6 +14694,12 @@ class AppLocalizationsHe extends AppLocalizations { @override String get settings_units_sacRate => 'קצב SAC'; + @override + String get settings_units_defaultCurrency => 'Default Currency'; + + @override + String get settings_units_dialog_defaultCurrency => 'Default Currency'; + @override String get settings_units_sac_pressurePerMinute => 'לחץ לדקה'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index c2b1f2c4c3..5adcad05d7 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -15031,6 +15031,12 @@ class AppLocalizationsHu extends AppLocalizations { @override String get settings_units_sacRate => 'SAC ertek'; + @override + String get settings_units_defaultCurrency => 'Default Currency'; + + @override + String get settings_units_dialog_defaultCurrency => 'Default Currency'; + @override String get settings_units_sac_pressurePerMinute => 'Nyomas percenként'; diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index 3e000591c3..0853bd1289 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -15071,6 +15071,12 @@ class AppLocalizationsIt extends AppLocalizations { @override String get settings_units_sacRate => 'SAC Rate'; + @override + String get settings_units_defaultCurrency => 'Default Currency'; + + @override + String get settings_units_dialog_defaultCurrency => 'Default Currency'; + @override String get settings_units_sac_pressurePerMinute => 'Pressione al minuto'; diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index a1e2fda188..6d3cfb6cda 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -14948,6 +14948,12 @@ class AppLocalizationsNl extends AppLocalizations { @override String get settings_units_sacRate => 'SAC-snelheid'; + @override + String get settings_units_defaultCurrency => 'Default Currency'; + + @override + String get settings_units_dialog_defaultCurrency => 'Default Currency'; + @override String get settings_units_sac_pressurePerMinute => 'Druk per minuut'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index 8a24277317..02cfaacd7f 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -15082,6 +15082,12 @@ class AppLocalizationsPt extends AppLocalizations { @override String get settings_units_sacRate => 'Taxa SAC'; + @override + String get settings_units_defaultCurrency => 'Default Currency'; + + @override + String get settings_units_dialog_defaultCurrency => 'Default Currency'; + @override String get settings_units_sac_pressurePerMinute => 'Pressao por minuto'; diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index 83e7b2d17c..c69a9b67b1 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -14333,6 +14333,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get settings_units_sacRate => '气体消耗率'; + @override + String get settings_units_defaultCurrency => 'Default Currency'; + + @override + String get settings_units_dialog_defaultCurrency => 'Default Currency'; + @override String get settings_units_sac_pressurePerMinute => '压力/分钟'; diff --git a/test/core/database/migration_v138_default_currency_test.dart b/test/core/database/migration_v138_default_currency_test.dart new file mode 100644 index 0000000000..817b01fea5 --- /dev/null +++ b/test/core/database/migration_v138_default_currency_test.dart @@ -0,0 +1,45 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; + +/// Minimal pre-v138 shape: a diver_settings table with just a primary key, +/// stamped at v137 so only the 137->138 default_currency migration runs. +NativeDatabase _dbAt137() { + return NativeDatabase.memory( + setup: (rawDb) { + rawDb.execute('PRAGMA user_version = 137'); + rawDb.execute(''' + CREATE TABLE diver_settings ( + id TEXT NOT NULL PRIMARY KEY + ) + '''); + rawDb.execute("INSERT INTO diver_settings (id) VALUES ('settings')"); + }, + ); +} + +void main() { + test( + 'v138 adds default_currency to diver_settings, defaulting to USD', + () async { + final db = AppDatabase(_dbAt137()); + addTearDown(db.close); + + final cols = await db + .customSelect("PRAGMA table_info('diver_settings')") + .get(); + final names = cols.map((c) => c.read('name')).toSet(); + expect(names, contains('default_currency')); + + final rows = await db + .customSelect('SELECT default_currency FROM diver_settings') + .get(); + expect(rows.single.read('default_currency'), 'USD'); + }, + ); + + test('v138 default_currency migration is present', () { + expect(AppDatabase.currentSchemaVersion, greaterThanOrEqualTo(138)); + expect(AppDatabase.migrationVersions, contains(138)); + }); +} diff --git a/test/core/utils/currency_test.dart b/test/core/utils/currency_test.dart new file mode 100644 index 0000000000..3cb15cb18e --- /dev/null +++ b/test/core/utils/currency_test.dart @@ -0,0 +1,42 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/utils/currency.dart'; + +void main() { + group('currencySymbol', () { + test('maps common codes to their symbols', () { + expect(currencySymbol('USD'), r'$'); + expect(currencySymbol('EUR'), '€'); + expect(currencySymbol('GBP'), '£'); + }); + + test('is case-insensitive and trims whitespace', () { + expect(currencySymbol(' eur '), '€'); + }); + + test('blank code yields an empty symbol', () { + expect(currencySymbol(''), ''); + expect(currencySymbol(' '), ''); + }); + + test('unrecognised code falls back to the code itself', () { + expect(currencySymbol('ZZZ'), 'ZZZ'); + }); + }); + + group('formatMoney', () { + test('includes the currency symbol', () { + expect(formatMoney(12.5, 'EUR'), contains('€')); + expect(formatMoney(12.5, 'USD'), contains(r'$')); + }); + + test('unrecognised code still shows the code and amount', () { + final formatted = formatMoney(12.5, 'ZZZ'); + expect(formatted, contains('ZZZ')); + expect(formatted, contains('12.5')); + }); + }); + + test('kCommonCurrencyCodes covers the major currencies', () { + expect(kCommonCurrencyCodes, containsAll(['USD', 'EUR', 'GBP', 'CHF'])); + }); +} diff --git a/test/features/equipment/presentation/equipment_edit_advanced_test.dart b/test/features/equipment/presentation/equipment_edit_advanced_test.dart index 40f882189f..8a17985318 100644 --- a/test/features/equipment/presentation/equipment_edit_advanced_test.dart +++ b/test/features/equipment/presentation/equipment_edit_advanced_test.dart @@ -183,5 +183,33 @@ void main() { final saved = await repository.getEquipmentById(created.id); expect(saved!.buoyancyKg, isNull); }); + + testWidgets('shows the stored currency and its symbol on the price field', ( + tester, + ) async { + final created = await repository.createEquipment( + EquipmentItem( + id: '', + name: 'Regulator', + type: EquipmentType.regulator, + purchasePrice: 150, + purchaseCurrency: 'EUR', + ), + ); + await pumpEditor(tester, created.id); + + // Bring the purchase section into view. + final currencyField = find.byType(DropdownMenu); + await tester.scrollUntilVisible( + currencyField, + 300, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + + // Currency field shows the stored code; the price prefix shows its symbol. + expect(find.text('EUR'), findsWidgets); + expect(find.textContaining('€'), findsWidgets); + }); }); } diff --git a/test/features/settings/presentation/pages/settings_page_test.dart b/test/features/settings/presentation/pages/settings_page_test.dart index 3e6ee86966..bffd994021 100644 --- a/test/features/settings/presentation/pages/settings_page_test.dart +++ b/test/features/settings/presentation/pages/settings_page_test.dart @@ -83,6 +83,10 @@ class _MockSettingsNotifier extends StateNotifier @override Future setSacUnit(SacUnit unit) async => state = state.copyWith(sacUnit: unit); + + @override + Future setDefaultCurrency(String currencyCode) async => + state = state.copyWith(defaultCurrency: currencyCode); @override Future setAltitudeUnit(AltitudeUnit unit) async => state = state.copyWith(altitudeUnit: unit); diff --git a/test/features/statistics/presentation/pages/records_page_test.dart b/test/features/statistics/presentation/pages/records_page_test.dart index a3165e80a2..2adbb09fd8 100644 --- a/test/features/statistics/presentation/pages/records_page_test.dart +++ b/test/features/statistics/presentation/pages/records_page_test.dart @@ -73,6 +73,10 @@ class _MockSettingsNotifier extends StateNotifier @override Future setSacUnit(SacUnit unit) async => state = state.copyWith(sacUnit: unit); + + @override + Future setDefaultCurrency(String currencyCode) async => + state = state.copyWith(defaultCurrency: currencyCode); @override Future setAltitudeUnit(AltitudeUnit unit) async => state = state.copyWith(altitudeUnit: unit); diff --git a/test/helpers/mock_providers.dart b/test/helpers/mock_providers.dart index 6daa303190..5bc36b4221 100644 --- a/test/helpers/mock_providers.dart +++ b/test/helpers/mock_providers.dart @@ -45,6 +45,10 @@ class MockSettingsNotifier extends StateNotifier @override Future setSacUnit(SacUnit unit) async => state = state.copyWith(sacUnit: unit); + + @override + Future setDefaultCurrency(String currencyCode) async => + state = state.copyWith(defaultCurrency: currencyCode); @override Future setAltitudeUnit(AltitudeUnit unit) async => state = state.copyWith(altitudeUnit: unit); From 52e136c31be1aaacfe94ec2350976188ea9452e9 Mon Sep 17 00:00:00 2001 From: etlami Date: Sat, 1 Aug 2026 21:45:52 +0200 Subject: [PATCH 2/4] fix(currency): translate new keys into all locales; add missing test mock CI caught two gaps the local `analyze lib` run missed: - The default-currency ARB keys were only in the English template, failing the arb_parity guard. Add real translations for all ten locales. - A fourth SettingsNotifier test double (settings_page_shared_data_test) needed the new setDefaultCurrency override to compile. Also make an EquipmentItem literal const in a test (analyzer info). --- lib/l10n/arb/app_ar.arb | 2 ++ lib/l10n/arb/app_de.arb | 2 ++ lib/l10n/arb/app_es.arb | 2 ++ lib/l10n/arb/app_fr.arb | 2 ++ lib/l10n/arb/app_he.arb | 2 ++ lib/l10n/arb/app_hu.arb | 2 ++ lib/l10n/arb/app_it.arb | 2 ++ lib/l10n/arb/app_localizations_ar.dart | 4 ++-- lib/l10n/arb/app_localizations_de.dart | 4 ++-- lib/l10n/arb/app_localizations_es.dart | 4 ++-- lib/l10n/arb/app_localizations_fr.dart | 4 ++-- lib/l10n/arb/app_localizations_he.dart | 4 ++-- lib/l10n/arb/app_localizations_hu.dart | 4 ++-- lib/l10n/arb/app_localizations_it.dart | 4 ++-- lib/l10n/arb/app_localizations_nl.dart | 4 ++-- lib/l10n/arb/app_localizations_pt.dart | 4 ++-- lib/l10n/arb/app_localizations_zh.dart | 4 ++-- lib/l10n/arb/app_nl.arb | 2 ++ lib/l10n/arb/app_pt.arb | 2 ++ lib/l10n/arb/app_zh.arb | 2 ++ .../equipment/presentation/equipment_edit_advanced_test.dart | 2 +- .../presentation/pages/settings_page_shared_data_test.dart | 3 +++ 22 files changed, 44 insertions(+), 21 deletions(-) diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index f92daa9070..666c550cbf 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -1,4 +1,6 @@ { + "settings_units_defaultCurrency": "العملة الافتراضية", + "settings_units_dialog_defaultCurrency": "العملة الافتراضية", "diveLog_edit_geofenceSuggestion_near": "بالقرب من {location}", "diveLog_edit_geofenceSuggestion_title": "اقتراح المعدات", "diveLog_edit_geofenceSuggestion_body": "تطبيق مجموعة \"{setName}\"؟", diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 87771fc1f1..288c6e6395 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -1,4 +1,6 @@ { + "settings_units_defaultCurrency": "Standardwährung", + "settings_units_dialog_defaultCurrency": "Standardwährung", "diveLog_edit_geofenceSuggestion_near": "In der Nähe von {location}", "diveLog_edit_geofenceSuggestion_title": "Ausrüstungsvorschlag", "diveLog_edit_geofenceSuggestion_body": "Set \"{setName}\" übernehmen?", diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index d690536036..00b33c3160 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -1,4 +1,6 @@ { + "settings_units_defaultCurrency": "Moneda predeterminada", + "settings_units_dialog_defaultCurrency": "Moneda predeterminada", "diveLog_edit_geofenceSuggestion_near": "Cerca de {location}", "diveLog_edit_geofenceSuggestion_title": "Sugerencia de equipo", "diveLog_edit_geofenceSuggestion_body": "¿Aplicar tu conjunto \"{setName}\"?", diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index f75a470c1f..04a53e226a 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -1,4 +1,6 @@ { + "settings_units_defaultCurrency": "Devise par défaut", + "settings_units_dialog_defaultCurrency": "Devise par défaut", "diveLog_edit_geofenceSuggestion_near": "Près de {location}", "diveLog_edit_geofenceSuggestion_title": "Suggestion d'équipement", "diveLog_edit_geofenceSuggestion_body": "Appliquer l'ensemble \"{setName}\" ?", diff --git a/lib/l10n/arb/app_he.arb b/lib/l10n/arb/app_he.arb index 1619b1f24e..003c2f6c58 100644 --- a/lib/l10n/arb/app_he.arb +++ b/lib/l10n/arb/app_he.arb @@ -1,4 +1,6 @@ { + "settings_units_defaultCurrency": "מטבע ברירת מחדל", + "settings_units_dialog_defaultCurrency": "מטבע ברירת מחדל", "diveLog_edit_geofenceSuggestion_near": "ליד {location}", "diveLog_edit_geofenceSuggestion_title": "הצעת ציוד", "diveLog_edit_geofenceSuggestion_body": "להחיל את ערכת \"{setName}\"?", diff --git a/lib/l10n/arb/app_hu.arb b/lib/l10n/arb/app_hu.arb index bc87b5329f..a987e2a297 100644 --- a/lib/l10n/arb/app_hu.arb +++ b/lib/l10n/arb/app_hu.arb @@ -1,4 +1,6 @@ { + "settings_units_defaultCurrency": "Alapértelmezett pénznem", + "settings_units_dialog_defaultCurrency": "Alapértelmezett pénznem", "diveLog_edit_geofenceSuggestion_near": "{location} közelében", "diveLog_edit_geofenceSuggestion_title": "Felszerelési javaslat", "diveLog_edit_geofenceSuggestion_body": "Alkalmazza a(z) \"{setName}\" készletet?", diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index 1cf74306fe..f812986a0b 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -1,4 +1,6 @@ { + "settings_units_defaultCurrency": "Valuta predefinita", + "settings_units_dialog_defaultCurrency": "Valuta predefinita", "diveLog_edit_geofenceSuggestion_near": "Vicino a {location}", "diveLog_edit_geofenceSuggestion_title": "Suggerimento attrezzatura", "diveLog_edit_geofenceSuggestion_body": "Applicare il set \"{setName}\"?", diff --git a/lib/l10n/arb/app_localizations_ar.dart b/lib/l10n/arb/app_localizations_ar.dart index ee9575a9e4..a7c301ca4d 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -14803,10 +14803,10 @@ class AppLocalizationsAr extends AppLocalizations { String get settings_units_sacRate => 'معدل SAC'; @override - String get settings_units_defaultCurrency => 'Default Currency'; + String get settings_units_defaultCurrency => 'العملة الافتراضية'; @override - String get settings_units_dialog_defaultCurrency => 'Default Currency'; + String get settings_units_dialog_defaultCurrency => 'العملة الافتراضية'; @override String get settings_units_sac_pressurePerMinute => 'الضغط في الدقيقة'; diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index 1e0ae1088d..1d286e978f 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -15057,10 +15057,10 @@ class AppLocalizationsDe extends AppLocalizations { String get settings_units_sacRate => 'AMV'; @override - String get settings_units_defaultCurrency => 'Default Currency'; + String get settings_units_defaultCurrency => 'Standardwährung'; @override - String get settings_units_dialog_defaultCurrency => 'Default Currency'; + String get settings_units_dialog_defaultCurrency => 'Standardwährung'; @override String get settings_units_sac_pressurePerMinute => 'Druck pro Minute'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index 55940cf74f..85ad993196 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -15076,10 +15076,10 @@ class AppLocalizationsEs extends AppLocalizations { String get settings_units_sacRate => 'SAC Rate'; @override - String get settings_units_defaultCurrency => 'Default Currency'; + String get settings_units_defaultCurrency => 'Moneda predeterminada'; @override - String get settings_units_dialog_defaultCurrency => 'Default Currency'; + String get settings_units_dialog_defaultCurrency => 'Moneda predeterminada'; @override String get settings_units_sac_pressurePerMinute => 'Presion por minuto'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index 74d55e0a78..2536cc935c 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -15131,10 +15131,10 @@ class AppLocalizationsFr extends AppLocalizations { String get settings_units_sacRate => 'SAC Rate'; @override - String get settings_units_defaultCurrency => 'Default Currency'; + String get settings_units_defaultCurrency => 'Devise par défaut'; @override - String get settings_units_dialog_defaultCurrency => 'Default Currency'; + String get settings_units_dialog_defaultCurrency => 'Devise par défaut'; @override String get settings_units_sac_pressurePerMinute => 'Pression par minute'; diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index 652a049819..9aace90fa7 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -14695,10 +14695,10 @@ class AppLocalizationsHe extends AppLocalizations { String get settings_units_sacRate => 'קצב SAC'; @override - String get settings_units_defaultCurrency => 'Default Currency'; + String get settings_units_defaultCurrency => 'מטבע ברירת מחדל'; @override - String get settings_units_dialog_defaultCurrency => 'Default Currency'; + String get settings_units_dialog_defaultCurrency => 'מטבע ברירת מחדל'; @override String get settings_units_sac_pressurePerMinute => 'לחץ לדקה'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index 5adcad05d7..cc9930bad4 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -15032,10 +15032,10 @@ class AppLocalizationsHu extends AppLocalizations { String get settings_units_sacRate => 'SAC ertek'; @override - String get settings_units_defaultCurrency => 'Default Currency'; + String get settings_units_defaultCurrency => 'Alapértelmezett pénznem'; @override - String get settings_units_dialog_defaultCurrency => 'Default Currency'; + String get settings_units_dialog_defaultCurrency => 'Alapértelmezett pénznem'; @override String get settings_units_sac_pressurePerMinute => 'Nyomas percenként'; diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index 0853bd1289..7e3226509a 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -15072,10 +15072,10 @@ class AppLocalizationsIt extends AppLocalizations { String get settings_units_sacRate => 'SAC Rate'; @override - String get settings_units_defaultCurrency => 'Default Currency'; + String get settings_units_defaultCurrency => 'Valuta predefinita'; @override - String get settings_units_dialog_defaultCurrency => 'Default Currency'; + String get settings_units_dialog_defaultCurrency => 'Valuta predefinita'; @override String get settings_units_sac_pressurePerMinute => 'Pressione al minuto'; diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index 6d3cfb6cda..9564830b09 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -14949,10 +14949,10 @@ class AppLocalizationsNl extends AppLocalizations { String get settings_units_sacRate => 'SAC-snelheid'; @override - String get settings_units_defaultCurrency => 'Default Currency'; + String get settings_units_defaultCurrency => 'Standaardvaluta'; @override - String get settings_units_dialog_defaultCurrency => 'Default Currency'; + String get settings_units_dialog_defaultCurrency => 'Standaardvaluta'; @override String get settings_units_sac_pressurePerMinute => 'Druk per minuut'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index 02cfaacd7f..9fbfdb9062 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -15083,10 +15083,10 @@ class AppLocalizationsPt extends AppLocalizations { String get settings_units_sacRate => 'Taxa SAC'; @override - String get settings_units_defaultCurrency => 'Default Currency'; + String get settings_units_defaultCurrency => 'Moeda padrão'; @override - String get settings_units_dialog_defaultCurrency => 'Default Currency'; + String get settings_units_dialog_defaultCurrency => 'Moeda padrão'; @override String get settings_units_sac_pressurePerMinute => 'Pressao por minuto'; diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index c69a9b67b1..28da801954 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -14334,10 +14334,10 @@ class AppLocalizationsZh extends AppLocalizations { String get settings_units_sacRate => '气体消耗率'; @override - String get settings_units_defaultCurrency => 'Default Currency'; + String get settings_units_defaultCurrency => '默认货币'; @override - String get settings_units_dialog_defaultCurrency => 'Default Currency'; + String get settings_units_dialog_defaultCurrency => '默认货币'; @override String get settings_units_sac_pressurePerMinute => '压力/分钟'; diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index cc4072015f..1690690121 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -1,4 +1,6 @@ { + "settings_units_defaultCurrency": "Standaardvaluta", + "settings_units_dialog_defaultCurrency": "Standaardvaluta", "diveLog_edit_geofenceSuggestion_near": "Bij {location}", "diveLog_edit_geofenceSuggestion_title": "Uitrustingssuggestie", "diveLog_edit_geofenceSuggestion_body": "Set \"{setName}\" toepassen?", diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index dfbe87ffe2..7854f082f7 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -1,4 +1,6 @@ { + "settings_units_defaultCurrency": "Moeda padrão", + "settings_units_dialog_defaultCurrency": "Moeda padrão", "diveLog_edit_geofenceSuggestion_near": "Perto de {location}", "diveLog_edit_geofenceSuggestion_title": "Sugestão de equipamento", "diveLog_edit_geofenceSuggestion_body": "Aplicar o conjunto \"{setName}\"?", diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index 087672b057..34cebee3e7 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -1,4 +1,6 @@ { + "settings_units_defaultCurrency": "默认货币", + "settings_units_dialog_defaultCurrency": "默认货币", "diveLog_edit_geofenceSuggestion_near": "靠近 {location}", "diveLog_edit_geofenceSuggestion_title": "装备建议", "diveLog_edit_geofenceSuggestion_body": "应用\"{setName}\"套装?", diff --git a/test/features/equipment/presentation/equipment_edit_advanced_test.dart b/test/features/equipment/presentation/equipment_edit_advanced_test.dart index 8a17985318..c97eceb800 100644 --- a/test/features/equipment/presentation/equipment_edit_advanced_test.dart +++ b/test/features/equipment/presentation/equipment_edit_advanced_test.dart @@ -188,7 +188,7 @@ void main() { tester, ) async { final created = await repository.createEquipment( - EquipmentItem( + const EquipmentItem( id: '', name: 'Regulator', type: EquipmentType.regulator, diff --git a/test/features/settings/presentation/pages/settings_page_shared_data_test.dart b/test/features/settings/presentation/pages/settings_page_shared_data_test.dart index 0057540f74..eee308d65e 100644 --- a/test/features/settings/presentation/pages/settings_page_shared_data_test.dart +++ b/test/features/settings/presentation/pages/settings_page_shared_data_test.dart @@ -192,6 +192,9 @@ class _MockSettingsNotifier extends StateNotifier Future setSacUnit(SacUnit unit) async => state = state.copyWith(sacUnit: unit); @override + Future setDefaultCurrency(String currencyCode) async => + state = state.copyWith(defaultCurrency: currencyCode); + @override Future setAltitudeUnit(AltitudeUnit unit) async => state = state.copyWith(altitudeUnit: unit); @override From 997f3ec3751a42b48d79e441c13d125e7e7df9a2 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 5 Aug 2026 23:02:34 -0400 Subject: [PATCH 3/4] Address review feedback and cover the currency paths Review comments: - currency_test pinned Intl.defaultLocale (and restores it). The symbol assertions were riding on intl's implicit fallback; under some locales USD renders as "US$". Number symbols are statically bundled, so no async initialization is needed here, unlike date formatting. - The equipment summary no longer adds items priced in different currencies into one figure under a single symbol. A new sumByCurrency() groups totals per currency (blank codes falling back to the diver's default) and the overview renders one card each. - Pickers keep a stored code that is outside the presets. Extracted the settings picker's inline logic as currencyCodesWith() and reused it on the equipment edit page, so an item priced in, say, ISK stays visible and re-selectable in the dropdown. - The Add Equipment bottom sheet no longer hardcodes USD: it opens in the diver's default currency, offers the same dropdown with live symbol prefix, and falls back to the default rather than USD on save. Its currency box is Expanded rather than a fixed width -- that row already carries a 250dp date button, so a fixed box would overflow on a narrow phone. The edit page's own blank-currency save path now resolves to the diver default too, and its controller starts empty so a stale USD cannot flash while an existing item loads. Tests: patch coverage 52% -> ~99%. New cases cover the settings currency picker end to end (list, select, cancel, non-preset code), SettingsNotifier.setDefaultCurrency normalisation, the detail page's formatted price, the configurable-column formatter under a non-USD default, the summary's per-currency totals, both equipment entry points' default-currency behaviour, and currency.dart's helpers including the unsupported-locale fallback (intl echoes unknown codes rather than throwing, so a bad code alone never reaches those branches). --- lib/core/utils/currency.dart | 43 ++++++ .../pages/equipment_edit_page.dart | 30 +++- .../pages/equipment_list_page.dart | 79 ++++++++-- .../widgets/equipment_summary_widget.dart | 33 ++-- .../presentation/pages/settings_page.dart | 6 +- test/core/utils/currency_test.dart | 142 ++++++++++++++++++ .../constants/equipment_field_test.dart | 23 ++- .../equipment_edit_advanced_test.dart | 137 +++++++++++++++++ .../pages/equipment_detail_page_test.dart | 92 ++++++++++++ .../pages/equipment_list_page_test.dart | 63 ++++++++ .../equipment_summary_currency_test.dart | 112 ++++++++++++++ .../pages/settings_page_test.dart | 119 +++++++++++++++ .../settings_notifier_real_test.dart | 25 +++ 13 files changed, 857 insertions(+), 47 deletions(-) create mode 100644 test/features/equipment/presentation/widgets/equipment_summary_currency_test.dart diff --git a/lib/core/utils/currency.dart b/lib/core/utils/currency.dart index 1dbda1c7ab..ad9793cb78 100644 --- a/lib/core/utils/currency.dart +++ b/lib/core/utils/currency.dart @@ -22,6 +22,18 @@ const List kCommonCurrencyCodes = [ 'ZAR', ]; +/// The preset codes, with [currentCode] prepended when it is a real code +/// outside the presets. Free-text entry means a stored currency can be +/// anything; without this it would vanish from every picker that offers only +/// the presets, leaving the current value unselectable. +List currencyCodesWith(String? currentCode) { + final current = (currentCode ?? '').trim().toUpperCase(); + if (current.isEmpty || kCommonCurrencyCodes.contains(current)) { + return kCommonCurrencyCodes; + } + return [current, ...kCommonCurrencyCodes]; +} + /// The symbol for [currencyCode] (e.g. 'EUR' -> '€'), falling back to the /// upper-cased code itself for anything intl doesn't recognise (or an empty /// string for a blank code). @@ -46,3 +58,34 @@ String formatMoney(double amount, String currencyCode) { return '$prefix${amount.toStringAsFixed(2)}'; } } + +/// Sums the amounts in [items] grouped by their currency, so a collection +/// priced in more than one currency is never added into a single misleading +/// figure. +/// +/// [amountOf] returns null for items with no price (those are skipped), and a +/// blank [currencyOf] falls back to [fallbackCode] - legacy rows can carry an +/// empty code. Entries come back ordered by descending total, then by code, so +/// the display order is stable across rebuilds. +List> sumByCurrency( + Iterable items, { + required double? Function(T item) amountOf, + required String Function(T item) currencyOf, + required String fallbackCode, +}) { + final fallback = fallbackCode.trim().toUpperCase(); + final totals = {}; + for (final item in items) { + final amount = amountOf(item); + if (amount == null) continue; + final raw = currencyOf(item).trim().toUpperCase(); + final code = raw.isEmpty ? fallback : raw; + totals[code] = (totals[code] ?? 0) + amount; + } + final entries = totals.entries.toList() + ..sort((a, b) { + final byTotal = b.value.compareTo(a.value); + return byTotal != 0 ? byTotal : a.key.compareTo(b.key); + }); + return entries; +} diff --git a/lib/features/equipment/presentation/pages/equipment_edit_page.dart b/lib/features/equipment/presentation/pages/equipment_edit_page.dart index ad5de86a0b..0afe55c614 100644 --- a/lib/features/equipment/presentation/pages/equipment_edit_page.dart +++ b/lib/features/equipment/presentation/pages/equipment_edit_page.dart @@ -43,7 +43,9 @@ class _EquipmentEditPageState extends ConsumerState { final _modelController = TextEditingController(); final _serialController = TextEditingController(); final _purchasePriceController = TextEditingController(); - final _purchaseCurrencyController = TextEditingController(text: 'USD'); + // Filled from the diver's default (new items) or the stored value (existing + // items); left blank until then so a stale 'USD' never flashes on load. + final _purchaseCurrencyController = TextEditingController(); final _notesController = TextEditingController(); EquipmentType _selectedType = EquipmentType.regulator; @@ -55,13 +57,18 @@ class _EquipmentEditPageState extends ConsumerState { bool? _customReminderEnabled; List _customReminderDays = [7, 14, 30]; + /// The code this form opened with. Currency is free text, so it can be + /// outside the presets; keeping it lets the dropdown still offer it. + String _initialCurrencyCode = ''; + @override void initState() { super.initState(); // New items start in the diver's default currency; existing items get // their stored currency from _loadEquipment. if (widget.equipmentId == null) { - _purchaseCurrencyController.text = ref.read(defaultCurrencyProvider); + _initialCurrencyCode = ref.read(defaultCurrencyProvider); + _purchaseCurrencyController.text = _initialCurrencyCode; } _nameController.addListener(_onFieldChanged); _brandController.addListener(_onFieldChanged); @@ -72,6 +79,13 @@ class _EquipmentEditPageState extends ConsumerState { _notesController.addListener(_onFieldChanged); } + /// The code to store when the currency field is left blank: the diver's + /// default, or USD if that is somehow unset (the column is NOT NULL). + String _fallbackCurrencyCode() { + final code = ref.read(defaultCurrencyProvider).trim().toUpperCase(); + return code.isEmpty ? 'USD' : code; + } + void _onFieldChanged() { if (!_hasChanges && _isInitialized) { setState(() => _hasChanges = true); @@ -112,7 +126,8 @@ class _EquipmentEditPageState extends ConsumerState { _modelController.text = equipment.model ?? ''; _serialController.text = equipment.serialNumber ?? ''; _purchasePriceController.text = equipment.purchasePrice?.toString() ?? ''; - _purchaseCurrencyController.text = equipment.purchaseCurrency; + _initialCurrencyCode = equipment.purchaseCurrency; + _purchaseCurrencyController.text = _initialCurrencyCode; _notesController.text = equipment.notes; _selectedType = equipment.type; // A legacy row can carry isActive=false with a non-retired status. @@ -604,7 +619,12 @@ class _EquipmentEditPageState extends ConsumerState { enableFilter: true, label: Text(context.l10n.equipment_edit_currencyLabel), dropdownMenuEntries: [ - for (final code in kCommonCurrencyCodes) + // The stored code leads the list when it is outside the + // presets, so an item priced in, say, ISK stays visible + // and re-selectable. + for (final code in currencyCodesWith( + _initialCurrencyCode, + )) DropdownMenuEntry( value: code, label: code, @@ -810,7 +830,7 @@ class _EquipmentEditPageState extends ConsumerState { ? double.tryParse(_purchasePriceController.text) : null, purchaseCurrency: _purchaseCurrencyController.text.trim().isEmpty - ? 'USD' + ? _fallbackCurrencyCode() : _purchaseCurrencyController.text.trim(), // Legacy service fields are frozen: service is managed via clocks on // the detail page. Preserve any existing values for export/import. diff --git a/lib/features/equipment/presentation/pages/equipment_list_page.dart b/lib/features/equipment/presentation/pages/equipment_list_page.dart index aa5a15c2e1..f9ba5fb331 100644 --- a/lib/features/equipment/presentation/pages/equipment_list_page.dart +++ b/lib/features/equipment/presentation/pages/equipment_list_page.dart @@ -6,6 +6,7 @@ import 'package:submersion/core/constants/enums.dart'; import 'package:submersion/core/constants/list_view_mode.dart'; import 'package:submersion/core/constants/sort_options.dart'; import 'package:submersion/core/models/sort_state.dart'; +import 'package:submersion/core/utils/currency.dart'; import 'package:submersion/l10n/l10n_extension.dart'; import 'package:submersion/shared/widgets/app_date_picker.dart'; import 'package:submersion/shared/widgets/entity_table/entity_table_column_picker.dart'; @@ -460,7 +461,7 @@ class _AddEquipmentSheetState extends ConsumerState { final _serialController = TextEditingController(); final _sizeController = TextEditingController(); final _purchasePriceController = TextEditingController(); - final _purchaseCurrencyController = TextEditingController(text: 'USD'); + final _purchaseCurrencyController = TextEditingController(); final _serviceIntervalController = TextEditingController(); final _notesController = TextEditingController(); @@ -468,6 +469,20 @@ class _AddEquipmentSheetState extends ConsumerState { DateTime? _purchaseDate; bool _isSaving = false; + /// The diver's default currency, captured once so the dropdown can offer it + /// even when it is outside the presets, and so save can fall back to it. + String _defaultCurrencyCode = ''; + + @override + void initState() { + super.initState(); + // New items are priced in the diver's default currency, matching the + // full edit page rather than hardcoding USD. + final code = ref.read(defaultCurrencyProvider).trim().toUpperCase(); + _defaultCurrencyCode = code.isEmpty ? 'USD' : code; + _purchaseCurrencyController.text = _defaultCurrencyCode; + } + @override void dispose() { _nameController.dispose(); @@ -624,25 +639,57 @@ class _AddEquipmentSheetState extends ConsumerState { ), const SizedBox(width: 12), Expanded( - child: TextFormField( - controller: _purchasePriceController, - decoration: InputDecoration( - labelText: context.l10n.equipment_addSheet_priceLabel, - ), - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - ), + flex: 2, + // Rebuild the price field when the currency changes so + // its prefix shows the right symbol (EUR -> €, ...). + child: ValueListenableBuilder( + valueListenable: _purchaseCurrencyController, + builder: (context, value, _) { + final symbol = currencySymbol(value.text); + return TextFormField( + controller: _purchasePriceController, + decoration: InputDecoration( + labelText: + context.l10n.equipment_addSheet_priceLabel, + prefixText: symbol.isEmpty ? null : '$symbol ', + ), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + ); + }, ), ), const SizedBox(width: 12), - SizedBox( - width: 90, - child: TextFormField( + // Flexible rather than a fixed width: this row already + // carries a 250dp date button, so a fixed currency box + // would overflow on a narrow phone. + Expanded( + // Editable dropdown: common currencies as presets, but + // any ISO code can still be typed. + child: DropdownMenu( controller: _purchaseCurrencyController, - decoration: InputDecoration( - labelText: - context.l10n.equipment_addSheet_currencyLabel, + expandedInsets: EdgeInsets.zero, + requestFocusOnTap: true, + enableFilter: true, + label: Text( + context.l10n.equipment_addSheet_currencyLabel, ), + dropdownMenuEntries: [ + for (final code in currencyCodesWith( + _defaultCurrencyCode, + )) + DropdownMenuEntry( + value: code, + label: code, + leadingIcon: SizedBox( + width: 28, + child: Center( + child: Text(currencySymbol(code)), + ), + ), + ), + ], ), ), ], @@ -738,7 +785,7 @@ class _AddEquipmentSheetState extends ConsumerState { ? double.tryParse(_purchasePriceController.text) : null, purchaseCurrency: _purchaseCurrencyController.text.trim().isEmpty - ? 'USD' + ? _defaultCurrencyCode : _purchaseCurrencyController.text.trim(), serviceIntervalDays: _serviceIntervalController.text.isNotEmpty ? int.tryParse(_serviceIntervalController.text) diff --git a/lib/features/equipment/presentation/widgets/equipment_summary_widget.dart b/lib/features/equipment/presentation/widgets/equipment_summary_widget.dart index 5bf6e299a2..648c252011 100644 --- a/lib/features/equipment/presentation/widgets/equipment_summary_widget.dart +++ b/lib/features/equipment/presentation/widgets/equipment_summary_widget.dart @@ -84,17 +84,21 @@ class EquipmentSummaryWidget extends ConsumerWidget { ) { // Calculate stats int activeCount = 0; - double totalValue = 0; - for (final item in equipment) { if (item.isActive) { activeCount++; } - if (item.purchasePrice != null) { - totalValue += item.purchasePrice; - } } + // Equipment can be priced in different currencies, so totals are kept + // per currency rather than added into one figure under a single symbol. + final totalsByCurrency = sumByCurrency( + equipment, + amountOf: (item) => item.purchasePrice as double?, + currencyOf: (item) => item.purchaseCurrency as String, + fallbackCode: ref.watch(defaultCurrencyProvider), + ); + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -131,15 +135,16 @@ class EquipmentSummaryWidget extends ConsumerWidget { label: context.l10n.equipment_summary_serviceDue, color: Colors.red, ), - if (totalValue > 0) - _buildStatCard( - context, - icon: Icons.attach_money, - value: - '${currencySymbol(ref.watch(defaultCurrencyProvider))}${totalValue.toStringAsFixed(0)}', - label: context.l10n.equipment_summary_totalValue, - color: Colors.orange, - ), + for (final entry in totalsByCurrency) + if (entry.value > 0) + _buildStatCard( + context, + icon: Icons.attach_money, + value: + '${currencySymbol(entry.key)}${entry.value.toStringAsFixed(0)}', + label: context.l10n.equipment_summary_totalValue, + color: Colors.orange, + ), ], ), if (serviceDue.isNotEmpty) ...[ diff --git a/lib/features/settings/presentation/pages/settings_page.dart b/lib/features/settings/presentation/pages/settings_page.dart index b0cfa21dac..192d5056e0 100644 --- a/lib/features/settings/presentation/pages/settings_page.dart +++ b/lib/features/settings/presentation/pages/settings_page.dart @@ -838,11 +838,7 @@ class _UnitsSectionContent extends ConsumerWidget { String currentCode, ) { final current = currentCode.trim().toUpperCase(); - final codes = [ - if (current.isNotEmpty && !kCommonCurrencyCodes.contains(current)) - current, - ...kCommonCurrencyCodes, - ]; + final codes = currencyCodesWith(current); showDialog( context: context, builder: (dialogContext) => AlertDialog( diff --git a/test/core/utils/currency_test.dart b/test/core/utils/currency_test.dart index 3cb15cb18e..2210c4d7b2 100644 --- a/test/core/utils/currency_test.dart +++ b/test/core/utils/currency_test.dart @@ -1,7 +1,24 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/intl.dart'; import 'package:submersion/core/utils/currency.dart'; void main() { + // currencySymbol/formatMoney resolve against Intl.defaultLocale, a process + // global the app sets from the diver's locale. Pin it so these assertions + // do not ride on intl's implicit fallback (under some locales 'USD' renders + // as 'US$' rather than '$'). Number symbols are statically bundled, so no + // async initialization is needed here - unlike date formatting. + late String? previousLocale; + + setUp(() { + previousLocale = Intl.defaultLocale; + Intl.defaultLocale = 'en_US'; + }); + + tearDown(() { + Intl.defaultLocale = previousLocale; + }); + group('currencySymbol', () { test('maps common codes to their symbols', () { expect(currencySymbol('USD'), r'$'); @@ -21,6 +38,16 @@ void main() { test('unrecognised code falls back to the code itself', () { expect(currencySymbol('ZZZ'), 'ZZZ'); }); + + test('every preset code resolves to a non-empty symbol', () { + for (final code in kCommonCurrencyCodes) { + expect( + currencySymbol(code), + isNotEmpty, + reason: '$code should have a symbol', + ); + } + }); }); group('formatMoney', () { @@ -29,14 +56,129 @@ void main() { expect(formatMoney(12.5, 'USD'), contains(r'$')); }); + test('is case-insensitive and trims whitespace', () { + expect(formatMoney(12.5, ' eur '), formatMoney(12.5, 'EUR')); + }); + test('unrecognised code still shows the code and amount', () { final formatted = formatMoney(12.5, 'ZZZ'); expect(formatted, contains('ZZZ')); expect(formatted, contains('12.5')); }); + + test('a blank code still shows the amount', () { + expect(formatMoney(12.5, ''), contains('12.5')); + }); + + test('formats zero and negative amounts', () { + expect(formatMoney(0, 'USD'), contains('0')); + expect(formatMoney(-5, 'USD'), contains('5')); + }); + }); + + group('locale data failures', () { + // intl throws ArgumentError when Intl.defaultLocale names a locale it has + // no number symbols for. These helpers are called from build methods, so + // they degrade to a plain code+amount rather than taking the UI down. + setUp(() { + Intl.defaultLocale = 'xx_YY'; + }); + + test('currencySymbol falls back to the code', () { + expect(currencySymbol('EUR'), 'EUR'); + }); + + test('formatMoney falls back to "CODE amount"', () { + expect(formatMoney(12.5, 'EUR'), 'EUR 12.50'); + }); + + test('formatMoney with a blank code omits the prefix', () { + expect(formatMoney(12.5, ''), '12.50'); + }); + }); + + group('currencyCodesWith', () { + test('returns the presets unchanged for a preset code', () { + expect(currencyCodesWith('EUR'), kCommonCurrencyCodes); + }); + + test('returns the presets unchanged for a blank or null code', () { + expect(currencyCodesWith(''), kCommonCurrencyCodes); + expect(currencyCodesWith(' '), kCommonCurrencyCodes); + expect(currencyCodesWith(null), kCommonCurrencyCodes); + }); + + test('leads with a non-preset code so it stays selectable', () { + final codes = currencyCodesWith('ISK'); + expect(codes.first, 'ISK'); + expect(codes.sublist(1), kCommonCurrencyCodes); + }); + + test('normalises case and whitespace before comparing', () { + expect(currencyCodesWith(' eur '), kCommonCurrencyCodes); + expect(currencyCodesWith(' isk ').first, 'ISK'); + }); + }); + + group('sumByCurrency', () { + // A minimal stand-in for a priced item. Results come back as records + // rather than MapEntry, which has no value equality. + List<(String, double)> sum( + List<(double?, String)> items, { + String fallback = 'USD', + }) => sumByCurrency<(double?, String)>( + items, + amountOf: (i) => i.$1, + currencyOf: (i) => i.$2, + fallbackCode: fallback, + ).map((e) => (e.key, e.value)).toList(); + + test('sums items sharing one currency into a single entry', () { + expect(sum([(10.0, 'EUR'), (5.5, 'EUR')]), [('EUR', 15.5)]); + }); + + test('keeps different currencies apart instead of adding them', () { + expect(sum([(10.0, 'EUR'), (100.0, 'USD')]), [ + ('USD', 100.0), + ('EUR', 10.0), + ]); + }); + + test('orders by descending total, then by code for ties', () { + final totals = sum([(10.0, 'GBP'), (10.0, 'EUR'), (99.0, 'JPY')]); + expect(totals.map((e) => e.$1), ['JPY', 'EUR', 'GBP']); + }); + + test('skips items with no price', () { + expect(sum([(null, 'EUR'), (7.0, 'EUR')]), [('EUR', 7.0)]); + }); + + test('normalises case and whitespace so codes do not fragment', () { + expect(sum([(1.0, 'eur'), (2.0, ' EUR ')]), [('EUR', 3.0)]); + }); + + test('a blank code falls back to the supplied default', () { + // Legacy rows can carry an empty currency; they belong with the + // diver's default rather than in a nameless bucket of their own. + expect(sum([(4.0, ''), (6.0, 'USD')], fallback: 'USD'), [('USD', 10.0)]); + }); + + test('an empty collection yields no entries', () { + expect(sum(const []), isEmpty); + }); }); test('kCommonCurrencyCodes covers the major currencies', () { expect(kCommonCurrencyCodes, containsAll(['USD', 'EUR', 'GBP', 'CHF'])); }); + + test('kCommonCurrencyCodes has no duplicates and is upper-case', () { + expect( + kCommonCurrencyCodes.toSet(), + hasLength(kCommonCurrencyCodes.length), + ); + for (final code in kCommonCurrencyCodes) { + expect(code, code.toUpperCase()); + } + }); } diff --git a/test/features/equipment/domain/constants/equipment_field_test.dart b/test/features/equipment/domain/constants/equipment_field_test.dart index 2044744af2..1ea4c225e4 100644 --- a/test/features/equipment/domain/constants/equipment_field_test.dart +++ b/test/features/equipment/domain/constants/equipment_field_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:intl/intl.dart'; import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/utils/currency.dart'; import 'package:submersion/core/utils/unit_formatter.dart'; import 'package:submersion/features/equipment/domain/constants/equipment_field.dart'; import 'package:submersion/features/equipment/domain/entities/equipment_attribute.dart'; @@ -440,17 +440,26 @@ void main() { ); }); - test('formats purchasePrice as currency', () { - final expected = NumberFormat.currency( - symbol: r'$', - decimalDigits: 2, - ).format(599.99); + test('formats purchasePrice in the diver default currency', () { + // This column has no per-item context, so it follows the diver's + // default currency rather than a hardcoded dollar sign. expect( adapter.formatValue(EquipmentField.purchasePrice, 599.99, units), - equals(expected), + equals(formatMoney(599.99, 'USD')), ); }); + test('purchasePrice follows a non-USD default currency', () { + const euroUnits = UnitFormatter(AppSettings(defaultCurrency: 'EUR')); + final formatted = adapter.formatValue( + EquipmentField.purchasePrice, + 599.99, + euroUnits, + ); + expect(formatted, equals(formatMoney(599.99, 'EUR'))); + expect(formatted, contains('€')); + }); + test('formats lastServiceDate with units.formatDate', () { final date = DateTime(2024, 6, 1); expect( diff --git a/test/features/equipment/presentation/equipment_edit_advanced_test.dart b/test/features/equipment/presentation/equipment_edit_advanced_test.dart index c97eceb800..ea81e14555 100644 --- a/test/features/equipment/presentation/equipment_edit_advanced_test.dart +++ b/test/features/equipment/presentation/equipment_edit_advanced_test.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:submersion/core/constants/enums.dart'; import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/core/utils/currency.dart'; import 'package:submersion/features/equipment/data/repositories/equipment_repository_impl.dart'; import 'package:submersion/features/equipment/domain/entities/equipment_attribute.dart'; import 'package:submersion/features/equipment/domain/entities/equipment_item.dart'; @@ -211,5 +213,140 @@ void main() { expect(find.text('EUR'), findsWidgets); expect(find.textContaining('€'), findsWidgets); }); + + testWidgets('a stored code outside the presets is offered by the menu', ( + tester, + ) async { + // Currency is free text, so a stored ISK must not vanish from the + // preset-only dropdown -- it leads the list instead. + final created = await repository.createEquipment( + const EquipmentItem( + id: '', + name: 'Drysuit', + type: EquipmentType.drysuit, + purchasePrice: 900, + purchaseCurrency: 'ISK', + ), + ); + await pumpEditor(tester, created.id); + + final menu = find.byType(DropdownMenu); + await tester.scrollUntilVisible( + menu, + 300, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + + final entries = tester + .widget>(menu) + .dropdownMenuEntries + .map((e) => e.value) + .toList(); + expect(entries.first, 'ISK'); + expect(entries.sublist(1), kCommonCurrencyCodes); + }); + + testWidgets('a new item opens in the diver default currency', ( + tester, + ) async { + final settings = MockSettingsNotifier(); + final overrides = await getBaseOverrides(settingsNotifier: settings); + await settings.setDefaultCurrency('SEK'); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + ...overrides, + equipmentRepositoryProvider.overrideWithValue(repository), + ].cast(), + child: const MaterialApp( + locale: Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: EquipmentEditPage(embedded: true)), + ), + ), + ); + await tester.pumpAndSettle(); + + final menu = find.byType(DropdownMenu); + await tester.scrollUntilVisible( + menu, + 300, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + + expect(tester.widget>(menu).controller?.text, 'SEK'); + // The price prefix tracks the selected currency, not a fixed dollar. + expect(find.textContaining(currencySymbol('SEK')), findsWidgets); + }); + + testWidgets('clearing the currency saves the diver default, not USD', ( + tester, + ) async { + // The column is NOT NULL, so a cleared field has to resolve to + // something; it resolves to the diver's default rather than a + // hardcoded USD. + final created = await repository.createEquipment( + const EquipmentItem( + id: '', + name: 'Fins', + type: EquipmentType.fins, + purchasePrice: 80, + purchaseCurrency: 'GBP', + ), + ); + + final settings = MockSettingsNotifier(); + final overrides = await getBaseOverrides(settingsNotifier: settings); + await settings.setDefaultCurrency('NOK'); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + ...overrides, + equipmentRepositoryProvider.overrideWithValue(repository), + ].cast(), + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: EquipmentEditPage(equipmentId: created.id, embedded: true), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final menu = find.byType(DropdownMenu); + await tester.scrollUntilVisible( + menu, + 300, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + + final field = find.descendant(of: menu, matching: find.byType(TextField)); + await tester.enterText(field, ''); + // Typing opens the dropdown overlay, which would sit over the Save + // button; close it before saving. + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + final saveButton = find.text('Save'); + await tester.scrollUntilVisible( + saveButton, + 300, + scrollable: find.byType(Scrollable).first, + ); + await tester.tap(saveButton); + await tester.pumpAndSettle(); + + final saved = await repository.getEquipmentById(created.id); + expect(saved!.purchaseCurrency, 'NOK'); + }); }); } diff --git a/test/features/equipment/presentation/pages/equipment_detail_page_test.dart b/test/features/equipment/presentation/pages/equipment_detail_page_test.dart index c969ea74e1..13da402110 100644 --- a/test/features/equipment/presentation/pages/equipment_detail_page_test.dart +++ b/test/features/equipment/presentation/pages/equipment_detail_page_test.dart @@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart'; import 'package:submersion/core/constants/enums.dart'; import 'package:submersion/core/constants/list_view_mode.dart'; import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/core/utils/currency.dart'; import 'package:submersion/features/equipment/domain/entities/equipment_attribute.dart'; import 'package:submersion/features/equipment/domain/entities/equipment_item.dart'; import 'package:submersion/features/equipment/domain/entities/service_record.dart'; @@ -228,6 +229,97 @@ void main() { }); }); + group('EquipmentDetailPage purchase price', () { + Future pumpWithEquipment( + WidgetTester tester, + EquipmentItem equipment, + ) async { + tester.view.devicePixelRatio = 1.0; + tester.view.physicalSize = const Size(600, 1600); + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + final overrides = await getBaseOverrides(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + ...overrides, + equipmentItemProvider( + equipment.id, + ).overrideWith((ref) async => equipment), + equipmentDiveCountProvider( + equipment.id, + ).overrideWith((ref) async => 0), + equipmentTripCountProvider( + equipment.id, + ).overrideWith((ref) async => 0), + serviceRecordNotifierProvider( + equipment.id, + ).overrideWith((ref) => _MockServiceRecordNotifier()), + serviceRecordTotalCostProvider( + equipment.id, + ).overrideWith((ref) async => 0.0), + ].cast(), + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: EquipmentDetailPage(equipmentId: equipment.id), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('renders the item currency symbol, not a hardcoded dollar', ( + tester, + ) async { + const equipment = EquipmentItem( + id: 'equip-price-eur', + name: 'Drysuit', + type: EquipmentType.drysuit, + purchasePrice: 1234.5, + purchaseCurrency: 'EUR', + ); + + await pumpWithEquipment(tester, equipment); + + final expected = formatMoney(1234.5, 'EUR'); + await tester.scrollUntilVisible( + find.text(expected), + 300, + scrollable: find.byType(Scrollable).first, + ); + expect(find.text(expected), findsOneWidget); + expect(expected, contains('€')); + }); + + testWidgets('an unrecognised code falls back to showing the code', ( + tester, + ) async { + const equipment = EquipmentItem( + id: 'equip-price-zzz', + name: 'Prototype rebreather', + type: EquipmentType.other, + purchasePrice: 99.0, + purchaseCurrency: 'ZZZ', + ); + + await pumpWithEquipment(tester, equipment); + + final expected = formatMoney(99.0, 'ZZZ'); + await tester.scrollUntilVisible( + find.text(expected), + 300, + scrollable: find.byType(Scrollable).first, + ); + expect(find.text(expected), findsOneWidget); + expect(expected, contains('ZZZ')); + }); + }); + group('ServiceRecordDialog date pickers (#765)', () { Future pumpDialog(WidgetTester tester) async { tester.view.devicePixelRatio = 1.0; diff --git a/test/features/equipment/presentation/pages/equipment_list_page_test.dart b/test/features/equipment/presentation/pages/equipment_list_page_test.dart index 82b2d9fbb5..0d5f09af2f 100644 --- a/test/features/equipment/presentation/pages/equipment_list_page_test.dart +++ b/test/features/equipment/presentation/pages/equipment_list_page_test.dart @@ -637,4 +637,67 @@ void main() { expect(find.byType(DatePickerDialog), findsNothing); }); }); + + group('AddEquipmentSheet currency', () { + Future> pumpSheet( + WidgetTester tester, { + required String defaultCurrency, + }) async { + tester.view.devicePixelRatio = 1.0; + tester.view.physicalSize = const Size(800, 1600); + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + final settings = MockSettingsNotifier(); + final overrides = await getBaseOverrides(settingsNotifier: settings); + await settings.setDefaultCurrency(defaultCurrency); + + await tester.pumpWidget( + ProviderScope( + overrides: overrides, + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Consumer( + builder: (context, ref, _) => AddEquipmentSheet(ref: ref), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final menu = find.byType(DropdownMenu); + await tester.scrollUntilVisible( + menu, + 200, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + return tester.widget>(menu); + } + + testWidgets('opens in the diver default currency rather than USD', ( + tester, + ) async { + final menu = await pumpSheet(tester, defaultCurrency: 'EUR'); + + expect(menu.controller?.text, 'EUR'); + // The price field's prefix follows the selected currency. + expect(find.textContaining('€'), findsWidgets); + }); + + testWidgets('a non-preset default currency is still offered', ( + tester, + ) async { + final menu = await pumpSheet(tester, defaultCurrency: 'ISK'); + + expect(menu.controller?.text, 'ISK'); + expect(menu.dropdownMenuEntries.map((e) => e.value).first, 'ISK'); + }); + }); } diff --git a/test/features/equipment/presentation/widgets/equipment_summary_currency_test.dart b/test/features/equipment/presentation/widgets/equipment_summary_currency_test.dart new file mode 100644 index 0000000000..9d2d5c1408 --- /dev/null +++ b/test/features/equipment/presentation/widgets/equipment_summary_currency_test.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/utils/currency.dart'; +import 'package:submersion/features/equipment/domain/entities/equipment_item.dart'; +import 'package:submersion/features/equipment/presentation/providers/equipment_providers.dart'; +import 'package:submersion/features/equipment/presentation/widgets/equipment_summary_widget.dart'; + +import '../../../../helpers/mock_providers.dart'; +import '../../../../helpers/test_app.dart'; + +EquipmentItem _priced(String id, double? price, String currency) { + return EquipmentItem( + id: id, + name: 'Item $id', + type: EquipmentType.regulator, + purchasePrice: price, + purchaseCurrency: currency, + ); +} + +void main() { + Future pumpSummary( + WidgetTester tester, + List equipment, { + String defaultCurrency = 'USD', + }) async { + tester.view.devicePixelRatio = 1.0; + tester.view.physicalSize = const Size(800, 1600); + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + final settings = MockSettingsNotifier(); + final overrides = await getBaseOverrides(settingsNotifier: settings); + await settings.setDefaultCurrency(defaultCurrency); + + final router = GoRouter( + initialLocation: '/equipment', + routes: [ + GoRoute( + path: '/equipment', + builder: (context, state) => const EquipmentSummaryWidget(), + ), + ], + ); + + await tester.pumpWidget( + testAppRouter( + router: router, + locale: const Locale('en'), + overrides: [ + ...overrides, + allEquipmentProvider.overrideWith((ref) async => equipment), + serviceDueEquipmentProvider.overrideWith((ref) async => const []), + ], + ), + ); + await tester.pumpAndSettle(); + } + + String card(double total, String code) => + '${currencySymbol(code)}${total.toStringAsFixed(0)}'; + + testWidgets('one currency yields a single total in that currency', ( + tester, + ) async { + await pumpSummary(tester, [ + _priced('a', 100.0, 'EUR'), + _priced('b', 250.0, 'EUR'), + ]); + + expect(find.text(card(350, 'EUR')), findsOneWidget); + }); + + testWidgets('mixed currencies are never added into one figure', ( + tester, + ) async { + // The regression this guards: 100 EUR + 900 USD once rendered as a single + // "$1000" total under whichever symbol the diver's default happened to be. + await pumpSummary(tester, [ + _priced('a', 100.0, 'EUR'), + _priced('b', 900.0, 'USD'), + ]); + + expect(find.text(card(900, 'USD')), findsOneWidget); + expect(find.text(card(100, 'EUR')), findsOneWidget); + expect(find.text(card(1000, 'USD')), findsNothing); + }); + + testWidgets('a blank stored currency falls back to the diver default', ( + tester, + ) async { + await pumpSummary(tester, [ + _priced('a', 40.0, ''), + _priced('b', 60.0, 'GBP'), + ], defaultCurrency: 'GBP'); + + expect(find.text(card(100, 'GBP')), findsOneWidget); + }); + + testWidgets('items without a price contribute no total card', (tester) async { + await pumpSummary(tester, [ + _priced('a', null, 'EUR'), + _priced('b', null, 'USD'), + ]); + + expect(find.byIcon(Icons.attach_money), findsNothing); + }); +} diff --git a/test/features/settings/presentation/pages/settings_page_test.dart b/test/features/settings/presentation/pages/settings_page_test.dart index f6343fcb39..51209058b1 100644 --- a/test/features/settings/presentation/pages/settings_page_test.dart +++ b/test/features/settings/presentation/pages/settings_page_test.dart @@ -14,6 +14,7 @@ import 'package:submersion/features/auto_update/presentation/providers/update_pr import 'package:riverpod/src/framework.dart' as riverpod show Override; import 'package:submersion/core/providers/provider.dart'; import 'package:submersion/core/constants/units.dart'; +import 'package:submersion/core/utils/currency.dart'; import 'package:submersion/core/deco/entities/cns_calculation_method.dart'; import 'package:submersion/features/divers/data/repositories/diver_repository.dart' show DeleteDiverResult; @@ -1492,4 +1493,122 @@ void main() { expect(tester.takeException(), isNull); }); }); + + group('UnitsSectionContent default currency', () { + Widget buildUnitsWidget(List overrides) { + final router = GoRouter( + initialLocation: '/settings?selected=units', + routes: [ + GoRoute( + path: '/settings', + builder: (context, state) => const SettingsPage(), + ), + ], + ); + return ProviderScope( + overrides: overrides, + child: MaterialApp.router( + routerConfig: router, + // Pin the locale so the English string-based finders below are + // deterministic regardless of the host environment locale. + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + ), + ); + } + + Future openPicker(WidgetTester tester) async { + await tester.scrollUntilVisible(find.text('Default Currency'), 200); + await tester.ensureVisible(find.text('Default Currency')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Default Currency')); + await tester.pumpAndSettle(); + } + + testWidgets('the tile shows the persisted currency code', (tester) async { + await tester.pumpWidget( + buildUnitsWidget( + getOverrides(const AppSettings(defaultCurrency: 'EUR')), + ), + ); + await tester.pumpAndSettle(); + + await tester.scrollUntilVisible(find.text('Default Currency'), 200); + await tester.pumpAndSettle(); + + expect(find.text('EUR'), findsOneWidget); + }); + + testWidgets('the picker lists the preset codes with their symbols', ( + tester, + ) async { + await tester.pumpWidget(buildUnitsWidget(getOverrides())); + await tester.pumpAndSettle(); + await openPicker(tester); + + expect(find.byType(AlertDialog), findsOneWidget); + expect(find.text('EUR €'), findsOneWidget); + expect(find.text('GBP £'), findsOneWidget); + // The current selection is ticked (scoped to the dialog - the page + // behind it has check icons of its own). + expect( + find.descendant( + of: find.byType(AlertDialog), + matching: find.byIcon(Icons.check), + ), + findsOneWidget, + ); + }); + + testWidgets('picking a currency persists it and closes the dialog', ( + tester, + ) async { + await tester.pumpWidget(buildUnitsWidget(getOverrides())); + await tester.pumpAndSettle(); + await openPicker(tester); + + await tester.tap(find.text('EUR €')); + await tester.pumpAndSettle(); + + expect(find.byType(AlertDialog), findsNothing); + expect(find.text('EUR'), findsOneWidget); + }); + + testWidgets('cancelling leaves the currency unchanged', (tester) async { + await tester.pumpWidget(buildUnitsWidget(getOverrides())); + await tester.pumpAndSettle(); + await openPicker(tester); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(find.byType(AlertDialog), findsNothing); + expect(find.text('USD'), findsOneWidget); + }); + + testWidgets('a stored code outside the presets stays selectable', ( + tester, + ) async { + // Currency is free text elsewhere in the app, so the picker must offer + // the persisted value even when it is not one of the presets. + await tester.pumpWidget( + buildUnitsWidget( + getOverrides(const AppSettings(defaultCurrency: 'ISK')), + ), + ); + await tester.pumpAndSettle(); + await openPicker(tester); + + // Listed with its symbol in the dialog, and ticked as the current value. + expect(find.text('ISK ${currencySymbol('ISK')}'), findsOneWidget); + expect( + find.descendant( + of: find.byType(AlertDialog), + matching: find.byIcon(Icons.check), + ), + findsOneWidget, + ); + }); + }); } diff --git a/test/features/settings/presentation/providers/settings_notifier_real_test.dart b/test/features/settings/presentation/providers/settings_notifier_real_test.dart index a0a4462de2..7491c3d796 100644 --- a/test/features/settings/presentation/providers/settings_notifier_real_test.dart +++ b/test/features/settings/presentation/providers/settings_notifier_real_test.dart @@ -186,6 +186,31 @@ void main() { ); }); + test('setDefaultCurrency normalises and persists the code', () async { + container.read(settingsProvider.notifier); + await waitForInit(); + + expect(container.read(settingsProvider).defaultCurrency, 'USD'); + + // Free-text entry elsewhere means the code can arrive padded or in the + // wrong case; the setter is the single place that normalises it. + await container + .read(settingsProvider.notifier) + .setDefaultCurrency(' eur '); + expect(container.read(settingsProvider).defaultCurrency, 'EUR'); + + // The derived provider the equipment pages read tracks the change. + expect(container.read(defaultCurrencyProvider), 'EUR'); + }); + + test('setDefaultCurrency accepts a code outside the presets', () async { + container.read(settingsProvider.notifier); + await waitForInit(); + + await container.read(settingsProvider.notifier).setDefaultCurrency('isk'); + expect(container.read(settingsProvider).defaultCurrency, 'ISK'); + }); + test('sets showDetailsPaneSites to true', () async { container.read(settingsProvider.notifier); await waitForInit(); From ebf3ab9fa413fb8bec660dd016ac47307cdb9ca0 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Thu, 6 Aug 2026 00:18:59 -0400 Subject: [PATCH 4/4] Give service records a real currency instead of assuming USD Follow-up to the equipment currency work: ServiceRecord already carried a currency field, but nothing wrote or read it. Every service cost was stored as USD and displayed with a hardcoded '$', regardless of the diver's default currency or what the record actually said. - The service dialog gains a currency dropdown alongside the cost field, with the same live symbol prefix as the equipment forms. New records open in the diver's default; editing keeps the record's stored currency, so opening and saving an old EUR record cannot silently reprice it. A stored code outside the presets still leads the menu. - The per-record cost renders via formatMoney with the record's own currency. - The total was the equipment-summary bug one layer deeper, in raw SQL: SELECT SUM(cost) added rows priced in different currencies into a figure that is not a real amount in any of them. It now groups by currency, and the card shows one labelled row per currency. getTotalServiceCost -> getTotalServiceCostByCurrency, so the wrong helper does not survive for the next caller; the provider's type changes with it. New l10n key equipment_serviceDialog_currencyLabel in all 11 locales, reusing each one's existing wording for "Currency". Tests: seven widget tests for the display and dialog behaviour, plus five repository tests driving the new GROUP BY against a real database (summing, separation, cost-less records, equipment scoping) -- the widget tests only ever see a stubbed provider, so the SQL needed its own coverage. --- lib/core/database/database.dart | 23 +- .../service_record_repository.dart | 26 +- .../pages/equipment_detail_page.dart | 191 +++++++++---- .../providers/equipment_providers.dart | 18 +- lib/l10n/arb/app_ar.arb | 1 + lib/l10n/arb/app_de.arb | 1 + lib/l10n/arb/app_en.arb | 1 + lib/l10n/arb/app_es.arb | 1 + lib/l10n/arb/app_fr.arb | 1 + lib/l10n/arb/app_he.arb | 1 + lib/l10n/arb/app_hu.arb | 1 + lib/l10n/arb/app_it.arb | 1 + lib/l10n/arb/app_localizations.dart | 6 + lib/l10n/arb/app_localizations_ar.dart | 3 + lib/l10n/arb/app_localizations_de.dart | 3 + lib/l10n/arb/app_localizations_en.dart | 3 + lib/l10n/arb/app_localizations_es.dart | 3 + lib/l10n/arb/app_localizations_fr.dart | 3 + lib/l10n/arb/app_localizations_he.dart | 3 + lib/l10n/arb/app_localizations_hu.dart | 3 + lib/l10n/arb/app_localizations_it.dart | 3 + lib/l10n/arb/app_localizations_nl.dart | 3 + lib/l10n/arb/app_localizations_pt.dart | 3 + lib/l10n/arb/app_localizations_zh.dart | 3 + lib/l10n/arb/app_nl.arb | 1 + lib/l10n/arb/app_pt.arb | 1 + lib/l10n/arb/app_zh.arb | 1 + ...migration_v141_default_currency_test.dart} | 10 +- .../service_record_cost_currency_test.dart | 109 ++++++++ .../pages/equipment_detail_page_test.dart | 8 +- .../pages/equipment_detail_service_test.dart | 2 +- .../equipment_service_currency_test.dart | 256 ++++++++++++++++++ 32 files changed, 606 insertions(+), 87 deletions(-) rename test/core/database/{migration_v140_default_currency_test.dart => migration_v141_default_currency_test.dart} (91%) create mode 100644 test/features/equipment/data/repositories/service_record_cost_currency_test.dart create mode 100644 test/features/equipment/presentation/pages/equipment_service_currency_test.dart diff --git a/lib/core/database/database.dart b/lib/core/database/database.dart index 843c932c20..98e4872c9f 100644 --- a/lib/core/database/database.dart +++ b/lib/core/database/database.dart @@ -2928,7 +2928,7 @@ class AppDatabase extends _$AppDatabase { /// The current schema version as a static constant so that pre-open checks /// (e.g. version-mismatch guard) can reference it without an instance. - static const int currentSchemaVersion = 140; + static const int currentSchemaVersion = 141; /// Every schema version that has a migration block in onUpgrade. /// Used to calculate progress step counts. When adding a new migration, @@ -3101,10 +3101,11 @@ class AppDatabase extends _$AppDatabase { // v139: cylinder_configs + cylinder_config_items (reusable diluent and // bailout setups). 139, - // v140: diver_settings.default_currency (default currency for priced - // items). Renumbered from v138 and then v139 as those were claimed by the + // v140 is reserved by the media-section branch (media.retain_in_library). + // v141: diver_settings.default_currency (default currency for priced + // items). Renumbered from v138 and then v139 as those went to the // divelogs.de branch and the cylinder configs respectively. - 140, + 141, ]; /// Idempotent DDL for the v106 connector-suggestion columns (Lightroom @@ -4070,8 +4071,8 @@ class AppDatabase extends _$AppDatabase { } } - /// Idempotent DDL for the v140 diver_settings.default_currency column. - /// Called from the v140 onUpgrade step and the beforeOpen backstop, and + /// Idempotent DDL for the v141 diver_settings.default_currency column. + /// Called from the v141 onUpgrade step and the beforeOpen backstop, and /// self-guarding when the table is absent (minimal migration-test fixtures). Future _assertDefaultCurrencyColumn() async { final cols = await customSelect( @@ -7310,11 +7311,13 @@ class AppDatabase extends _$AppDatabase { await _assertCylinderConfigSchema(); await reportProgress(); } - // v140: default currency for priced items (e.g. equipment). - if (from < 140) { + // v141: default currency for priced items (e.g. equipment). A DB + // that upgraded past 141 on a parallel branch never enters this block; + // the beforeOpen backstop below is its only path to the column. + if (from < 141) { await _assertDefaultCurrencyColumn(); } - if (from < 140) await reportProgress(); + if (from < 141) await reportProgress(); }, beforeOpen: (details) async { // Enable foreign keys @@ -7336,7 +7339,7 @@ class AppDatabase extends _$AppDatabase { // v137 backstop: re-assert dives.weather_code. await _assertWeatherCodeColumn(); - // v140 backstop: re-assert diver_settings.default_currency. + // v141 backstop: re-assert diver_settings.default_currency. await _assertDefaultCurrencyColumn(); // v106 backstop: re-assert connector-suggestion columns (the helper diff --git a/lib/features/equipment/data/repositories/service_record_repository.dart b/lib/features/equipment/data/repositories/service_record_repository.dart index cdee7692c0..0a9beaee55 100644 --- a/lib/features/equipment/data/repositories/service_record_repository.dart +++ b/lib/features/equipment/data/repositories/service_record_repository.dart @@ -195,20 +195,32 @@ class ServiceRecordRepository { return results.map(_mapCustomRowToServiceRecord).toList(); } - /// Get total cost of services for an equipment item - Future getTotalServiceCost(String equipmentId) async { - final result = await _db + /// Total service cost for an equipment item, keyed by the currency each + /// record was priced in. + /// + /// Grouped rather than summed into one figure: records can carry different + /// currencies, and adding them together would produce a number that is not + /// a real amount in any of them. + Future> getTotalServiceCostByCurrency( + String equipmentId, + ) async { + final results = await _db .customSelect( ''' - SELECT COALESCE(SUM(cost), 0) as total + SELECT currency, COALESCE(SUM(cost), 0) as total FROM service_records - WHERE equipment_id = ? + WHERE equipment_id = ? AND cost IS NOT NULL + GROUP BY currency ''', variables: [Variable.withString(equipmentId)], ) - .getSingle(); + .get(); - return (result.data['total'] as num?)?.toDouble() ?? 0.0; + return { + for (final row in results) + (row.data['currency'] as String?) ?? '': + (row.data['total'] as num?)?.toDouble() ?? 0.0, + }; } /// Get service record count for an equipment item diff --git a/lib/features/equipment/presentation/pages/equipment_detail_page.dart b/lib/features/equipment/presentation/pages/equipment_detail_page.dart index 060d0ca331..8d5a34974d 100644 --- a/lib/features/equipment/presentation/pages/equipment_detail_page.dart +++ b/lib/features/equipment/presentation/pages/equipment_detail_page.dart @@ -926,36 +926,55 @@ class _ServiceHistorySection extends ConsumerWidget { return Column( children: [ - // Total cost summary + // Total cost summary, one row per currency: a history + // priced in more than one currency has no single total. totalCostAsync.when( - data: (totalCost) { - if (totalCost > 0) { - return Container( - padding: const EdgeInsets.all(12), - margin: const EdgeInsets.only(bottom: 12), - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - context.l10n.equipment_service_totalCostLabel, - style: Theme.of(context).textTheme.bodyMedium, - ), - Text( - '\$${totalCost.toStringAsFixed(2)}', - style: Theme.of(context).textTheme.titleMedium - ?.copyWith(fontWeight: FontWeight.bold), + data: (rawTotals) { + final totals = sumByCurrency>( + rawTotals.entries, + amountOf: (e) => e.value, + currencyOf: (e) => e.key, + fallbackCode: ref.watch(defaultCurrencyProvider), + ).where((e) => e.value > 0).toList(); + if (totals.isEmpty) return const SizedBox.shrink(); + return Container( + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + children: [ + for (final entry in totals) + Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Text( + context + .l10n + .equipment_service_totalCostLabel, + style: Theme.of( + context, + ).textTheme.bodyMedium, + ), + Text( + formatMoney(entry.value, entry.key), + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], ), - ], - ), - ); - } - return const SizedBox.shrink(); + ], + ), + ); }, loading: () => const SizedBox.shrink(), error: (_, _) => const SizedBox.shrink(), @@ -1113,7 +1132,7 @@ class _ServiceRecordTile extends ConsumerWidget { children: [ if (record.cost != null) Text( - '\$${record.cost!.toStringAsFixed(2)}', + formatMoney(record.cost!, record.currency), style: Theme.of( context, ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold), @@ -1200,11 +1219,17 @@ class _ServiceRecordDialogState extends ConsumerState { late DateTime _serviceDate; final _providerController = TextEditingController(); final _costController = TextEditingController(); + final _currencyController = TextEditingController(); final _notesController = TextEditingController(); DateTime? _nextServiceDue; String? _serviceKindId; bool _isSaving = false; + /// The code this dialog opened with: the record's stored currency when + /// editing, the diver's default for a new record. Currency is free text, so + /// this can be outside the presets; keeping it lets the dropdown offer it. + String _initialCurrencyCode = ''; + bool get isEditing => widget.existingRecord != null; @override @@ -1216,6 +1241,7 @@ class _ServiceRecordDialogState extends ConsumerState { _serviceDate = record.serviceDate; _providerController.text = record.provider ?? ''; _costController.text = record.cost?.toString() ?? ''; + _initialCurrencyCode = record.currency; _notesController.text = record.notes; _nextServiceDue = record.nextServiceDue; _serviceKindId = record.serviceKindId; @@ -1223,13 +1249,23 @@ class _ServiceRecordDialogState extends ConsumerState { _serviceType = ServiceType.annual; _serviceDate = DateTime.now(); _serviceKindId = widget.serviceKindId; + _initialCurrencyCode = _fallbackCurrencyCode(); } + _currencyController.text = _initialCurrencyCode; + } + + /// The code to store when the currency field is left blank: the diver's + /// default, or USD if that is somehow unset (the column is NOT NULL). + String _fallbackCurrencyCode() { + final code = ref.read(defaultCurrencyProvider).trim().toUpperCase(); + return code.isEmpty ? 'USD' : code; } @override void dispose() { _providerController.dispose(); _costController.dispose(); + _currencyController.dispose(); _notesController.dispose(); super.dispose(); } @@ -1351,28 +1387,77 @@ class _ServiceRecordDialogState extends ConsumerState { ), const SizedBox(height: 16), - // Cost field - TextFormField( - controller: _costController, - decoration: InputDecoration( - labelText: context.l10n.equipment_serviceDialog_costLabel, - prefixIcon: const Icon(Icons.attach_money), - hintText: context.l10n.equipment_serviceDialog_costHint, - ), - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - ), - validator: (value) { - if (value != null && value.isNotEmpty) { - final parsed = double.tryParse(value); - if (parsed == null || parsed < 0) { - return context - .l10n - .equipment_serviceDialog_costValidation; - } - } - return null; - }, + // Cost field, with the currency it is priced in. + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 2, + // Rebuild the cost field when the currency changes so + // its prefix shows the right symbol (EUR -> €, ...). + child: ValueListenableBuilder( + valueListenable: _currencyController, + builder: (context, value, _) { + final symbol = currencySymbol(value.text); + return TextFormField( + controller: _costController, + decoration: InputDecoration( + labelText: context + .l10n + .equipment_serviceDialog_costLabel, + prefixText: symbol.isEmpty ? null : '$symbol ', + hintText: + context.l10n.equipment_serviceDialog_costHint, + ), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + validator: (value) { + if (value != null && value.isNotEmpty) { + final parsed = double.tryParse(value); + if (parsed == null || parsed < 0) { + return context + .l10n + .equipment_serviceDialog_costValidation; + } + } + return null; + }, + ); + }, + ), + ), + const SizedBox(width: 12), + Expanded( + // Editable dropdown: common currencies as presets, but + // any ISO code can still be typed. The stored code leads + // the list when it is outside the presets. + child: DropdownMenu( + controller: _currencyController, + expandedInsets: EdgeInsets.zero, + requestFocusOnTap: true, + enableFilter: true, + label: Text( + context.l10n.equipment_serviceDialog_currencyLabel, + ), + dropdownMenuEntries: [ + for (final code in currencyCodesWith( + _initialCurrencyCode, + )) + DropdownMenuEntry( + value: code, + label: code, + leadingIcon: SizedBox( + width: 28, + child: Center( + child: Text(currencySymbol(code)), + ), + ), + ), + ], + ), + ), + ], ), const SizedBox(height: 16), @@ -1500,7 +1585,9 @@ class _ServiceRecordDialogState extends ConsumerState { cost: _costController.text.isEmpty ? null : double.tryParse(_costController.text), - currency: 'USD', + currency: _currencyController.text.trim().isEmpty + ? _fallbackCurrencyCode() + : _currencyController.text.trim().toUpperCase(), nextServiceDue: _nextServiceDue, notes: _notesController.text.trim(), createdAt: widget.existingRecord?.createdAt ?? now, diff --git a/lib/features/equipment/presentation/providers/equipment_providers.dart b/lib/features/equipment/presentation/providers/equipment_providers.dart index aa8b2108a0..be2749c042 100644 --- a/lib/features/equipment/presentation/providers/equipment_providers.dart +++ b/lib/features/equipment/presentation/providers/equipment_providers.dart @@ -359,14 +359,16 @@ final mostRecentServiceRecordProvider = return repository.getMostRecentRecord(equipmentId); }); -/// Total service cost for equipment -final serviceRecordTotalCostProvider = FutureProvider.family(( - ref, - equipmentId, -) async { - final repository = ref.watch(serviceRecordRepositoryProvider); - return repository.getTotalServiceCost(equipmentId); -}); +/// Total service cost for equipment, keyed by the currency of each record. +/// Kept per currency so mixed-currency histories are never added together. +final serviceRecordTotalCostProvider = + FutureProvider.family, String>(( + ref, + equipmentId, + ) async { + final repository = ref.watch(serviceRecordRepositoryProvider); + return repository.getTotalServiceCostByCurrency(equipmentId); + }); /// Service record count for equipment final serviceRecordCountProvider = FutureProvider.family(( diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 9c0d898247..263bc8fbc4 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -3411,6 +3411,7 @@ "equipment_serviceDialog_clearNextServiceDateTooltip": "مسح تاريخ الصيانة القادمة", "equipment_serviceDialog_costHint": "0.00", "equipment_serviceDialog_costLabel": "التكلفة", + "equipment_serviceDialog_currencyLabel": "العملة", "equipment_serviceDialog_costValidation": "أدخل مبلغاً صالحاً", "equipment_serviceDialog_editTitle": "تعديل سجل الصيانة", "equipment_serviceDialog_nextServiceDueLabel": "موعد الصيانة القادمة", diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 584a2b3c6c..4f4aba776c 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -3411,6 +3411,7 @@ "equipment_serviceDialog_clearNextServiceDateTooltip": "Nächstes Wartungsdatum löschen", "equipment_serviceDialog_costHint": "0,00", "equipment_serviceDialog_costLabel": "Kosten", + "equipment_serviceDialog_currencyLabel": "Währung", "equipment_serviceDialog_costValidation": "Geben Sie einen gültigen Betrag ein", "equipment_serviceDialog_editTitle": "Wartungseintrag bearbeiten", "equipment_serviceDialog_nextServiceDueLabel": "Nächste Wartung fällig", diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 8182bb5c5b..849f1ec051 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -5997,6 +5997,7 @@ "equipment_serviceDialog_clearNextServiceDateTooltip": "Clear Next Service Date", "equipment_serviceDialog_costHint": "0.00", "equipment_serviceDialog_costLabel": "Cost", + "equipment_serviceDialog_currencyLabel": "Currency", "equipment_serviceDialog_costValidation": "Enter a valid amount", "equipment_serviceDialog_editTitle": "Edit Service Record", "equipment_serviceDialog_nextServiceDueLabel": "Next Service Due", diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index 49206fc797..d3e20b4222 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -3411,6 +3411,7 @@ "equipment_serviceDialog_clearNextServiceDateTooltip": "Borrar fecha del proximo servicio", "equipment_serviceDialog_costHint": "0.00", "equipment_serviceDialog_costLabel": "Costo", + "equipment_serviceDialog_currencyLabel": "Moneda", "equipment_serviceDialog_costValidation": "Ingresa un monto valido", "equipment_serviceDialog_editTitle": "Editar registro de servicio", "equipment_serviceDialog_nextServiceDueLabel": "Proximo servicio", diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index ffcf838a10..02c36050bc 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -3339,6 +3339,7 @@ "equipment_serviceDialog_clearNextServiceDateTooltip": "Effacer la date de prochaine revision", "equipment_serviceDialog_costHint": "0.00", "equipment_serviceDialog_costLabel": "Cout", + "equipment_serviceDialog_currencyLabel": "Devise", "equipment_serviceDialog_costValidation": "Entrez un montant valide", "equipment_serviceDialog_editTitle": "Modifier l'enregistrement de revision", "equipment_serviceDialog_nextServiceDueLabel": "Prochaine revision", diff --git a/lib/l10n/arb/app_he.arb b/lib/l10n/arb/app_he.arb index 00f66fac4f..3c36d8ef0f 100644 --- a/lib/l10n/arb/app_he.arb +++ b/lib/l10n/arb/app_he.arb @@ -3339,6 +3339,7 @@ "equipment_serviceDialog_clearNextServiceDateTooltip": "נקה תאריך טיפול הבא", "equipment_serviceDialog_costHint": "0.00", "equipment_serviceDialog_costLabel": "עלות", + "equipment_serviceDialog_currencyLabel": "מטבע", "equipment_serviceDialog_costValidation": "הזן סכום חוקי", "equipment_serviceDialog_editTitle": "ערוך רשומת טיפול", "equipment_serviceDialog_nextServiceDueLabel": "הטיפול הבא", diff --git a/lib/l10n/arb/app_hu.arb b/lib/l10n/arb/app_hu.arb index 5691d38f58..d45b7de6e5 100644 --- a/lib/l10n/arb/app_hu.arb +++ b/lib/l10n/arb/app_hu.arb @@ -3339,6 +3339,7 @@ "equipment_serviceDialog_clearNextServiceDateTooltip": "Kovetkezo szerviz datum torlese", "equipment_serviceDialog_costHint": "0.00", "equipment_serviceDialog_costLabel": "Koltseg", + "equipment_serviceDialog_currencyLabel": "Penznem", "equipment_serviceDialog_costValidation": "Adjon meg ervenyes osszeget", "equipment_serviceDialog_editTitle": "Szervizrekord szerkesztese", "equipment_serviceDialog_nextServiceDueLabel": "Kovetkezo szerviz esedekesseg", diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index 3a2a2bb4da..3ebe61894a 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -3339,6 +3339,7 @@ "equipment_serviceDialog_clearNextServiceDateTooltip": "Cancella data prossima manutenzione", "equipment_serviceDialog_costHint": "0.00", "equipment_serviceDialog_costLabel": "Costo", + "equipment_serviceDialog_currencyLabel": "Valuta", "equipment_serviceDialog_costValidation": "Inserisci un importo valido", "equipment_serviceDialog_editTitle": "Modifica registro manutenzione", "equipment_serviceDialog_nextServiceDueLabel": "Prossima manutenzione prevista", diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index 352e5e8a13..5b0c3dd284 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -18459,6 +18459,12 @@ abstract class AppLocalizations { /// **'Cost'** String get equipment_serviceDialog_costLabel; + /// No description provided for @equipment_serviceDialog_currencyLabel. + /// + /// In en, this message translates to: + /// **'Currency'** + String get equipment_serviceDialog_currencyLabel; + /// No description provided for @equipment_serviceDialog_costValidation. /// /// In en, this message translates to: diff --git a/lib/l10n/arb/app_localizations_ar.dart b/lib/l10n/arb/app_localizations_ar.dart index 1247eb83ba..7deca1f92d 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -10635,6 +10635,9 @@ class AppLocalizationsAr extends AppLocalizations { @override String get equipment_serviceDialog_costLabel => 'التكلفة'; + @override + String get equipment_serviceDialog_currencyLabel => 'العملة'; + @override String get equipment_serviceDialog_costValidation => 'أدخل مبلغاً صالحاً'; diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index 4bb232d8be..40ec107d40 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -10815,6 +10815,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get equipment_serviceDialog_costLabel => 'Kosten'; + @override + String get equipment_serviceDialog_currencyLabel => 'Währung'; + @override String get equipment_serviceDialog_costValidation => 'Geben Sie einen gültigen Betrag ein'; diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index d33d21d5ae..d25ae5ec32 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -10655,6 +10655,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get equipment_serviceDialog_costLabel => 'Cost'; + @override + String get equipment_serviceDialog_currencyLabel => 'Currency'; + @override String get equipment_serviceDialog_costValidation => 'Enter a valid amount'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index 4ed57e9644..476ba441c4 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -10812,6 +10812,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get equipment_serviceDialog_costLabel => 'Costo'; + @override + String get equipment_serviceDialog_currencyLabel => 'Moneda'; + @override String get equipment_serviceDialog_costValidation => 'Ingresa un monto valido'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index a3d3ab9bf5..e93e9ffe00 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -10849,6 +10849,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get equipment_serviceDialog_costLabel => 'Cout'; + @override + String get equipment_serviceDialog_currencyLabel => 'Devise'; + @override String get equipment_serviceDialog_costValidation => 'Entrez un montant valide'; diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index 3b0fd66513..f8ad147c6e 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -10571,6 +10571,9 @@ class AppLocalizationsHe extends AppLocalizations { @override String get equipment_serviceDialog_costLabel => 'עלות'; + @override + String get equipment_serviceDialog_currencyLabel => 'מטבע'; + @override String get equipment_serviceDialog_costValidation => 'הזן סכום חוקי'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index 506921b91b..09322e81e1 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -10792,6 +10792,9 @@ class AppLocalizationsHu extends AppLocalizations { @override String get equipment_serviceDialog_costLabel => 'Koltseg'; + @override + String get equipment_serviceDialog_currencyLabel => 'Penznem'; + @override String get equipment_serviceDialog_costValidation => 'Adjon meg ervenyes osszeget'; diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index 0b313237e0..ab5a2b8b44 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -10818,6 +10818,9 @@ class AppLocalizationsIt extends AppLocalizations { @override String get equipment_serviceDialog_costLabel => 'Costo'; + @override + String get equipment_serviceDialog_currencyLabel => 'Valuta'; + @override String get equipment_serviceDialog_costValidation => 'Inserisci un importo valido'; diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index 1137955460..75b6b5183f 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -10741,6 +10741,9 @@ class AppLocalizationsNl extends AppLocalizations { @override String get equipment_serviceDialog_costLabel => 'Kosten'; + @override + String get equipment_serviceDialog_currencyLabel => 'Valuta'; + @override String get equipment_serviceDialog_costValidation => 'Voer een geldig bedrag in'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index 94dc393812..f24f89b4da 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -10816,6 +10816,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get equipment_serviceDialog_costLabel => 'Custo'; + @override + String get equipment_serviceDialog_currencyLabel => 'Moeda'; + @override String get equipment_serviceDialog_costValidation => 'Insira um valor valido'; diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index d23fe7347a..2ef67642d0 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -10338,6 +10338,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get equipment_serviceDialog_costLabel => '费用'; + @override + String get equipment_serviceDialog_currencyLabel => '货币'; + @override String get equipment_serviceDialog_costValidation => '请输入有效金额'; diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index c3e4cafc06..e008e9af7e 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -3411,6 +3411,7 @@ "equipment_serviceDialog_clearNextServiceDateTooltip": "Volgende servicedatum wissen", "equipment_serviceDialog_costHint": "0,00", "equipment_serviceDialog_costLabel": "Kosten", + "equipment_serviceDialog_currencyLabel": "Valuta", "equipment_serviceDialog_costValidation": "Voer een geldig bedrag in", "equipment_serviceDialog_editTitle": "Servicerecord bewerken", "equipment_serviceDialog_nextServiceDueLabel": "Volgende service gepland", diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index 34de2fcd61..f1bd066b23 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -3411,6 +3411,7 @@ "equipment_serviceDialog_clearNextServiceDateTooltip": "Limpar Data da Proxima Manutencao", "equipment_serviceDialog_costHint": "0,00", "equipment_serviceDialog_costLabel": "Custo", + "equipment_serviceDialog_currencyLabel": "Moeda", "equipment_serviceDialog_costValidation": "Insira um valor valido", "equipment_serviceDialog_editTitle": "Editar Registro de Manutencao", "equipment_serviceDialog_nextServiceDueLabel": "Proxima Manutencao", diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index a23dbf05a3..cb2990f63a 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -3553,6 +3553,7 @@ "equipment_serviceDialog_clearNextServiceDateTooltip": "清除下次维护日期", "equipment_serviceDialog_costHint": "0.00", "equipment_serviceDialog_costLabel": "费用", + "equipment_serviceDialog_currencyLabel": "货币", "equipment_serviceDialog_costValidation": "请输入有效金额", "equipment_serviceDialog_editTitle": "编辑维护记录", "equipment_serviceDialog_nextServiceDueLabel": "下次维护日期", diff --git a/test/core/database/migration_v140_default_currency_test.dart b/test/core/database/migration_v141_default_currency_test.dart similarity index 91% rename from test/core/database/migration_v140_default_currency_test.dart rename to test/core/database/migration_v141_default_currency_test.dart index cf0980dea5..04a1444fb0 100644 --- a/test/core/database/migration_v140_default_currency_test.dart +++ b/test/core/database/migration_v141_default_currency_test.dart @@ -2,7 +2,7 @@ import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:submersion/core/database/database.dart'; -/// Minimal pre-v140 shape: a diver_settings table with just a primary key, +/// Minimal pre-v141 shape: a diver_settings table with just a primary key, /// stamped at v137 so only the default_currency migration runs. NativeDatabase _dbAt137() { return NativeDatabase.memory( @@ -39,7 +39,7 @@ NativeDatabase _strandedAtCurrent() { void main() { test( - 'v140 adds default_currency to diver_settings, defaulting to USD', + 'v141 adds default_currency to diver_settings, defaulting to USD', () async { final db = AppDatabase(_dbAt137()); addTearDown(db.close); @@ -57,9 +57,9 @@ void main() { }, ); - test('v140 default_currency migration is present', () { - expect(AppDatabase.currentSchemaVersion, greaterThanOrEqualTo(140)); - expect(AppDatabase.migrationVersions, contains(140)); + test('v141 default_currency migration is present', () { + expect(AppDatabase.currentSchemaVersion, greaterThanOrEqualTo(141)); + expect(AppDatabase.migrationVersions, contains(141)); }); test('a fresh database gets default_currency via onCreate', () async { diff --git a/test/features/equipment/data/repositories/service_record_cost_currency_test.dart b/test/features/equipment/data/repositories/service_record_cost_currency_test.dart new file mode 100644 index 0000000000..25c9e18032 --- /dev/null +++ b/test/features/equipment/data/repositories/service_record_cost_currency_test.dart @@ -0,0 +1,109 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/features/equipment/data/repositories/equipment_repository_impl.dart'; +import 'package:submersion/features/equipment/data/repositories/service_record_repository.dart'; +import 'package:submersion/features/equipment/domain/entities/equipment_item.dart'; +import 'package:submersion/features/equipment/domain/entities/service_record.dart'; + +import '../../../../helpers/test_database.dart'; + +/// Exercises the raw GROUP BY in getTotalServiceCostByCurrency against a real +/// database -- the widget tests above it only ever see a stubbed provider. +void main() { + late ServiceRecordRepository records; + late EquipmentRepository equipment; + late String equipmentId; + + setUp(() async { + await setUpTestDatabase(); + records = ServiceRecordRepository(); + equipment = EquipmentRepository(); + final item = await equipment.createEquipment( + const EquipmentItem( + id: '', + name: 'Primary Reg', + type: EquipmentType.regulator, + ), + ); + equipmentId = item.id; + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + Future addRecord({double? cost, String currency = 'USD'}) async { + final now = DateTime.now(); + await records.createRecord( + ServiceRecord( + id: '', + equipmentId: equipmentId, + serviceType: ServiceType.annual, + serviceDate: now, + cost: cost, + currency: currency, + createdAt: now, + updatedAt: now, + ), + ); + } + + test('no records yields no totals', () async { + expect(await records.getTotalServiceCostByCurrency(equipmentId), isEmpty); + }); + + test('records sharing a currency sum into one entry', () async { + await addRecord(cost: 100, currency: 'EUR'); + await addRecord(cost: 25.5, currency: 'EUR'); + + expect(await records.getTotalServiceCostByCurrency(equipmentId), { + 'EUR': 125.5, + }); + }); + + test('different currencies stay in separate entries', () async { + await addRecord(cost: 100, currency: 'EUR'); + await addRecord(cost: 900, currency: 'USD'); + + final totals = await records.getTotalServiceCostByCurrency(equipmentId); + expect(totals, {'EUR': 100.0, 'USD': 900.0}); + // The bug this replaced: one combined 1000 under a single symbol. + expect(totals.values.length, 2); + }); + + test('records with no cost are excluded entirely', () async { + await addRecord(cost: null, currency: 'EUR'); + await addRecord(cost: 40, currency: 'USD'); + + expect(await records.getTotalServiceCostByCurrency(equipmentId), { + 'USD': 40.0, + }); + }); + + test('the totals are scoped to the requested equipment', () async { + await addRecord(cost: 100, currency: 'EUR'); + final other = await equipment.createEquipment( + const EquipmentItem(id: '', name: 'Octo', type: EquipmentType.regulator), + ); + final now = DateTime.now(); + await records.createRecord( + ServiceRecord( + id: '', + equipmentId: other.id, + serviceType: ServiceType.annual, + serviceDate: now, + cost: 500, + currency: 'EUR', + createdAt: now, + updatedAt: now, + ), + ); + + expect(await records.getTotalServiceCostByCurrency(equipmentId), { + 'EUR': 100.0, + }); + expect(await records.getTotalServiceCostByCurrency(other.id), { + 'EUR': 500.0, + }); + }); +} diff --git a/test/features/equipment/presentation/pages/equipment_detail_page_test.dart b/test/features/equipment/presentation/pages/equipment_detail_page_test.dart index 13da402110..eaefbc65c6 100644 --- a/test/features/equipment/presentation/pages/equipment_detail_page_test.dart +++ b/test/features/equipment/presentation/pages/equipment_detail_page_test.dart @@ -72,7 +72,7 @@ void main() { ).overrideWith((ref) => _MockServiceRecordNotifier()), serviceRecordTotalCostProvider( equipment.id, - ).overrideWith((ref) async => 0.0), + ).overrideWith((ref) async => {}), ].cast(), child: MaterialApp.router( routerConfig: router, @@ -135,7 +135,7 @@ void main() { ).overrideWith((ref) => _MockServiceRecordNotifier()), serviceRecordTotalCostProvider( equipment.id, - ).overrideWith((ref) async => 0.0), + ).overrideWith((ref) async => {}), ].cast(), child: MaterialApp.router( routerConfig: router, @@ -205,7 +205,7 @@ void main() { ).overrideWith((ref) => _MockServiceRecordNotifier()), serviceRecordTotalCostProvider( equipment.id, - ).overrideWith((ref) async => 0.0), + ).overrideWith((ref) async => {}), ].cast(), child: MaterialApp( locale: const Locale('en'), @@ -260,7 +260,7 @@ void main() { ).overrideWith((ref) => _MockServiceRecordNotifier()), serviceRecordTotalCostProvider( equipment.id, - ).overrideWith((ref) async => 0.0), + ).overrideWith((ref) async => {}), ].cast(), child: MaterialApp( locale: const Locale('en'), diff --git a/test/features/equipment/presentation/pages/equipment_detail_service_test.dart b/test/features/equipment/presentation/pages/equipment_detail_service_test.dart index 6a59deea73..03fb210ad0 100644 --- a/test/features/equipment/presentation/pages/equipment_detail_service_test.dart +++ b/test/features/equipment/presentation/pages/equipment_detail_service_test.dart @@ -63,7 +63,7 @@ void main() { ).overrideWith((ref) => _MockServiceRecordNotifier()), serviceRecordTotalCostProvider( item.id, - ).overrideWith((ref) async => 0.0), + ).overrideWith((ref) async => {}), serviceClockStatusesProvider( item.id, ).overrideWith((ref) async => clockStatuses), diff --git a/test/features/equipment/presentation/pages/equipment_service_currency_test.dart b/test/features/equipment/presentation/pages/equipment_service_currency_test.dart new file mode 100644 index 0000000000..a17db50368 --- /dev/null +++ b/test/features/equipment/presentation/pages/equipment_service_currency_test.dart @@ -0,0 +1,256 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/core/utils/currency.dart'; +import 'package:submersion/features/equipment/domain/entities/equipment_item.dart'; +import 'package:submersion/features/equipment/domain/entities/service_record.dart'; +import 'package:submersion/features/equipment/presentation/pages/equipment_detail_page.dart'; +import 'package:submersion/features/equipment/presentation/providers/equipment_providers.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +import '../../../../helpers/mock_providers.dart'; + +const _equipment = EquipmentItem( + id: 'equip-svc', + name: 'Primary Reg', + type: EquipmentType.regulator, +); + +ServiceRecord _record({ + required String id, + double? cost, + String currency = 'USD', +}) { + return ServiceRecord( + id: id, + equipmentId: _equipment.id, + serviceType: ServiceType.annual, + serviceDate: DateTime(2026, 1, 1), + cost: cost, + currency: currency, + createdAt: DateTime(2026, 1, 1), + updatedAt: DateTime(2026, 1, 1), + ); +} + +class _SeededServiceRecordNotifier + extends StateNotifier>> + implements ServiceRecordNotifier { + _SeededServiceRecordNotifier(List records) + : super(AsyncValue.data(records)); + + @override + dynamic noSuchMethod(Invocation invocation) => null; +} + +void main() { + Future pumpDetail( + WidgetTester tester, { + required List records, + Map totals = const {}, + String defaultCurrency = 'USD', + }) async { + tester.view.devicePixelRatio = 1.0; + tester.view.physicalSize = const Size(600, 2400); + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + final settings = MockSettingsNotifier(); + final overrides = await getBaseOverrides(settingsNotifier: settings); + await settings.setDefaultCurrency(defaultCurrency); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + ...overrides, + equipmentItemProvider( + _equipment.id, + ).overrideWith((ref) async => _equipment), + equipmentDiveCountProvider( + _equipment.id, + ).overrideWith((ref) async => 0), + equipmentTripCountProvider( + _equipment.id, + ).overrideWith((ref) async => 0), + serviceRecordNotifierProvider( + _equipment.id, + ).overrideWith((ref) => _SeededServiceRecordNotifier(records)), + serviceRecordTotalCostProvider( + _equipment.id, + ).overrideWith((ref) async => totals), + serviceClockStatusesProvider( + _equipment.id, + ).overrideWith((ref) async => const []), + ].cast(), + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: EquipmentDetailPage(equipmentId: _equipment.id), + ), + ), + ); + await tester.pumpAndSettle(); + } + + // `.first` because a per-currency total and its record row can render the + // same formatted string, which would make the target ambiguous. + Future scrollTo(WidgetTester tester, Finder finder) async { + await tester.scrollUntilVisible( + finder.first, + 300, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + } + + group('service record cost display', () { + testWidgets('a record shows its own currency, not a hardcoded dollar', ( + tester, + ) async { + await pumpDetail( + tester, + records: [_record(id: 'r1', cost: 120, currency: 'EUR')], + ); + + final expected = formatMoney(120, 'EUR'); + await scrollTo(tester, find.text(expected)); + expect(find.text(expected), findsOneWidget); + expect(expected, contains('€')); + }); + + testWidgets('total cost is split per currency, never added together', ( + tester, + ) async { + // The regression: 100 EUR + 900 USD once rendered as a single "$1000". + await pumpDetail( + tester, + records: [ + _record(id: 'r1', cost: 100, currency: 'EUR'), + _record(id: 'r2', cost: 900, currency: 'USD'), + ], + totals: const {'EUR': 100, 'USD': 900}, + ); + + await scrollTo(tester, find.text(formatMoney(900, 'USD'))); + + // One total row per currency, so both are labelled -- a single combined + // row would give exactly one label. + expect(find.text('Total Service Cost'), findsNWidgets(2)); + // Each currency's own figure appears, and the naive sum never does. + expect(find.text(formatMoney(900, 'USD')), findsWidgets); + expect(find.text(formatMoney(100, 'EUR')), findsWidgets); + expect(find.text(formatMoney(1000, 'USD')), findsNothing); + expect(find.text(formatMoney(1000, 'EUR')), findsNothing); + }); + + testWidgets('a blank stored currency falls back to the diver default', ( + tester, + ) async { + await pumpDetail( + tester, + records: [_record(id: 'r1', cost: 40, currency: '')], + totals: const {'': 40}, + defaultCurrency: 'GBP', + ); + + await scrollTo(tester, find.text(formatMoney(40, 'GBP'))); + expect(find.text(formatMoney(40, 'GBP')), findsOneWidget); + }); + + testWidgets('records without a cost produce no total row', (tester) async { + await pumpDetail(tester, records: [_record(id: 'r1')]); + + expect(find.text('Total Service Cost'), findsNothing); + }); + }); + + group('ServiceRecordDialog currency', () { + Future> pumpDialog( + WidgetTester tester, { + ServiceRecord? existing, + String defaultCurrency = 'USD', + }) async { + tester.view.devicePixelRatio = 1.0; + tester.view.physicalSize = const Size(800, 1600); + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + final settings = MockSettingsNotifier(); + final overrides = await getBaseOverrides(settingsNotifier: settings); + await settings.setDefaultCurrency(defaultCurrency); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + ...overrides, + serviceKindsProvider.overrideWith((ref) async => const []), + ].cast(), + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: ServiceRecordDialog( + equipmentId: _equipment.id, + existingRecord: existing, + onSave: (_) async {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final menu = find.byType(DropdownMenu); + await tester.scrollUntilVisible( + menu, + 200, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + return tester.widget>(menu); + } + + testWidgets('a new record opens in the diver default currency', ( + tester, + ) async { + final menu = await pumpDialog(tester, defaultCurrency: 'SEK'); + + expect(menu.controller?.text, 'SEK'); + // The cost field's prefix tracks the selected currency. + expect(find.textContaining(currencySymbol('SEK')), findsWidgets); + }); + + testWidgets('editing keeps the currency the record was priced in', ( + tester, + ) async { + final menu = await pumpDialog( + tester, + existing: _record(id: 'r1', cost: 75, currency: 'EUR'), + defaultCurrency: 'USD', + ); + + // The stored currency wins over the diver's default when editing -- + // otherwise saving would silently reprice the record. + expect(menu.controller?.text, 'EUR'); + }); + + testWidgets('a stored code outside the presets is still offered', ( + tester, + ) async { + final menu = await pumpDialog( + tester, + existing: _record(id: 'r1', cost: 75, currency: 'ISK'), + ); + + expect(menu.controller?.text, 'ISK'); + expect(menu.dropdownMenuEntries.map((e) => e.value).first, 'ISK'); + }); + }); +}