diff --git a/lib/core/database/database.dart b/lib/core/database/database.dart index ce1572e48d..6a4877eb80 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'))(); @@ -2927,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 = 139; + 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, @@ -3100,6 +3101,11 @@ class AppDatabase extends _$AppDatabase { // v139: cylinder_configs + cylinder_config_items (reusable diluent and // bailout setups). 139, + // 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. + 141, ]; /// Idempotent DDL for the v106 connector-suggestion columns (Lightroom @@ -4075,6 +4081,22 @@ class AppDatabase extends _$AppDatabase { } } + /// 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( + "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 @@ -7299,6 +7321,13 @@ class AppDatabase extends _$AppDatabase { await _assertCylinderConfigSchema(); await reportProgress(); } + // 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 < 141) await reportProgress(); }, beforeOpen: (details) async { // Enable foreign keys @@ -7320,6 +7349,9 @@ class AppDatabase extends _$AppDatabase { // v137 backstop: re-assert dives.weather_code. await _assertWeatherCodeColumn(); + // v141 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..ad9793cb78 --- /dev/null +++ b/lib/core/utils/currency.dart @@ -0,0 +1,91 @@ +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 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). +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)}'; + } +} + +/// 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/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/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 7d7091b6a0..8d5a34974d 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/app_date_picker.dart'; @@ -612,7 +613,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( @@ -922,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(), @@ -1109,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), @@ -1196,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 @@ -1212,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; @@ -1219,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(); } @@ -1347,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), @@ -1496,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/pages/equipment_edit_page.dart b/lib/features/equipment/presentation/pages/equipment_edit_page.dart index f81ae43d35..0afe55c614 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'; @@ -42,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; @@ -54,9 +57,19 @@ 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) { + _initialCurrencyCode = ref.read(defaultCurrencyProvider); + _purchaseCurrencyController.text = _initialCurrencyCode; + } _nameController.addListener(_onFieldChanged); _brandController.addListener(_onFieldChanged); _modelController.addListener(_onFieldChanged); @@ -66,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); @@ -106,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. @@ -563,27 +584,56 @@ 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: [ + // 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, + leadingIcon: SizedBox( + width: 28, + child: Center(child: Text(currencySymbol(code))), + ), + ), + ], ), ), ], @@ -780,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/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/features/equipment/presentation/widgets/equipment_summary_widget.dart b/lib/features/equipment/presentation/widgets/equipment_summary_widget.dart index fa9e9cfca1..648c252011 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. @@ -82,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: [ @@ -129,14 +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: '\$${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/data/repositories/diver_settings_repository.dart b/lib/features/settings/data/repositories/diver_settings_repository.dart index b712ba7776..f2fe6dfea3 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)), @@ -219,6 +220,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)), @@ -412,6 +414,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 97b80d3964..192d5056e0 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'; @@ -475,6 +476,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, + ), + ), ], ), ), @@ -820,6 +832,51 @@ class _UnitsSectionContent extends ConsumerWidget { ); } + void _showCurrencyPicker( + BuildContext context, + WidgetRef ref, + String currentCode, + ) { + final current = currentCode.trim().toUpperCase(); + final codes = currencyCodesWith(current); + 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 d48622f52c..f853c09938 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; @@ -393,6 +398,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, @@ -543,6 +549,7 @@ class AppSettings { WeightUnit? weightUnit, AltitudeUnit? altitudeUnit, SacUnit? sacUnit, + String? defaultCurrency, TimeFormat? timeFormat, DateFormatPreference? dateFormat, ThemeMode? themeMode, @@ -659,6 +666,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, @@ -1058,6 +1066,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(); @@ -1685,6 +1698,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_ar.arb b/lib/l10n/arb/app_ar.arb index 0d42ef4a9f..263bc8fbc4 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": "العملة الافتراضية", "diveSites_list_menu_select": "تحديد المواقع", "diveLog_edit_geofenceSuggestion_near": "بالقرب من {location}", "diveLog_edit_geofenceSuggestion_title": "اقتراح المعدات", @@ -3409,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 810b8f6ce7..4f4aba776c 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", "diveSites_list_menu_select": "Tauchplätze auswählen", "diveLog_edit_geofenceSuggestion_near": "In der Nähe von {location}", "diveLog_edit_geofenceSuggestion_title": "Ausrüstungsvorschlag", @@ -3409,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 4f571e564a..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", @@ -8366,6 +8367,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_es.arb b/lib/l10n/arb/app_es.arb index f420565680..d3e20b4222 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", "diveSites_list_menu_select": "Seleccionar puntos", "diveLog_edit_geofenceSuggestion_near": "Cerca de {location}", "diveLog_edit_geofenceSuggestion_title": "Sugerencia de equipo", @@ -3409,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 0a0fa59bda..02c36050bc 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", "diveSites_list_menu_select": "Sélectionner des sites", "diveLog_edit_geofenceSuggestion_near": "Près de {location}", "diveLog_edit_geofenceSuggestion_title": "Suggestion d'équipement", @@ -3337,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 0e5207611d..3c36d8ef0f 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": "מטבע ברירת מחדל", "diveSites_list_menu_select": "בחירת אתרים", "diveLog_edit_geofenceSuggestion_near": "ליד {location}", "diveLog_edit_geofenceSuggestion_title": "הצעת ציוד", @@ -3337,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 34cd5d17fa..d45b7de6e5 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", "diveSites_list_menu_select": "Merülőhelyek kiválasztása", "diveLog_edit_geofenceSuggestion_near": "{location} közelében", "diveLog_edit_geofenceSuggestion_title": "Felszerelési javaslat", @@ -3337,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 d5bd0f1943..3ebe61894a 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", "diveSites_list_menu_select": "Seleziona siti", "diveLog_edit_geofenceSuggestion_near": "Vicino a {location}", "diveLog_edit_geofenceSuggestion_title": "Suggerimento attrezzatura", @@ -3337,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 c08dd6d0ef..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: @@ -25574,6 +25580,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 139478f14b..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 => 'أدخل مبلغاً صالحاً'; @@ -14906,6 +14909,12 @@ class AppLocalizationsAr extends AppLocalizations { @override String get settings_units_sacRate => 'معدل SAC'; + @override + String get settings_units_defaultCurrency => 'العملة الافتراضية'; + + @override + 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 d2813dee49..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'; @@ -15160,6 +15163,12 @@ class AppLocalizationsDe extends AppLocalizations { @override String get settings_units_sacRate => 'AMV'; + @override + String get settings_units_defaultCurrency => 'Standardwährung'; + + @override + 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_en.dart b/lib/l10n/arb/app_localizations_en.dart index 03ef371d7e..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'; @@ -14925,6 +14928,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 191f6bd69a..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'; @@ -15179,6 +15182,12 @@ class AppLocalizationsEs extends AppLocalizations { @override String get settings_units_sacRate => 'SAC Rate'; + @override + String get settings_units_defaultCurrency => 'Moneda predeterminada'; + + @override + 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 dce2fe733c..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'; @@ -15234,6 +15237,12 @@ class AppLocalizationsFr extends AppLocalizations { @override String get settings_units_sacRate => 'SAC Rate'; + @override + String get settings_units_defaultCurrency => 'Devise par défaut'; + + @override + 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 1c84542a5b..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 => 'הזן סכום חוקי'; @@ -14797,6 +14800,12 @@ class AppLocalizationsHe extends AppLocalizations { @override String get settings_units_sacRate => 'קצב SAC'; + @override + String get settings_units_defaultCurrency => 'מטבע ברירת מחדל'; + + @override + 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 0637c9e958..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'; @@ -15133,6 +15136,12 @@ class AppLocalizationsHu extends AppLocalizations { @override String get settings_units_sacRate => 'SAC ertek'; + @override + String get settings_units_defaultCurrency => 'Alapértelmezett pénznem'; + + @override + 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 16ea1979bd..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'; @@ -15175,6 +15178,12 @@ class AppLocalizationsIt extends AppLocalizations { @override String get settings_units_sacRate => 'SAC Rate'; + @override + String get settings_units_defaultCurrency => 'Valuta predefinita'; + + @override + 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 ff73fce150..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'; @@ -15053,6 +15056,12 @@ class AppLocalizationsNl extends AppLocalizations { @override String get settings_units_sacRate => 'SAC-snelheid'; + @override + String get settings_units_defaultCurrency => 'Standaardvaluta'; + + @override + 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 2079822cad..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'; @@ -15186,6 +15189,12 @@ class AppLocalizationsPt extends AppLocalizations { @override String get settings_units_sacRate => 'Taxa SAC'; + @override + String get settings_units_defaultCurrency => 'Moeda padrão'; + + @override + 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 cc967876b5..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 => '请输入有效金额'; @@ -14430,6 +14433,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get settings_units_sacRate => '气体消耗率'; + @override + String get settings_units_defaultCurrency => '默认货币'; + + @override + 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 3e07737bc7..e008e9af7e 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", "diveSites_list_menu_select": "Duikstekken selecteren", "diveLog_edit_geofenceSuggestion_near": "Bij {location}", "diveLog_edit_geofenceSuggestion_title": "Uitrustingssuggestie", @@ -3409,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 734f63ffcd..f1bd066b23 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", "diveSites_list_menu_select": "Selecionar pontos", "diveLog_edit_geofenceSuggestion_near": "Perto de {location}", "diveLog_edit_geofenceSuggestion_title": "Sugestão de equipamento", @@ -3409,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 b4ac77dbce..cb2990f63a 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": "默认货币", "diveSites_list_menu_select": "选择潜水点", "diveLog_edit_geofenceSuggestion_near": "靠近 {location}", "diveLog_edit_geofenceSuggestion_title": "装备建议", @@ -3551,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_v141_default_currency_test.dart b/test/core/database/migration_v141_default_currency_test.dart new file mode 100644 index 0000000000..04a1444fb0 --- /dev/null +++ b/test/core/database/migration_v141_default_currency_test.dart @@ -0,0 +1,89 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; + +/// 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( + 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')"); + }, + ); +} + +/// A DB already stamped at the current version but missing the column - the +/// shape a device ends up in when it upgraded through a parallel branch that +/// claimed a higher number. Only the beforeOpen backstop can heal it. +NativeDatabase _strandedAtCurrent() { + return NativeDatabase.memory( + setup: (rawDb) { + rawDb.execute( + 'PRAGMA user_version = ${AppDatabase.currentSchemaVersion}', + ); + rawDb.execute(''' + CREATE TABLE diver_settings ( + id TEXT NOT NULL PRIMARY KEY + ) + '''); + rawDb.execute("INSERT INTO diver_settings (id) VALUES ('settings')"); + }, + ); +} + +void main() { + test( + 'v141 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('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 { + final db = AppDatabase(NativeDatabase.memory()); + 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')); + }); + + test( + 'the beforeOpen backstop heals a DB stranded at the current version', + () async { + final db = AppDatabase(_strandedAtCurrent()); + 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')); + }, + ); +} diff --git a/test/core/utils/currency_test.dart b/test/core/utils/currency_test.dart new file mode 100644 index 0000000000..2210c4d7b2 --- /dev/null +++ b/test/core/utils/currency_test.dart @@ -0,0 +1,184 @@ +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'$'); + 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'); + }); + + 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', () { + test('includes the currency symbol', () { + expect(formatMoney(12.5, 'EUR'), contains('€')); + 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/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/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 40f882189f..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'; @@ -183,5 +185,168 @@ 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( + const 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); + }); + + 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..eaefbc65c6 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'; @@ -71,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, @@ -134,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, @@ -204,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'), @@ -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 => {}), + ].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_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_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/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'); + }); + }); +} 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_shared_data_test.dart b/test/features/settings/presentation/pages/settings_page_shared_data_test.dart index c45c07c68d..932d54162c 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 @@ -204,6 +204,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 diff --git a/test/features/settings/presentation/pages/settings_page_test.dart b/test/features/settings/presentation/pages/settings_page_test.dart index c28889c041..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; @@ -99,6 +100,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); @@ -1488,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(); diff --git a/test/features/statistics/presentation/pages/records_page_test.dart b/test/features/statistics/presentation/pages/records_page_test.dart index 5973039c50..db00b3837d 100644 --- a/test/features/statistics/presentation/pages/records_page_test.dart +++ b/test/features/statistics/presentation/pages/records_page_test.dart @@ -85,6 +85,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 27d8511633..0224551f9f 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);