diff --git a/lib/core/database/database.dart b/lib/core/database/database.dart index 58190f8e0b..2c1664c409 100644 --- a/lib/core/database/database.dart +++ b/lib/core/database/database.dart @@ -1772,7 +1772,12 @@ class Certifications extends Table { TextColumn get diverId => text().nullable().references(Divers, #id)(); TextColumn get name => text()(); // e.g., "Open Water Diver" TextColumn get agency => text()(); // PADI, SSI, etc. + // Free-text agency when `agency` == 'other' and the diver's agency isn't in + // the list (issue #806-style escape hatch). + TextColumn get agencyCustom => text().nullable()(); TextColumn get level => text().nullable()(); // For more specific level info + // Free-text level/certification when `level` == 'other'. + TextColumn get levelCustom => text().nullable()(); TextColumn get cardNumber => text().nullable()(); IntColumn get issueDate => integer().nullable()(); IntColumn get expiryDate => integer().nullable()(); // For certs that expire @@ -2952,7 +2957,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 = 148; + static const int currentSchemaVersion = 149; /// Every schema version that has a migration block in onUpgrade. /// Used to calculate progress step counts. When adding a new migration, @@ -3153,6 +3158,8 @@ class AppDatabase extends _$AppDatabase { // index plus the site-side dedupe cleanup and partial unique index // mirroring the dive-side v38 pair. 148, + // v149: free-text agency/level escape hatch for certifications. + 149, ]; /// Idempotent DDL for the v106 connector-suggestion columns (Lightroom @@ -4386,6 +4393,27 @@ class AppDatabase extends _$AppDatabase { } } + /// Idempotent DDL for the v149 certifications free-text agency/level columns. + /// Called from the v149 onUpgrade step and the beforeOpen backstop, and + /// self-guarding when the table is absent (minimal migration-test fixtures). + Future _assertCertificationCustomColumns() async { + final cols = await customSelect( + "PRAGMA table_info('certifications')", + ).get(); + if (cols.isEmpty) return; + final names = cols.map((c) => c.read('name')).toSet(); + if (!names.contains('agency_custom')) { + await customStatement( + 'ALTER TABLE certifications ADD COLUMN agency_custom TEXT', + ); + } + if (!names.contains('level_custom')) { + await customStatement( + 'ALTER TABLE certifications ADD COLUMN level_custom TEXT', + ); + } + } + /// Idempotent DDL for the v142 return-flight column. Called from the v142 /// onUpgrade step and the beforeOpen backstop, matching the /// _assertWeatherCodeColumn pattern so a schema-version collision cannot @@ -7771,6 +7799,11 @@ class AppDatabase extends _$AppDatabase { } } if (from < 148) await reportProgress(); + if (from < 149) { + // Free-text agency/level escape hatch for certifications. + await _assertCertificationCustomColumns(); + } + if (from < 149) await reportProgress(); }, beforeOpen: (details) async { // Enable foreign keys @@ -7795,6 +7828,9 @@ class AppDatabase extends _$AppDatabase { // v141 backstop: re-assert diver_settings.default_currency. await _assertDefaultCurrencyColumn(); + // v148 backstop: re-assert certifications.agency_custom/level_custom. + await _assertCertificationCustomColumns(); + // v106 backstop: re-assert connector-suggestion columns (the helper // is self-guarding when the suggestions table is absent). await _assertConnectorSuggestionColumns(); diff --git a/lib/features/certifications/data/repositories/certification_repository.dart b/lib/features/certifications/data/repositories/certification_repository.dart index 76e8200415..cb024366c6 100644 --- a/lib/features/certifications/data/repositories/certification_repository.dart +++ b/lib/features/certifications/data/repositories/certification_repository.dart @@ -199,7 +199,9 @@ class CertificationRepository { buddyId: Value(cert.buddyId), name: Value(cert.name), agency: Value(cert.agency.name), + agencyCustom: Value(cert.agencyCustom), level: Value(cert.level?.name), + levelCustom: Value(cert.levelCustom), cardNumber: Value(cert.cardNumber), issueDate: Value(cert.issueDate?.millisecondsSinceEpoch), expiryDate: Value(cert.expiryDate?.millisecondsSinceEpoch), @@ -248,7 +250,9 @@ class CertificationRepository { CertificationsCompanion( name: Value(cert.name), agency: Value(cert.agency.name), + agencyCustom: Value(cert.agencyCustom), level: Value(cert.level?.name), + levelCustom: Value(cert.levelCustom), cardNumber: Value(cert.cardNumber), issueDate: Value(cert.issueDate?.millisecondsSinceEpoch), expiryDate: Value(cert.expiryDate?.millisecondsSinceEpoch), @@ -375,7 +379,9 @@ class CertificationRepository { buddyId: row.data['buddy_id'] as String?, name: row.data['name'] as String, agency: _parseCertificationAgency(row.data['agency'] as String), + agencyCustom: row.data['agency_custom'] as String?, level: _parseCertificationLevel(row.data['level'] as String?), + levelCustom: row.data['level_custom'] as String?, cardNumber: row.data['card_number'] as String?, issueDate: _parseDateTime(row.data['issue_date'] as int?), expiryDate: _parseDateTime(row.data['expiry_date'] as int?), @@ -401,7 +407,9 @@ class CertificationRepository { buddyId: row.buddyId, name: row.name, agency: _parseCertificationAgency(row.agency), + agencyCustom: row.agencyCustom, level: _parseCertificationLevel(row.level), + levelCustom: row.levelCustom, cardNumber: row.cardNumber, issueDate: _parseDateTime(row.issueDate), expiryDate: _parseDateTime(row.expiryDate), diff --git a/lib/features/certifications/domain/certification_title.dart b/lib/features/certifications/domain/certification_title.dart index 2345d2fbca..20b19f5fa2 100644 --- a/lib/features/certifications/domain/certification_title.dart +++ b/lib/features/certifications/domain/certification_title.dart @@ -19,10 +19,36 @@ import 'package:submersion/features/certifications/domain/entities/certification /// detail page's Agency row, the picker's subtitle, the PDF's agency line, the /// list's Agency column -- so prefixing here would just trade one duplication /// for another. +/// The agency label to show: the free-text custom agency when [agency] is +/// [CertificationAgency.other] and a custom name was entered, otherwise the +/// enum's display name. +String effectiveAgencyLabel(CertificationAgency agency, String? agencyCustom) { + if (agency == CertificationAgency.other) { + final custom = agencyCustom?.trim(); + if (custom != null && custom.isNotEmpty) return custom; + } + return agency.displayName; +} + +/// The level label to show: the free-text custom level when [level] is +/// [CertificationLevel.other] and a custom name was entered, otherwise the +/// enum's display name. Null when there is no level. +String? effectiveLevelLabel(CertificationLevel? level, String? levelCustom) { + if (level == CertificationLevel.other) { + final custom = levelCustom?.trim(); + if (custom != null && custom.isNotEmpty) return custom; + } + return level?.displayName; +} + String derivedCertificationTitle( CertificationAgency agency, - CertificationLevel? level, -) => level?.displayName ?? agency.displayName; + CertificationLevel? level, { + String? agencyCustom, + String? levelCustom, +}) => + effectiveLevelLabel(level, levelCustom) ?? + effectiveAgencyLabel(agency, agencyCustom); String _normalized(String value) => value.trim().toLowerCase().replaceAll(RegExp(r'\s+'), ' '); @@ -33,15 +59,15 @@ bool hasDerivedName(Certification cert) { final stored = _normalized(cert.name); if (stored.isEmpty) return true; - final agencyName = cert.agency.displayName; - final level = cert.level; + final agencyName = effectiveAgencyLabel(cert.agency, cert.agencyCustom); + final level = effectiveLevelLabel(cert.level, cert.levelCustom); final candidates = [ agencyName, if (level != null) ...[ - '$agencyName ${level.displayName}', - '$agencyName: ${level.displayName}', - '$agencyName : ${level.displayName}', - level.displayName, + '$agencyName $level', + '$agencyName: $level', + '$agencyName : $level', + level, ], ]; return candidates.map(_normalized).contains(stored); @@ -55,11 +81,18 @@ String? customNameOrNull(Certification cert) => /// The title to show for [cert] anywhere one is needed. Never empty. String certificationTitle(Certification cert) => customNameOrNull(cert) ?? - derivedCertificationTitle(cert.agency, cert.level); + derivedCertificationTitle( + cert.agency, + cert.level, + agencyCustom: cert.agencyCustom, + levelCustom: cert.levelCustom, + ); /// The secondary line beneath [certificationTitle]: the level, but only when /// the title is a custom name. When the title is derived it already contains /// the level, and showing it again is the duplication this module exists to /// remove. String? certificationSubtitle(Certification cert) => - customNameOrNull(cert) == null ? null : cert.level?.displayName; + customNameOrNull(cert) == null + ? null + : effectiveLevelLabel(cert.level, cert.levelCustom); diff --git a/lib/features/certifications/domain/entities/certification.dart b/lib/features/certifications/domain/entities/certification.dart index 7c17ee3211..6bb2bad9fd 100644 --- a/lib/features/certifications/domain/entities/certification.dart +++ b/lib/features/certifications/domain/entities/certification.dart @@ -15,7 +15,15 @@ class Certification extends Equatable { final String? buddyId; final String name; final CertificationAgency agency; + + /// Free-text agency name when [agency] is [CertificationAgency.other] and the + /// diver's agency isn't in the list. Null otherwise. + final String? agencyCustom; final CertificationLevel? level; + + /// Free-text level/certification name when [level] is + /// [CertificationLevel.other]. Null otherwise. + final String? levelCustom; final String? cardNumber; final DateTime? issueDate; final DateTime? expiryDate; @@ -34,7 +42,9 @@ class Certification extends Equatable { this.buddyId, required this.name, required this.agency, + this.agencyCustom, this.level, + this.levelCustom, this.cardNumber, this.issueDate, this.expiryDate, @@ -88,7 +98,9 @@ class Certification extends Equatable { String? buddyId, String? name, CertificationAgency? agency, + String? agencyCustom, CertificationLevel? level, + String? levelCustom, String? cardNumber, DateTime? issueDate, DateTime? expiryDate, @@ -107,7 +119,9 @@ class Certification extends Equatable { buddyId: buddyId ?? this.buddyId, name: name ?? this.name, agency: agency ?? this.agency, + agencyCustom: agencyCustom ?? this.agencyCustom, level: level ?? this.level, + levelCustom: levelCustom ?? this.levelCustom, cardNumber: cardNumber ?? this.cardNumber, issueDate: issueDate ?? this.issueDate, expiryDate: expiryDate ?? this.expiryDate, @@ -130,7 +144,9 @@ class Certification extends Equatable { buddyId: buddyId, name: name, agency: agency, + agencyCustom: agencyCustom, level: level, + levelCustom: levelCustom, cardNumber: cardNumber, issueDate: issueDate, expiryDate: expiryDate, @@ -164,7 +180,9 @@ class Certification extends Equatable { buddyId, name, agency, + agencyCustom, level, + levelCustom, cardNumber, issueDate, expiryDate, diff --git a/lib/features/certifications/presentation/pages/certification_edit_page.dart b/lib/features/certifications/presentation/pages/certification_edit_page.dart index de496befea..0825852dbb 100644 --- a/lib/features/certifications/presentation/pages/certification_edit_page.dart +++ b/lib/features/certifications/presentation/pages/certification_edit_page.dart @@ -63,6 +63,9 @@ class _CertificationEditPageState extends ConsumerState { final _instructorNameController = TextEditingController(); final _instructorNumberController = TextEditingController(); final _notesController = TextEditingController(); + // Free-text agency/level, shown only when the respective dropdown is "Other". + final _agencyCustomController = TextEditingController(); + final _levelCustomController = TextEditingController(); CertificationAgency _agency = CertificationAgency.padi; CertificationLevel? _level; @@ -100,6 +103,8 @@ class _CertificationEditPageState extends ConsumerState { _instructorNameController.addListener(_onFieldChanged); _instructorNumberController.addListener(_onFieldChanged); _notesController.addListener(_onFieldChanged); + _agencyCustomController.addListener(_onFieldChanged); + _levelCustomController.addListener(_onFieldChanged); } void _onFieldChanged() { @@ -108,6 +113,20 @@ class _CertificationEditPageState extends ConsumerState { } } + /// The custom agency text, only when the agency is "Other" and non-blank. + String? get _agencyCustomValue { + if (_agency != CertificationAgency.other) return null; + final text = _agencyCustomController.text.trim(); + return text.isEmpty ? null : text; + } + + /// The custom level text, only when the level is "Other" and non-blank. + String? get _levelCustomValue { + if (_level != CertificationLevel.other) return null; + final text = _levelCustomController.text.trim(); + return text.isEmpty ? null : text; + } + /// Prefill the form from an in-memory (possibly unpersisted) certification, /// used by staging mode instead of loading by id. void _prefillFrom(Certification cert) { @@ -119,6 +138,8 @@ class _CertificationEditPageState extends ConsumerState { _instructorNameController.text = cert.instructorName ?? ''; _instructorNumberController.text = cert.instructorNumber ?? ''; _notesController.text = cert.notes; + _agencyCustomController.text = cert.agencyCustom ?? ''; + _levelCustomController.text = cert.levelCustom ?? ''; _agency = cert.agency; _level = cert.level; @@ -143,6 +164,8 @@ class _CertificationEditPageState extends ConsumerState { _instructorNameController.text = cert.instructorName ?? ''; _instructorNumberController.text = cert.instructorNumber ?? ''; _notesController.text = cert.notes; + _agencyCustomController.text = cert.agencyCustom ?? ''; + _levelCustomController.text = cert.levelCustom ?? ''; setState(() { _agency = cert.agency; _level = cert.level; @@ -345,6 +368,8 @@ class _CertificationEditPageState extends ConsumerState { _instructorNameController.dispose(); _instructorNumberController.dispose(); _notesController.dispose(); + _agencyCustomController.dispose(); + _levelCustomController.dispose(); super.dispose(); } @@ -450,6 +475,22 @@ class _CertificationEditPageState extends ConsumerState { ), const SizedBox(height: 16), + // Free-text agency when "Other" is selected. + if (_agency == CertificationAgency.other) ...[ + TextFormField( + key: const Key('cert-agency-custom'), + controller: _agencyCustomController, + decoration: InputDecoration( + labelText: + context.l10n.certifications_edit_label_agencyCustom, + prefixIcon: const Icon(Icons.edit_outlined), + ), + textCapitalization: TextCapitalization.characters, + onChanged: (_) => setState(() => _hasChanges = true), + ), + const SizedBox(height: 16), + ], + // Certification dropdown (options depend on the agency) DropdownButtonFormField( // DropdownButtonFormField keeps its selection in its own @@ -473,6 +514,22 @@ class _CertificationEditPageState extends ConsumerState { ), const SizedBox(height: 16), + // Free-text level/certification when "Other" is selected. + if (_level == CertificationLevel.other) ...[ + TextFormField( + key: const Key('cert-level-custom'), + controller: _levelCustomController, + decoration: InputDecoration( + labelText: + context.l10n.certifications_edit_label_levelCustom, + prefixIcon: const Icon(Icons.edit_outlined), + ), + textCapitalization: TextCapitalization.words, + onChanged: (_) => setState(() => _hasChanges = true), + ), + const SizedBox(height: 16), + ], + // Name on card: optional. Blank means "use the derived // title", which the hint shows live. TextFormField( @@ -481,7 +538,12 @@ class _CertificationEditPageState extends ConsumerState { labelText: context.l10n.certifications_edit_label_nameOnCard, prefixIcon: const Icon(Icons.card_membership), - hintText: derivedCertificationTitle(_agency, _level), + hintText: derivedCertificationTitle( + _agency, + _level, + agencyCustom: _agencyCustomController.text, + levelCustom: _levelCustomController.text, + ), helperText: context.l10n.certifications_edit_helper_nameOnCard, ), @@ -893,7 +955,9 @@ class _CertificationEditPageState extends ConsumerState { buddyId: widget.initialCertification?.buddyId, name: _nameController.text.trim(), agency: _agency, + agencyCustom: _agencyCustomValue, level: _level, + levelCustom: _levelCustomValue, cardNumber: _cardNumberController.text.trim().isEmpty ? null : _cardNumberController.text.trim(), @@ -939,7 +1003,9 @@ class _CertificationEditPageState extends ConsumerState { diverId: diverId, name: _nameController.text.trim(), agency: _agency, + agencyCustom: _agencyCustomValue, level: _level, + levelCustom: _levelCustomValue, cardNumber: _cardNumberController.text.trim().isEmpty ? null : _cardNumberController.text.trim(), diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 5dc0144ad8..99a1b8d701 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -1,5 +1,7 @@ { "universalImport_action_importFromGarmin": "استيراد من جهاز Garmin", + "certifications_edit_label_agencyCustom": "اسم جهة الإصدار", + "certifications_edit_label_levelCustom": "اسم الشهادة", "diveLog_tank_saveAsPreset": "حفظ كإعداد مسبق", "diveLog_tank_saveAsPreset_needSpecs": "أدخل الحجم وضغط العمل أولاً", "diveLog_tank_saveAsPreset_nameTitle": "حفظ إعداد الأسطوانة المسبق", diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 548078740f..5d31b62d27 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -1,5 +1,7 @@ { "universalImport_action_importFromGarmin": "Von Garmin-Gerät importieren", + "certifications_edit_label_agencyCustom": "Name der Agentur", + "certifications_edit_label_levelCustom": "Name der Zertifizierung", "diveLog_tank_saveAsPreset": "Als Vorlage speichern", "diveLog_tank_saveAsPreset_needSpecs": "Zuerst Volumen und Arbeitsdruck eingeben", "diveLog_tank_saveAsPreset_nameTitle": "Flaschenvorlage speichern", diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 43bfbec976..ba674e9c39 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1106,6 +1106,8 @@ "certifications_edit_hint_instructorNumber": "Instructor certification number", "certifications_edit_hint_notes": "Any additional notes", "certifications_edit_label_agency": "Agency *", + "certifications_edit_label_agencyCustom": "Agency name", + "certifications_edit_label_levelCustom": "Certification name", "certifications_edit_label_cardNumber": "Card Number", "certifications_edit_label_certification": "Certification", "certifications_edit_label_expiryDate": "Expiry Date", diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index 569d78df8f..679129edcf 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -1,5 +1,7 @@ { "universalImport_action_importFromGarmin": "Importar desde dispositivo Garmin", + "certifications_edit_label_agencyCustom": "Nombre de la agencia", + "certifications_edit_label_levelCustom": "Nombre de la certificación", "diveLog_tank_saveAsPreset": "Guardar como preajuste", "diveLog_tank_saveAsPreset_needSpecs": "Introduce primero un volumen y una presión de trabajo", "diveLog_tank_saveAsPreset_nameTitle": "Guardar preajuste de tanque", diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index 0834985115..7be4ba7133 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -1,5 +1,7 @@ { "universalImport_action_importFromGarmin": "Importer depuis l'appareil Garmin", + "certifications_edit_label_agencyCustom": "Nom de l'organisme", + "certifications_edit_label_levelCustom": "Nom de la certification", "diveLog_tank_saveAsPreset": "Enregistrer comme preset", "diveLog_tank_saveAsPreset_needSpecs": "Saisissez d'abord un volume et une pression de service", "diveLog_tank_saveAsPreset_nameTitle": "Enregistrer le preset de bloc", diff --git a/lib/l10n/arb/app_he.arb b/lib/l10n/arb/app_he.arb index 2c05fb83c0..129b989d26 100644 --- a/lib/l10n/arb/app_he.arb +++ b/lib/l10n/arb/app_he.arb @@ -1,5 +1,7 @@ { "universalImport_action_importFromGarmin": "ייבוא מהתקן Garmin", + "certifications_edit_label_agencyCustom": "שם הסוכנות", + "certifications_edit_label_levelCustom": "שם ההסמכה", "diveLog_tank_saveAsPreset": "שמור כתבנית", "diveLog_tank_saveAsPreset_needSpecs": "הזן תחילה נפח ולחץ עבודה", "diveLog_tank_saveAsPreset_nameTitle": "שמור תבנית בלון", diff --git a/lib/l10n/arb/app_hu.arb b/lib/l10n/arb/app_hu.arb index 2bb369f01b..860490f18f 100644 --- a/lib/l10n/arb/app_hu.arb +++ b/lib/l10n/arb/app_hu.arb @@ -1,5 +1,7 @@ { "universalImport_action_importFromGarmin": "Importálás Garmin eszközről", + "certifications_edit_label_agencyCustom": "Ügynökség neve", + "certifications_edit_label_levelCustom": "Minősítés neve", "diveLog_tank_saveAsPreset": "Mentés előre beállításként", "diveLog_tank_saveAsPreset_needSpecs": "Először adjon meg térfogatot és üzemi nyomást", "diveLog_tank_saveAsPreset_nameTitle": "Palack előre beállítás mentése", diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index 0cdc0d8017..27c8786ad6 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -1,5 +1,7 @@ { "universalImport_action_importFromGarmin": "Importa da dispositivo Garmin", + "certifications_edit_label_agencyCustom": "Nome dell'agenzia", + "certifications_edit_label_levelCustom": "Nome della certificazione", "diveLog_tank_saveAsPreset": "Salva come preset", "diveLog_tank_saveAsPreset_needSpecs": "Inserisci prima volume e pressione di esercizio", "diveLog_tank_saveAsPreset_nameTitle": "Salva preset bombola", diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index bacebc8ae3..7984b34008 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -3074,6 +3074,18 @@ abstract class AppLocalizations { /// **'Agency *'** String get certifications_edit_label_agency; + /// No description provided for @certifications_edit_label_agencyCustom. + /// + /// In en, this message translates to: + /// **'Agency name'** + String get certifications_edit_label_agencyCustom; + + /// No description provided for @certifications_edit_label_levelCustom. + /// + /// In en, this message translates to: + /// **'Certification name'** + String get certifications_edit_label_levelCustom; + /// No description provided for @certifications_edit_label_cardNumber. /// /// 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 0268489bff..9ebd7d8226 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -1772,6 +1772,12 @@ class AppLocalizationsAr extends AppLocalizations { @override String get certifications_edit_label_agency => 'الجهة المانحة *'; + @override + String get certifications_edit_label_agencyCustom => 'اسم جهة الإصدار'; + + @override + String get certifications_edit_label_levelCustom => 'اسم الشهادة'; + @override String get certifications_edit_label_cardNumber => 'رقم البطاقة'; diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index a01555b9cd..40afb50fbc 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -1806,6 +1806,12 @@ class AppLocalizationsDe extends AppLocalizations { @override String get certifications_edit_label_agency => 'Verband *'; + @override + String get certifications_edit_label_agencyCustom => 'Name der Agentur'; + + @override + String get certifications_edit_label_levelCustom => 'Name der Zertifizierung'; + @override String get certifications_edit_label_cardNumber => 'Kartennummer'; diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index b795e56091..5c4976d324 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -1769,6 +1769,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get certifications_edit_label_agency => 'Agency *'; + @override + String get certifications_edit_label_agencyCustom => 'Agency name'; + + @override + String get certifications_edit_label_levelCustom => 'Certification name'; + @override String get certifications_edit_label_cardNumber => 'Card Number'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index 7218bdc451..39e928ffbc 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -1806,6 +1806,13 @@ class AppLocalizationsEs extends AppLocalizations { @override String get certifications_edit_label_agency => 'Agencia *'; + @override + String get certifications_edit_label_agencyCustom => 'Nombre de la agencia'; + + @override + String get certifications_edit_label_levelCustom => + 'Nombre de la certificación'; + @override String get certifications_edit_label_cardNumber => 'Numero de tarjeta'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index ddf3634a4a..2a1df74ccb 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -1810,6 +1810,12 @@ class AppLocalizationsFr extends AppLocalizations { @override String get certifications_edit_label_agency => 'Organisme *'; + @override + String get certifications_edit_label_agencyCustom => 'Nom de l\'organisme'; + + @override + String get certifications_edit_label_levelCustom => 'Nom de la certification'; + @override String get certifications_edit_label_cardNumber => 'Numero de carte'; diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index 6affaab84d..d8b822b371 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -1754,6 +1754,12 @@ class AppLocalizationsHe extends AppLocalizations { @override String get certifications_edit_label_agency => 'סוכנות *'; + @override + String get certifications_edit_label_agencyCustom => 'שם הסוכנות'; + + @override + String get certifications_edit_label_levelCustom => 'שם ההסמכה'; + @override String get certifications_edit_label_cardNumber => 'מספר כרטיס'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index 01a48bb589..7ca9acd550 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -1793,6 +1793,12 @@ class AppLocalizationsHu extends AppLocalizations { @override String get certifications_edit_label_agency => 'Szervezet *'; + @override + String get certifications_edit_label_agencyCustom => 'Ügynökség neve'; + + @override + String get certifications_edit_label_levelCustom => 'Minősítés neve'; + @override String get certifications_edit_label_cardNumber => 'Kartyaszam'; diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index 5071a6cb88..e45a25b7b0 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -1803,6 +1803,13 @@ class AppLocalizationsIt extends AppLocalizations { @override String get certifications_edit_label_agency => 'Ente *'; + @override + String get certifications_edit_label_agencyCustom => 'Nome dell\'agenzia'; + + @override + String get certifications_edit_label_levelCustom => + 'Nome della certificazione'; + @override String get certifications_edit_label_cardNumber => 'Numero tessera'; diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index 3144904197..08579d98da 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -1793,6 +1793,14 @@ class AppLocalizationsNl extends AppLocalizations { @override String get certifications_edit_label_agency => 'Organisatie *'; + @override + String get certifications_edit_label_agencyCustom => + 'Naam van de organisatie'; + + @override + String get certifications_edit_label_levelCustom => + 'Naam van de certificering'; + @override String get certifications_edit_label_cardNumber => 'Kaartnummer'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index 2d50612f85..9fd98d8318 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -1800,6 +1800,12 @@ class AppLocalizationsPt extends AppLocalizations { @override String get certifications_edit_label_agency => 'Agencia *'; + @override + String get certifications_edit_label_agencyCustom => 'Nome da agência'; + + @override + String get certifications_edit_label_levelCustom => 'Nome da certificação'; + @override String get certifications_edit_label_cardNumber => 'Numero do Cartao'; diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index ce32103e3a..f68bbde56e 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -1697,6 +1697,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get certifications_edit_label_agency => '机构 *'; + @override + String get certifications_edit_label_agencyCustom => '机构名称'; + + @override + String get certifications_edit_label_levelCustom => '认证名称'; + @override String get certifications_edit_label_cardNumber => '卡号'; diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index 3c7d7f3452..31736645e0 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -1,5 +1,7 @@ { "universalImport_action_importFromGarmin": "Importeren vanaf Garmin-apparaat", + "certifications_edit_label_agencyCustom": "Naam van de organisatie", + "certifications_edit_label_levelCustom": "Naam van de certificering", "diveLog_tank_saveAsPreset": "Als voorinstelling opslaan", "diveLog_tank_saveAsPreset_needSpecs": "Voer eerst een volume en werkdruk in", "diveLog_tank_saveAsPreset_nameTitle": "Flesvoorinstelling opslaan", diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index ca56d5269d..f49bd0f973 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -1,5 +1,7 @@ { "universalImport_action_importFromGarmin": "Importar do dispositivo Garmin", + "certifications_edit_label_agencyCustom": "Nome da agência", + "certifications_edit_label_levelCustom": "Nome da certificação", "diveLog_tank_saveAsPreset": "Guardar como preset", "diveLog_tank_saveAsPreset_needSpecs": "Introduza primeiro um volume e uma pressão de trabalho", "diveLog_tank_saveAsPreset_nameTitle": "Guardar preset do cilindro", diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index 9ce7135c0d..c9c7b7f30b 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -1,5 +1,7 @@ { "universalImport_action_importFromGarmin": "从 Garmin 设备导入", + "certifications_edit_label_agencyCustom": "机构名称", + "certifications_edit_label_levelCustom": "认证名称", "diveLog_tank_saveAsPreset": "另存为预设", "diveLog_tank_saveAsPreset_needSpecs": "请先输入容积和工作压力", "diveLog_tank_saveAsPreset_nameTitle": "保存气瓶预设", diff --git a/packages/libdivecomputer_plugin/third_party/libdivecomputer b/packages/libdivecomputer_plugin/third_party/libdivecomputer index 6fc6ca1051..1a47a011a9 160000 --- a/packages/libdivecomputer_plugin/third_party/libdivecomputer +++ b/packages/libdivecomputer_plugin/third_party/libdivecomputer @@ -1 +1 @@ -Subproject commit 6fc6ca1051514f9ef15fe3c0db04b3cb47ac5167 +Subproject commit 1a47a011a9ae2b2253ccb768efd05d37067acb3c diff --git a/test/core/database/migration_v149_certification_custom_test.dart b/test/core/database/migration_v149_certification_custom_test.dart new file mode 100644 index 0000000000..33acaf198b --- /dev/null +++ b/test/core/database/migration_v149_certification_custom_test.dart @@ -0,0 +1,98 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:submersion/core/database/database.dart'; + +void main() { + test('v149 is in the migration ladder', () { + expect(AppDatabase.currentSchemaVersion, greaterThanOrEqualTo(149)); + expect(AppDatabase.migrationVersions, contains(149)); + }); + + test( + 'a fresh database has certifications.agency_custom/level_custom', + () async { + final db = AppDatabase(NativeDatabase.memory()); + addTearDown(db.close); + + final cols = await db + .customSelect("PRAGMA table_info('certifications')") + .get(); + final names = cols.map((c) => c.read('name')).toSet(); + expect(names, contains('agency_custom')); + expect(names, contains('level_custom')); + }, + ); + + test( + 'a database stranded before v149 gains the columns via beforeOpen', + () async { + // Only the columns this migration touches are omitted; the beforeOpen + // backstop must add them even when onUpgrade never ran. + final nativeDb = NativeDatabase.memory( + setup: (rawDb) { + rawDb.execute(''' + CREATE TABLE certifications ( + id TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + agency TEXT NOT NULL, + level TEXT + ) + '''); + }, + ); + final db = AppDatabase(nativeDb); + addTearDown(db.close); + + final cols = await db + .customSelect("PRAGMA table_info('certifications')") + .get(); + final names = cols.map((c) => c.read('name')).toSet(); + expect(names, contains('agency_custom')); + expect(names, contains('level_custom')); + }, + ); + + test('the v149 onUpgrade step adds the columns to a v148 database', () async { + // Stamp user_version=148 so drift runs onUpgrade(148, 149) - the + // migration step itself, not just the beforeOpen backstop. + final nativeDb = NativeDatabase.memory( + setup: (rawDb) { + rawDb.execute(''' + CREATE TABLE certifications ( + id TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + agency TEXT NOT NULL, + level TEXT + ) + '''); + rawDb.execute('PRAGMA user_version = 148'); + }, + ); + final db = AppDatabase(nativeDb); + addTearDown(db.close); + + final cols = await db + .customSelect("PRAGMA table_info('certifications')") + .get(); + final names = cols.map((c) => c.read('name')).toSet(); + expect(names, contains('agency_custom')); + expect(names, contains('level_custom')); + }); + + test( + 'the assert is a no-op when the certifications table is absent', + () async { + final nativeDb = NativeDatabase.memory( + setup: (rawDb) { + rawDb.execute('CREATE TABLE unrelated (id TEXT)'); + }, + ); + final db = AppDatabase(nativeDb); + addTearDown(db.close); + + // Opening must not throw on a minimal fixture. + await db.customSelect('SELECT 1').get(); + }, + ); +} diff --git a/test/features/certifications/domain/certification_title_test.dart b/test/features/certifications/domain/certification_title_test.dart index 3c50c19f4a..14940f4bfa 100644 --- a/test/features/certifications/domain/certification_title_test.dart +++ b/test/features/certifications/domain/certification_title_test.dart @@ -123,4 +123,85 @@ void main() { expect(certificationSubtitle(cert(name: 'Bali OW', level: null)), isNull); }); }); + + group('custom agency/level (free-text "Other")', () { + Certification customCert({ + String name = '', + String? agencyCustom, + String? levelCustom, + }) { + final now = DateTime(2026); + return Certification( + id: 'c1', + name: name, + agency: CertificationAgency.other, + agencyCustom: agencyCustom, + level: CertificationLevel.other, + levelCustom: levelCustom, + createdAt: now, + updatedAt: now, + ); + } + + test('effectiveAgencyLabel uses the custom text for Other', () { + expect(effectiveAgencyLabel(CertificationAgency.other, 'TSA'), 'TSA'); + }); + + test('effectiveAgencyLabel falls back to display name without custom', () { + expect(effectiveAgencyLabel(CertificationAgency.other, null), 'Other'); + expect(effectiveAgencyLabel(CertificationAgency.other, ' '), 'Other'); + }); + + test('effectiveLevelLabel uses the custom text for Other', () { + expect( + effectiveLevelLabel(CertificationLevel.other, 'Full Cave'), + 'Full Cave', + ); + }); + + test('a known agency ignores any stray custom text', () { + expect(effectiveAgencyLabel(CertificationAgency.padi, 'TSA'), 'PADI'); + }); + + test('derived title is the custom level', () { + expect( + derivedCertificationTitle( + CertificationAgency.other, + CertificationLevel.other, + agencyCustom: 'TSA', + levelCustom: 'Full Cave', + ), + 'Full Cave', + ); + }); + + test('title of a custom cert uses the custom level', () { + expect( + certificationTitle( + customCert(agencyCustom: 'TSA', levelCustom: 'Full Cave'), + ), + 'Full Cave', + ); + }); + + test('a name that just repeats the custom label is treated as derived', () { + final c = customCert( + name: 'Full Cave', + agencyCustom: 'TSA', + levelCustom: 'Full Cave', + ); + expect(hasDerivedName(c), isTrue); + expect(customNameOrNull(c), isNull); + }); + + test('a genuinely custom name is kept', () { + final c = customCert( + name: 'Full Cave (Mexico)', + agencyCustom: 'TSA', + levelCustom: 'Full Cave', + ); + expect(hasDerivedName(c), isFalse); + expect(certificationTitle(c), 'Full Cave (Mexico)'); + }); + }); } diff --git a/test/features/certifications/presentation/pages/certification_edit_agency_level_test.dart b/test/features/certifications/presentation/pages/certification_edit_agency_level_test.dart index aa29b33b67..99f25a2b32 100644 --- a/test/features/certifications/presentation/pages/certification_edit_agency_level_test.dart +++ b/test/features/certifications/presentation/pages/certification_edit_agency_level_test.dart @@ -225,4 +225,96 @@ void main() { expect(selectedCertification('Not specified'), findsOneWidget); expect(find.text('Progression'), findsNothing); }); + + group('custom agency/level free-text', () { + testWidgets('an "Other" cert prefills the custom fields with its values', ( + tester, + ) async { + final now = DateTime(2024); + final cert = await repository.createCertification( + Certification( + id: '', + name: '', + agency: CertificationAgency.other, + agencyCustom: 'TSA', + level: CertificationLevel.other, + levelCustom: 'Full Cave', + createdAt: now, + updatedAt: now, + ), + ); + + await tester.pumpWidget( + await buildHarness(tester, certificationId: cert.id), + ); + await tester.pumpAndSettle(); + + // Both free-text fields are shown (agency + level are "Other") and + // prefilled with the stored custom values. Scope to the field keys: + // the level label also renders in the derived-title name hint. + expect( + find.descendant( + of: find.byKey(const Key('cert-agency-custom')), + matching: find.text('TSA'), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: find.byKey(const Key('cert-level-custom')), + matching: find.text('Full Cave'), + ), + findsOneWidget, + ); + }); + + testWidgets('a known agency hides the custom agency field', (tester) async { + await tester.pumpWidget(await buildHarness(tester)); + await tester.pumpAndSettle(); + + // Default agency is PADI -> no custom field. + expect(find.byKey(const Key('cert-agency-custom')), findsNothing); + + await selectFromDropdown(tester, agencyDropdown(), 'Other'); + expect(find.byKey(const Key('cert-agency-custom')), findsOneWidget); + }); + + testWidgets('typing custom agency/level persists on save', (tester) async { + final now = DateTime(2024); + final cert = await repository.createCertification( + Certification( + id: '', + name: '', + agency: CertificationAgency.other, + level: CertificationLevel.other, + createdAt: now, + updatedAt: now, + ), + ); + + await tester.pumpWidget( + await buildHarness(tester, certificationId: cert.id), + ); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const Key('cert-agency-custom')), + 'TSA', + ); + await tester.enterText( + find.byKey(const Key('cert-level-custom')), + 'Full Cave', + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Save')); + await tester.pump(const Duration(seconds: 1)); + + final saved = await tester.runAsync( + () => repository.getCertificationById(cert.id), + ); + expect(saved!.agencyCustom, 'TSA'); + expect(saved.levelCustom, 'Full Cave'); + }); + }); }