Skip to content
34 changes: 33 additions & 1 deletion lib/core/database/database.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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'))();
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<void> _assertDefaultCurrencyColumn() async {
final cols = await customSelect(
"PRAGMA table_info('diver_settings')",
).get();
if (cols.isEmpty) return;
final names = cols.map((c) => c.read<String>('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
Expand Down Expand Up @@ -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
Expand All @@ -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();
Expand Down
91 changes: 91 additions & 0 deletions lib/core/utils/currency.dart
Original file line number Diff line number Diff line change
@@ -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<String> 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<String> 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<MapEntry<String, double>> sumByCurrency<T>(
Iterable<T> items, {
required double? Function(T item) amountOf,
required String Function(T item) currencyOf,
required String fallbackCode,
}) {
final fallback = fallbackCode.trim().toUpperCase();
final totals = <String, double>{};
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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -195,20 +195,32 @@ class ServiceRecordRepository {
return results.map(_mapCustomRowToServiceRecord).toList();
}

/// Get total cost of services for an equipment item
Future<double> 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<Map<String, double>> 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
Expand Down
10 changes: 6 additions & 4 deletions lib/features/equipment/domain/constants/equipment_field.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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),
Expand All @@ -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) {
Expand Down
Loading