From 314da360d17040bce947e9ca2b32fb627cfa4666 Mon Sep 17 00:00:00 2001 From: Cornelius Schmale Date: Wed, 26 Aug 2026 18:51:44 +0200 Subject: [PATCH 1/5] feat: show dive type badges in the dive detail header Adds a row of type badges (Wreck, Night, Drift, etc.) directly below the star rating and OC/CCR badge in the dive detail header, matching that badge's shape but in a lighter, translucent tone so it stays quiet next to the mode indicator while still reading over the header's live map background. When more types are set than fit on one line, they collapse into a "+N" badge with a tooltip listing the rest instead of wrapping. Built-in dive types get short-form abbreviations (Wreck, Tec, Rec, Wrack, Tief, Boot, Safari, ...) via new localized strings, translated across all eleven supported locales. Custom dive types can now set their own short name too (schema v164: dive_types.short_name), added as an optional field on the Add Dive Type dialog and previewed on the Manage Dive Types settings page. Closes #1269. --- lib/core/database/database.dart | 39 ++++++- .../formatters/dive_type_label.dart | 23 ++++ .../presentation/pages/dive_detail_page.dart | 75 ++++++++---- .../presentation/widgets/dive_type_badge.dart | 74 ++++++++++++ .../widgets/dive_type_badge_row.dart | 82 +++++++++++++ .../repositories/dive_type_repository.dart | 4 + .../domain/entities/dive_type_entity.dart | 13 +++ .../presentation/dive_type_display.dart | 27 +++++ .../presentation/pages/dive_types_page.dart | 102 +++++++++++++---- .../providers/dive_type_providers.dart | 7 +- lib/l10n/arb/app_ar.arb | 18 +++ lib/l10n/arb/app_de.arb | 18 +++ lib/l10n/arb/app_en.arb | 18 +++ lib/l10n/arb/app_es.arb | 18 +++ lib/l10n/arb/app_fr.arb | 18 +++ lib/l10n/arb/app_he.arb | 18 +++ lib/l10n/arb/app_hu.arb | 18 +++ lib/l10n/arb/app_it.arb | 18 +++ lib/l10n/arb/app_localizations.dart | 108 ++++++++++++++++++ lib/l10n/arb/app_localizations_ar.dart | 55 +++++++++ lib/l10n/arb/app_localizations_de.dart | 55 +++++++++ lib/l10n/arb/app_localizations_en.dart | 55 +++++++++ lib/l10n/arb/app_localizations_es.dart | 55 +++++++++ lib/l10n/arb/app_localizations_fr.dart | 55 +++++++++ lib/l10n/arb/app_localizations_he.dart | 55 +++++++++ lib/l10n/arb/app_localizations_hu.dart | 55 +++++++++ lib/l10n/arb/app_localizations_it.dart | 55 +++++++++ lib/l10n/arb/app_localizations_nl.dart | 55 +++++++++ lib/l10n/arb/app_localizations_pt.dart | 55 +++++++++ lib/l10n/arb/app_localizations_zh.dart | 54 +++++++++ lib/l10n/arb/app_nl.arb | 18 +++ lib/l10n/arb/app_pt.arb | 18 +++ lib/l10n/arb/app_zh.arb | 18 +++ ...ration_v173_dive_type_short_name_test.dart | 68 +++++++++++ .../formatters/dive_type_label_test.dart | 25 ++++ .../pages/dive_detail_page_test.dart | 21 ++++ .../dive_type_repository_short_name_test.dart | 68 +++++++++++ .../pages/dive_types_page_test.dart | 54 +++++++++ 38 files changed, 1545 insertions(+), 47 deletions(-) create mode 100644 lib/features/dive_log/presentation/widgets/dive_type_badge.dart create mode 100644 lib/features/dive_log/presentation/widgets/dive_type_badge_row.dart create mode 100644 test/core/database/migration_v173_dive_type_short_name_test.dart create mode 100644 test/features/dive_types/data/repositories/dive_type_repository_short_name_test.dart diff --git a/lib/core/database/database.dart b/lib/core/database/database.dart index a41b6dc1ab..99c2464f50 100644 --- a/lib/core/database/database.dart +++ b/lib/core/database/database.dart @@ -2130,6 +2130,11 @@ class DiveTypes extends Table { /// (nullable: rows written before HLC rollout fall back to updatedAt). TextColumn get hlc => text().nullable()(); + /// Abbreviated display form for a custom type (v173). Built-in types never + /// set this -- they use the fixed translated abbreviation in + /// builtInDiveTypeShortName instead. Null means the diver hasn't set one. + TextColumn get shortName => text().nullable()(); + @override Set get primaryKey => {id}; } @@ -3253,7 +3258,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 = 171; + static const int currentSchemaVersion = 173; /// The oldest schema whose reader can apply this build's sync payloads /// without loss or misinterpretation (the compatibility floor). @@ -3592,6 +3597,13 @@ class AppDatabase extends _$AppDatabase { // PR #1320 to 175. This ladder is non-contiguous by design; the audit // asserts monotonic, unique, and scalar == max, never contiguous. 171, + // v173: dive_types.short_name, an optional diver-set abbreviation for + // custom dive types (mirrors the fixed built-in abbreviations). Issue + // #1269 (this PR). Renumbered up from 167, then 171, as main kept + // landing past this branch's claim while it was open; main's own v171 + // comment above already reserves 173 for this PR, so that's the number + // landed here directly. + 173, ]; /// Idempotent DDL for the v106 connector-suggestion columns (Lightroom @@ -5389,6 +5401,21 @@ class AppDatabase extends _$AppDatabase { ); } + /// Idempotent DDL for the v173 dive_types.short_name column: an optional + /// abbreviation a diver can set on a custom dive type (built-ins use the + /// fixed translated abbreviation in builtInDiveTypeShortName instead). + /// Called from the v173 onUpgrade step and the beforeOpen backstop, + /// matching the _assertTripReturnFlightColumn pattern so a schema-version + /// collision cannot strand a database without it. Self-guarding when the + /// table is absent (minimal migration-test fixtures). + Future _assertDiveTypeShortNameColumn() async { + final cols = await customSelect("PRAGMA table_info('dive_types')").get(); + if (cols.isEmpty) return; + final names = cols.map((c) => c.read('name')).toSet(); + if (names.contains('short_name')) return; + await customStatement('ALTER TABLE dive_types ADD COLUMN short_name TEXT'); + } + /// One-time attribution of existing dive_profiles rows to their owning /// dive_data_sources row (issue #1149). /// @@ -8914,6 +8941,12 @@ class AppDatabase extends _$AppDatabase { await _assertTripDayWeatherSchema(); } if (from < 171) await reportProgress(); + // v173: dive_types.short_name, an optional diver-set abbreviation + // for custom dive types. + if (from < 173) { + await _assertDiveTypeShortNameColumn(); + } + if (from < 173) await reportProgress(); }, beforeOpen: (details) async { // Enable foreign keys @@ -9135,6 +9168,10 @@ class AppDatabase extends _$AppDatabase { // every open after the first. await _assertTripDayWeatherSchema(); + // v173 backstop: re-assert dive_types.short_name (same + // parallel-branch version-collision self-heal). + await _assertDiveTypeShortNameColumn(); + // v145 backstop: re-assert the gps_tracks provenance and trim columns. await _assertGpsTrackColumns(); diff --git a/lib/features/dive_log/presentation/formatters/dive_type_label.dart b/lib/features/dive_log/presentation/formatters/dive_type_label.dart index 2033e851ac..e58c8dbc3b 100644 --- a/lib/features/dive_log/presentation/formatters/dive_type_label.dart +++ b/lib/features/dive_log/presentation/formatters/dive_type_label.dart @@ -39,3 +39,26 @@ String diveTypeLabels( Iterable ids, { Map? typesById, }) => ids.map((id) => diveTypeLabel(l10n, id, typesById: typesById)).join(', '); + +/// Short-form counterpart to [diveTypeLabel], for space-constrained surfaces +/// like the dive detail header's type badges. +/// +/// A built-in type uses the fixed translated abbreviation (see +/// [builtInDiveTypeShortName]). A custom type -- including a diver's own row +/// sitting on a built-in slug (see [diveTypeLabel]'s tier 1) -- uses +/// [DiveTypeEntity.shortName] if the diver set one, else falls through to +/// its full [diveTypeLabel]. +String diveTypeShortLabel( + AppLocalizations l10n, + String id, { + Map? typesById, +}) { + final loaded = typesById?[id]; + if (loaded != null && !loaded.isBuiltIn) { + final shortName = loaded.shortName; + if (shortName != null && shortName.trim().isNotEmpty) return shortName; + return diveTypeLabel(l10n, id, typesById: typesById); + } + return builtInDiveTypeShortName(l10n, id) ?? + diveTypeLabel(l10n, id, typesById: typesById); +} diff --git a/lib/features/dive_log/presentation/pages/dive_detail_page.dart b/lib/features/dive_log/presentation/pages/dive_detail_page.dart index cf15b14832..535adaacf6 100644 --- a/lib/features/dive_log/presentation/pages/dive_detail_page.dart +++ b/lib/features/dive_log/presentation/pages/dive_detail_page.dart @@ -40,6 +40,7 @@ import 'package:submersion/features/dive_log/presentation/providers/dive_compute import 'package:submersion/features/dive_log/presentation/providers/dive_detail_ui_providers.dart'; import 'package:submersion/features/dive_log/presentation/providers/dive_providers.dart'; import 'package:submersion/features/dive_log/presentation/widgets/dive_mode_badge.dart'; +import 'package:submersion/features/dive_log/presentation/widgets/dive_type_badge_row.dart'; import 'package:submersion/shared/utils/ink_centered_text_style.dart'; import 'package:submersion/features/dive_log/presentation/widgets/dive_nav_buttons.dart'; import 'package:submersion/features/dive_log/presentation/providers/gas_analysis_providers.dart'; @@ -1225,6 +1226,11 @@ class _DiveDetailPageState extends ConsumerState { final hasLocation = siteLoc != null || hasGps; final colorScheme = Theme.of(context).colorScheme; final cardColor = Theme.of(context).cardColor; + final diveTypesById = { + for (final t + in ref.watch(diveTypesProvider).value ?? const []) + t.id: t, + }; final content = Padding( padding: const EdgeInsets.all(16), @@ -1321,31 +1327,58 @@ class _DiveDetailPageState extends ConsumerState { ], ), ), - Row( + Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, children: [ - if (dive.rating != null) ...[ - ExcludeSemantics( - child: Icon( - Icons.star, - color: Colors.amber.shade600, - size: 20, + Row( + children: [ + if (dive.rating != null) ...[ + ExcludeSemantics( + child: Icon( + Icons.star, + color: Colors.amber.shade600, + size: 20, + ), + ), + const SizedBox(width: 4), + Text( + '${dive.rating}', + // Same ink-centering fix as DiveModeBadge: without + // it this number's default line leading isn't + // split evenly around its own glyph, so it + // doesn't sit on the same visual line as the star + // icon next to it. + style: Theme.of( + context, + ).textTheme.titleMedium?.inkCentered, + textHeightBehavior: inkCenteredTextHeightBehavior, + ), + const SizedBox(width: 8), + ], + DiveModeBadge(mode: dive.diveMode), + ], + ), + if (dive.diveTypeIds.isNotEmpty) ...[ + const SizedBox(height: 8), + // Capped rather than left unbounded: Row hands a + // non-flex child unbounded width, which would let a + // long run of type badges grow without limit instead of + // wrapping under the rating/mode row. + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 200), + child: DiveTypeBadgeRow( + labels: [ + for (final typeId in dive.diveTypeIds) + diveTypeShortLabel( + context.l10n, + typeId, + typesById: diveTypesById, + ), + ], ), ), - const SizedBox(width: 4), - Text( - '${dive.rating}', - // Same ink-centering fix as DiveModeBadge: without it - // this number's default line leading isn't split - // evenly around its own glyph, so it doesn't sit on - // the same visual line as the star icon next to it. - style: Theme.of( - context, - ).textTheme.titleMedium?.inkCentered, - textHeightBehavior: inkCenteredTextHeightBehavior, - ), - const SizedBox(width: 8), ], - DiveModeBadge(mode: dive.diveMode), ], ), ], diff --git a/lib/features/dive_log/presentation/widgets/dive_type_badge.dart b/lib/features/dive_log/presentation/widgets/dive_type_badge.dart new file mode 100644 index 0000000000..ed21b17c75 --- /dev/null +++ b/lib/features/dive_log/presentation/widgets/dive_type_badge.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; + +import 'package:submersion/shared/utils/ink_centered_text_style.dart'; + +/// Badge for one of a dive's types (e.g. Wreck, Night, Drift). +/// +/// Shares [DiveModeBadge]'s bordered-box shape, type scale, and font weight +/// so the two read as one family in the dive detail header, but sits a step +/// quieter: the border uses the lighter outlineVariant tone rather than the +/// mode badge's stronger outline, since a run of several type badges +/// shouldn't compete with the single OC/CCR indicator for attention. The +/// text splits the difference between outlineVariant (too low-contrast to +/// read as text) and onSurfaceVariant (the mode badge's full-strength +/// label color, too bright once several of these sit in a row) -- see +/// [_textColor]. Renders whatever [label] the caller passes in -- callers +/// typically resolve a short-form abbreviation via `diveTypeShortLabel` +/// first, falling back to the full localized name when none is available. +/// +/// Translucent-filled (unlike [DiveModeBadge]'s bare outline): the header +/// can sit this badge over a live map background (see the location card in +/// dive_detail_page.dart), whose gradient scrim is only lightly tinted right +/// where the badge row sits. A fully transparent badge loses all contrast +/// there, but a fully opaque one reads as a heavy, out-of-place block next +/// to the plain-outline mode badge on a flat card -- so this splits the +/// difference with a low-alpha fill: enough of a backing to stay legible +/// over the map, faint enough to disappear into a flat card. +class DiveTypeBadge extends StatelessWidget { + final String label; + + const DiveTypeBadge({super.key, required this.label}); + + /// Matches [DiveModeBadge]'s non-dense font size: close to the header's + /// titleMedium rating number, but a touch under it. + static double fontSizeOf(BuildContext context) => + (Theme.of(context).textTheme.titleMedium?.fontSize ?? 16) - 3; + + /// Midpoint between outlineVariant and onSurfaceVariant: dim enough not to + /// shout next to the mode badge, but still legible as text (outlineVariant + /// alone reads fine as a 1px border, not as a run of glyphs). Derived from + /// the theme tokens rather than a fixed color so it keeps adapting across + /// light/dark and any accent-color theme. + static Color _textColor(ColorScheme colorScheme) => Color.lerp( + colorScheme.outlineVariant, + colorScheme.onSurfaceVariant, + 0.5, + )!; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Container( + // Matches DiveModeBadge's padding, including the asymmetric vertical + // split that compensates for the ink sitting a hair low within the + // tight ascent/descent box textHeightBehavior forces. + padding: const EdgeInsets.only(left: 4, right: 4, top: 2.5, bottom: 3.5), + decoration: BoxDecoration( + color: colorScheme.surface.withValues(alpha: 0.3), + border: Border.all(color: colorScheme.outlineVariant), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + label, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + fontSize: fontSizeOf(context), + color: _textColor(colorScheme), + fontWeight: FontWeight.bold, + ) + .inkCentered, + textHeightBehavior: inkCenteredTextHeightBehavior, + ), + ); + } +} diff --git a/lib/features/dive_log/presentation/widgets/dive_type_badge_row.dart b/lib/features/dive_log/presentation/widgets/dive_type_badge_row.dart new file mode 100644 index 0000000000..3aa2b0cb9d --- /dev/null +++ b/lib/features/dive_log/presentation/widgets/dive_type_badge_row.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; + +import 'package:submersion/features/dive_log/presentation/widgets/dive_type_badge.dart'; + +/// Single-line run of [DiveTypeBadge]s that collapses into a "+N" badge +/// (with a tooltip listing the hidden types) instead of wrapping when the +/// available width can't fit every label. +/// +/// Sits below the OC/CCR mode badge in the dive detail header, where a +/// second line of type badges would push into the stat row underneath -- so +/// this measures labels against the incoming width and always renders +/// exactly one line. +class DiveTypeBadgeRow extends StatelessWidget { + final List labels; + + const DiveTypeBadgeRow({super.key, required this.labels}); + + static const _spacing = 6.0; + + // DiveTypeBadge's horizontal padding (4 each side) plus its 1px border + // each side -- the width a badge adds on top of its text. + static const _badgeChrome = 4.0 * 2 + 1.0 * 2; + + @override + Widget build(BuildContext context) { + if (labels.isEmpty) return const SizedBox.shrink(); + + final style = Theme.of(context).textTheme.labelSmall?.copyWith( + fontSize: DiveTypeBadge.fontSizeOf(context), + fontWeight: FontWeight.bold, + ); + final direction = Directionality.of(context); + + double badgeWidth(String text) { + final painter = TextPainter( + text: TextSpan(text: text, style: style), + textDirection: direction, + maxLines: 1, + )..layout(); + return painter.width + _badgeChrome; + } + + return LayoutBuilder( + builder: (context, constraints) { + final maxWidth = constraints.maxWidth; + var used = 0.0; + var visibleCount = 0; + + for (var i = 0; i < labels.length; i++) { + final isLast = i == labels.length - 1; + final ownWidth = badgeWidth(labels[i]); + final overflowReserve = isLast + ? 0.0 + : _spacing + badgeWidth('+${labels.length - i - 1}'); + final prefix = visibleCount == 0 ? 0.0 : _spacing; + if (used + prefix + ownWidth + overflowReserve > maxWidth) break; + used += prefix + ownWidth; + visibleCount++; + } + + final hidden = labels.sublist(visibleCount); + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < visibleCount; i++) ...[ + if (i > 0) const SizedBox(width: _spacing), + DiveTypeBadge(label: labels[i]), + ], + if (hidden.isNotEmpty) ...[ + if (visibleCount > 0) const SizedBox(width: _spacing), + Tooltip( + message: hidden.join(', '), + child: DiveTypeBadge(label: '+${hidden.length}'), + ), + ], + ], + ); + }, + ); + } +} diff --git a/lib/features/dive_types/data/repositories/dive_type_repository.dart b/lib/features/dive_types/data/repositories/dive_type_repository.dart index e434f8b719..a93338bba7 100644 --- a/lib/features/dive_types/data/repositories/dive_type_repository.dart +++ b/lib/features/dive_types/data/repositories/dive_type_repository.dart @@ -192,6 +192,7 @@ class DiveTypeRepository { ), createdAt: Value(now), updatedAt: Value(now), + shortName: Value(diveType.shortName), ), ); @@ -240,6 +241,7 @@ class DiveTypeRepository { name: Value(diveType.name), sortOrder: Value(diveType.sortOrder), updatedAt: Value(now), + shortName: Value(diveType.shortName), ), ); await _syncRepository.markRecordPending( @@ -343,6 +345,7 @@ class DiveTypeRepository { updatedAt: DateTime.fromMillisecondsSinceEpoch( row.data['updated_at'] as int, ), + shortName: row.data['short_name'] as String?, ), diveCount: row.data['dive_count'] as int, ), @@ -400,6 +403,7 @@ class DiveTypeRepository { sortOrder: row.sortOrder, createdAt: DateTime.fromMillisecondsSinceEpoch(row.createdAt), updatedAt: DateTime.fromMillisecondsSinceEpoch(row.updatedAt), + shortName: row.shortName, ); } } diff --git a/lib/features/dive_types/domain/entities/dive_type_entity.dart b/lib/features/dive_types/domain/entities/dive_type_entity.dart index 302e20d2fe..d28d221158 100644 --- a/lib/features/dive_types/domain/entities/dive_type_entity.dart +++ b/lib/features/dive_types/domain/entities/dive_type_entity.dart @@ -10,6 +10,13 @@ class DiveTypeEntity extends Equatable { final DateTime createdAt; final DateTime updatedAt; + /// Abbreviated form for space-constrained surfaces like the dive detail + /// header's type badges (e.g. "Wreck" -> "Wreck", "Search & Recovery" -> + /// "S&R"). Only settable on custom types -- built-ins use the fixed + /// translated abbreviation in [builtInDiveTypeShortName] instead. Null + /// means the diver hasn't set one, so callers fall back to [name]. + final String? shortName; + const DiveTypeEntity({ required this.id, this.diverId, @@ -18,6 +25,7 @@ class DiveTypeEntity extends Equatable { this.sortOrder = 0, required this.createdAt, required this.updatedAt, + this.shortName, }); /// Create a new custom dive type @@ -26,6 +34,7 @@ class DiveTypeEntity extends Equatable { required String name, String? diverId, int sortOrder = 0, + String? shortName, }) { final now = DateTime.now(); return DiveTypeEntity( @@ -36,6 +45,7 @@ class DiveTypeEntity extends Equatable { sortOrder: sortOrder, createdAt: now, updatedAt: now, + shortName: shortName, ); } @@ -56,6 +66,7 @@ class DiveTypeEntity extends Equatable { int? sortOrder, DateTime? createdAt, DateTime? updatedAt, + String? shortName, }) { return DiveTypeEntity( id: id ?? this.id, @@ -65,6 +76,7 @@ class DiveTypeEntity extends Equatable { sortOrder: sortOrder ?? this.sortOrder, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, + shortName: shortName ?? this.shortName, ); } @@ -77,5 +89,6 @@ class DiveTypeEntity extends Equatable { sortOrder, createdAt, updatedAt, + shortName, ]; } diff --git a/lib/features/dive_types/presentation/dive_type_display.dart b/lib/features/dive_types/presentation/dive_type_display.dart index b49fb43971..dff6b5f74a 100644 --- a/lib/features/dive_types/presentation/dive_type_display.dart +++ b/lib/features/dive_types/presentation/dive_type_display.dart @@ -31,6 +31,33 @@ String? builtInDiveTypeName(AppLocalizations l10n, String id) => switch (id) { _ => null, }; +/// Short-form localized name for a built-in dive type (e.g. "Wreck" -> "Wreck", +/// "Technisches Tauchen" -> "Tec"), for space-constrained surfaces like the +/// dive detail header's type badges. +/// +/// Returns null for anything that is not a known built-in slug; callers should +/// handle custom dive types separately (e.g. `DiveTypeEntity.shortName` or the +/// full display name). +String? builtInDiveTypeShortName(AppLocalizations l10n, String id) => + switch (id) { + 'recreational' => l10n.diveType_builtin_recreational_short, + 'technical' => l10n.diveType_builtin_technical_short, + 'freedive' => l10n.diveType_builtin_freedive_short, + 'training' => l10n.diveType_builtin_training_short, + 'wreck' => l10n.diveType_builtin_wreck_short, + 'cave' => l10n.diveType_builtin_cave_short, + 'ice' => l10n.diveType_builtin_ice_short, + 'night' => l10n.diveType_builtin_night_short, + 'drift' => l10n.diveType_builtin_drift_short, + 'deep' => l10n.diveType_builtin_deep_short, + 'altitude' => l10n.diveType_builtin_altitude_short, + 'shore' => l10n.diveType_builtin_shore_short, + 'boat' => l10n.diveType_builtin_boat_short, + 'liveaboard' => l10n.diveType_builtin_liveaboard_short, + 'cavern' => l10n.diveType_builtin_cavern_short, + _ => null, + }; + extension DiveTypeDisplay on DiveTypeEntity { /// Localized name for built-in types; the stored name for custom types. /// diff --git a/lib/features/dive_types/presentation/pages/dive_types_page.dart b/lib/features/dive_types/presentation/pages/dive_types_page.dart index f8c373055d..1f9ed32577 100644 --- a/lib/features/dive_types/presentation/pages/dive_types_page.dart +++ b/lib/features/dive_types/presentation/pages/dive_types_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:submersion/core/providers/provider.dart'; import 'package:go_router/go_router.dart'; +import 'package:submersion/features/dive_log/presentation/widgets/dive_type_badge.dart'; import 'package:submersion/features/dive_types/domain/entities/dive_type_entity.dart'; import 'package:submersion/features/dive_types/presentation/dive_type_display.dart'; import 'package:submersion/features/dive_types/presentation/providers/dive_type_providers.dart'; @@ -83,6 +84,21 @@ class DiveTypesPage extends ConsumerWidget { DiveTypeEntity diveType, { required bool canDelete, }) { + // Previewed here so a diver knows what a header badge collapses their + // selection to: the fixed translated abbreviation for a built-in type + // (see builtInDiveTypeShortName), or the diver's own short name for a + // custom one. Hidden when there's nothing to preview -- no short name + // set, or (for a built-in) identical to the full name (e.g. English + // "Wreck"). + final fullName = diveType.localizedName(context.l10n); + final builtInShort = canDelete + ? null + : builtInDiveTypeShortName(context.l10n, diveType.id); + final customShort = canDelete ? diveType.shortName?.trim() : null; + final shortName = canDelete + ? (customShort?.isNotEmpty == true ? customShort : null) + : (builtInShort == fullName ? null : builtInShort); + return ListTile( leading: Icon( canDelete ? Icons.label_outline : Icons.label, @@ -90,17 +106,25 @@ class DiveTypesPage extends ConsumerWidget { ? Theme.of(context).colorScheme.secondary : Theme.of(context).colorScheme.primary, ), - title: Text(diveType.localizedName(context.l10n)), + title: Text(fullName), subtitle: canDelete ? Text(context.l10n.diveTypes_custom) : Text(context.l10n.diveTypes_builtIn), - trailing: canDelete - ? IconButton( + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (shortName != null) ...[ + DiveTypeBadge(label: shortName), + if (canDelete) const SizedBox(width: 8), + ], + if (canDelete) + IconButton( icon: const Icon(Icons.delete_outline), onPressed: () => _confirmDelete(context, ref, diveType), tooltip: context.l10n.diveTypes_deleteTooltip, - ) - : null, + ), + ], + ), ); } @@ -109,28 +133,50 @@ class DiveTypesPage extends ConsumerWidget { WidgetRef ref, ) async { final nameController = TextEditingController(); + final shortNameController = TextEditingController(); final formKey = GlobalKey(); - final result = await showDialog( + final result = await showDialog<({String name, String? shortName})>( context: context, builder: (dialogContext) => AlertDialog( title: Text(dialogContext.l10n.diveTypes_addDialog_title), content: Form( key: formKey, - child: TextFormField( - controller: nameController, - autofocus: true, - decoration: InputDecoration( - labelText: dialogContext.l10n.diveTypes_addDialog_nameLabel, - hintText: dialogContext.l10n.diveTypes_addDialog_nameHint, - ), - textCapitalization: TextCapitalization.words, - validator: (value) { - if (value == null || value.trim().isEmpty) { - return dialogContext.l10n.diveTypes_addDialog_nameValidation; - } - return null; - }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + controller: nameController, + autofocus: true, + decoration: InputDecoration( + labelText: dialogContext.l10n.diveTypes_addDialog_nameLabel, + hintText: dialogContext.l10n.diveTypes_addDialog_nameHint, + ), + textCapitalization: TextCapitalization.words, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return dialogContext + .l10n + .diveTypes_addDialog_nameValidation; + } + return null; + }, + ), + const SizedBox(height: 12), + TextFormField( + controller: shortNameController, + decoration: InputDecoration( + labelText: + dialogContext.l10n.diveTypes_addDialog_shortNameLabel, + hintText: + dialogContext.l10n.diveTypes_addDialog_shortNameHint, + helperText: + dialogContext.l10n.diveTypes_addDialog_shortNameHelper, + helperMaxLines: 2, + ), + textCapitalization: TextCapitalization.words, + ), + ], ), ), actions: [ @@ -141,7 +187,12 @@ class DiveTypesPage extends ConsumerWidget { FilledButton( onPressed: () { if (formKey.currentState!.validate()) { - Navigator.of(dialogContext).pop(nameController.text.trim()); + Navigator.of(dialogContext).pop(( + name: nameController.text.trim(), + shortName: shortNameController.text.trim().isEmpty + ? null + : shortNameController.text.trim(), + )); } }, child: Text(dialogContext.l10n.diveTypes_addDialog_addButton), @@ -150,14 +201,17 @@ class DiveTypesPage extends ConsumerWidget { ), ); - if (result != null && result.isNotEmpty) { + if (result != null && result.name.isNotEmpty) { try { final notifier = ref.read(diveTypeListNotifierProvider.notifier); - await notifier.addDiveTypeByName(result); + await notifier.addDiveTypeByName( + result.name, + shortName: result.shortName, + ); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(context.l10n.diveTypes_snackbar_added(result)), + content: Text(context.l10n.diveTypes_snackbar_added(result.name)), ), ); } diff --git a/lib/features/dive_types/presentation/providers/dive_type_providers.dart b/lib/features/dive_types/presentation/providers/dive_type_providers.dart index c9abb2dc5b..d6ef7be202 100644 --- a/lib/features/dive_types/presentation/providers/dive_type_providers.dart +++ b/lib/features/dive_types/presentation/providers/dive_type_providers.dart @@ -179,7 +179,10 @@ class DiveTypeListNotifier /// Add a custom dive type by name (generates ID automatically) /// Throws if no valid diver profile exists - Future addDiveTypeByName(String name) async { + Future addDiveTypeByName( + String name, { + String? shortName, + }) async { // Get fresh validated diver ID before creating final validatedId = await _ref.read(validatedCurrentDiverIdProvider.future); @@ -187,10 +190,12 @@ class DiveTypeListNotifier throw Exception('Cannot create custom dive type without a diver profile'); } + final trimmedShortName = shortName?.trim(); final diveType = DiveTypeEntity.create( id: DiveTypeEntity.generateSlug(name), name: name.trim(), diverId: validatedId, + shortName: trimmedShortName?.isNotEmpty == true ? trimmedShortName : null, ); return addDiveType(diveType); } diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 959b84d371..07ec85c50b 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -2959,24 +2959,42 @@ "diveSites_summary_stat_totalSites": "إجمالي المواقع", "diveSites_summary_stat_withGps": "مع GPS", "diveType_builtin_altitude": "ارتفاع", + "diveType_builtin_altitude_short": "ارتفاع", "diveType_builtin_boat": "من القارب", + "diveType_builtin_boat_short": "قارب", "diveType_builtin_cave": "كهف", + "diveType_builtin_cave_short": "كهف", "diveType_builtin_cavern": "كهف ضحل", + "diveType_builtin_cavern_short": "كهف ضحل", "diveType_builtin_deep": "عميق", + "diveType_builtin_deep_short": "عميق", "diveType_builtin_drift": "انجراف", + "diveType_builtin_drift_short": "انجراف", "diveType_builtin_freedive": "غطس حر", + "diveType_builtin_freedive_short": "غطس حر", "diveType_builtin_ice": "جليد", + "diveType_builtin_ice_short": "جليد", "diveType_builtin_liveaboard": "رحلة غوص بحرية", + "diveType_builtin_liveaboard_short": "رحلة غوص", "diveType_builtin_night": "ليلي", + "diveType_builtin_night_short": "ليلي", "diveType_builtin_recreational": "ترفيهي", + "diveType_builtin_recreational_short": "ترفيه", "diveType_builtin_shore": "من الشاطئ", + "diveType_builtin_shore_short": "شاطئ", "diveType_builtin_technical": "تقني", + "diveType_builtin_technical_short": "تقني", "diveType_builtin_training": "تدريب", + "diveType_builtin_training_short": "تدريب", "diveType_builtin_wreck": "حطام", + "diveType_builtin_wreck_short": "حطام", "diveTypes_addDialog_addButton": "إضافة", "diveTypes_addDialog_nameHint": "مثال: البحث والإنقاذ", "diveTypes_addDialog_nameLabel": "اسم نوع الغوص", "diveTypes_addDialog_nameValidation": "الرجاء إدخال اسم", + "diveTypes_addDialog_shortNameHelper": "يظهر في رأس تفاصيل الغطسة عند ضيق المساحة", + "diveTypes_addDialog_shortNameHint": "مثال: ب.إ", + "diveTypes_addDialog_shortNameLabel": "اسم مختصر (اختياري)", "diveTypes_addDialog_title": "إضافة نوع غوص مخصص", "diveTypes_addTooltip": "إضافة نوع غوص", "diveTypes_appBar_title": "أنواع الغوص", diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index e84df0a8be..88419c87b7 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -2959,24 +2959,42 @@ "diveSites_summary_stat_totalSites": "Tauchplätze gesamt", "diveSites_summary_stat_withGps": "Mit GPS", "diveType_builtin_altitude": "Bergseetauchen", + "diveType_builtin_altitude_short": "Bergsee", "diveType_builtin_boat": "Bootstauchgang", + "diveType_builtin_boat_short": "Boot", "diveType_builtin_cave": "Höhlentauchen", + "diveType_builtin_cave_short": "Höhle", "diveType_builtin_cavern": "Cavern", + "diveType_builtin_cavern_short": "Cavern", "diveType_builtin_deep": "Tieftauchen", + "diveType_builtin_deep_short": "Tief", "diveType_builtin_drift": "Strömungstauchen", + "diveType_builtin_drift_short": "Strömung", "diveType_builtin_freedive": "Apnoetauchen", + "diveType_builtin_freedive_short": "Apnoe", "diveType_builtin_ice": "Eistauchen", + "diveType_builtin_ice_short": "Eis", "diveType_builtin_liveaboard": "Tauchsafari", + "diveType_builtin_liveaboard_short": "Safari", "diveType_builtin_night": "Nachttauchen", + "diveType_builtin_night_short": "Nacht", "diveType_builtin_recreational": "Sporttauchen", + "diveType_builtin_recreational_short": "Rec", "diveType_builtin_shore": "Ufertauchgang", + "diveType_builtin_shore_short": "Ufer", "diveType_builtin_technical": "Technisches Tauchen", + "diveType_builtin_technical_short": "Tec", "diveType_builtin_training": "Ausbildung", + "diveType_builtin_training_short": "Kurs", "diveType_builtin_wreck": "Wracktauchen", + "diveType_builtin_wreck_short": "Wrack", "diveTypes_addDialog_addButton": "Hinzufügen", "diveTypes_addDialog_nameHint": "z.B. Suche & Bergung", "diveTypes_addDialog_nameLabel": "Tauchgangstyp-Name", "diveTypes_addDialog_nameValidation": "Bitte geben Sie einen Namen ein", + "diveTypes_addDialog_shortNameHelper": "Wird im Tauchgang-Header angezeigt, wenn der Platz knapp ist", + "diveTypes_addDialog_shortNameHint": "z.B. S&B", + "diveTypes_addDialog_shortNameLabel": "Kurzname (optional)", "diveTypes_addDialog_title": "Benutzerdefinierten Tauchgangstyp hinzufügen", "diveTypes_addTooltip": "Tauchgangstyp hinzufügen", "diveTypes_appBar_title": "Tauchgangstypen", diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 9f5f0603c7..7c4f2cc6f5 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -5440,24 +5440,42 @@ } }, "diveType_builtin_altitude": "Altitude", + "diveType_builtin_altitude_short": "Alt", "diveType_builtin_boat": "Boat", + "diveType_builtin_boat_short": "Boat", "diveType_builtin_cave": "Cave", + "diveType_builtin_cave_short": "Cave", "diveType_builtin_cavern": "Cavern", + "diveType_builtin_cavern_short": "Cavern", "diveType_builtin_deep": "Deep", + "diveType_builtin_deep_short": "Deep", "diveType_builtin_drift": "Drift", + "diveType_builtin_drift_short": "Drift", "diveType_builtin_freedive": "Freedive", + "diveType_builtin_freedive_short": "Free", "diveType_builtin_ice": "Ice", + "diveType_builtin_ice_short": "Ice", "diveType_builtin_liveaboard": "Liveaboard", + "diveType_builtin_liveaboard_short": "Live", "diveType_builtin_night": "Night", + "diveType_builtin_night_short": "Night", "diveType_builtin_recreational": "Recreational", + "diveType_builtin_recreational_short": "Rec", "diveType_builtin_shore": "Shore", + "diveType_builtin_shore_short": "Shore", "diveType_builtin_technical": "Technical", + "diveType_builtin_technical_short": "Tec", "diveType_builtin_training": "Training", + "diveType_builtin_training_short": "Training", "diveType_builtin_wreck": "Wreck", + "diveType_builtin_wreck_short": "Wreck", "diveTypes_addDialog_addButton": "Add", "diveTypes_addDialog_nameHint": "e.g., Search & Recovery", "diveTypes_addDialog_nameLabel": "Dive Type Name", "diveTypes_addDialog_nameValidation": "Please enter a name", + "diveTypes_addDialog_shortNameHelper": "Shown on the dive detail header when space is tight", + "diveTypes_addDialog_shortNameHint": "e.g., S&R", + "diveTypes_addDialog_shortNameLabel": "Short name (optional)", "diveTypes_addDialog_title": "Add Custom Dive Type", "diveTypes_addTooltip": "Add dive type", "diveTypes_appBar_title": "Dive Types", diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index 526fd3e0fa..a7f28486c2 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -2959,24 +2959,42 @@ "diveSites_summary_stat_totalSites": "Total de sitios", "diveSites_summary_stat_withGps": "Con GPS", "diveType_builtin_altitude": "Altitud", + "diveType_builtin_altitude_short": "Altitud", "diveType_builtin_boat": "Desde barco", + "diveType_builtin_boat_short": "Barco", "diveType_builtin_cave": "Cueva", + "diveType_builtin_cave_short": "Cueva", "diveType_builtin_cavern": "Caverna", + "diveType_builtin_cavern_short": "Caverna", "diveType_builtin_deep": "Profunda", + "diveType_builtin_deep_short": "Profunda", "diveType_builtin_drift": "Deriva", + "diveType_builtin_drift_short": "Deriva", "diveType_builtin_freedive": "Apnea", + "diveType_builtin_freedive_short": "Apnea", "diveType_builtin_ice": "Hielo", + "diveType_builtin_ice_short": "Hielo", "diveType_builtin_liveaboard": "Crucero de buceo", + "diveType_builtin_liveaboard_short": "Crucero", "diveType_builtin_night": "Nocturna", + "diveType_builtin_night_short": "Nocturna", "diveType_builtin_recreational": "Recreativa", + "diveType_builtin_recreational_short": "Rec", "diveType_builtin_shore": "Desde costa", + "diveType_builtin_shore_short": "Costa", "diveType_builtin_technical": "Técnica", + "diveType_builtin_technical_short": "Tec", "diveType_builtin_training": "Formación", + "diveType_builtin_training_short": "Formación", "diveType_builtin_wreck": "Pecio", + "diveType_builtin_wreck_short": "Pecio", "diveTypes_addDialog_addButton": "Agregar", "diveTypes_addDialog_nameHint": "ej., Búsqueda y Recuperación", "diveTypes_addDialog_nameLabel": "Nombre del Tipo de Inmersión", "diveTypes_addDialog_nameValidation": "Por favor ingresa un nombre", + "diveTypes_addDialog_shortNameHelper": "Se muestra en el encabezado del detalle de inmersión cuando hay poco espacio", + "diveTypes_addDialog_shortNameHint": "ej., ByR", + "diveTypes_addDialog_shortNameLabel": "Nombre corto (opcional)", "diveTypes_addDialog_title": "Agregar Tipo de Inmersión Personalizado", "diveTypes_addTooltip": "Agregar tipo de inmersión", "diveTypes_appBar_title": "Tipos de Inmersión", diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index abb02ed6aa..10d0190314 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -2886,24 +2886,42 @@ "diveSites_summary_stat_totalSites": "Total sites", "diveSites_summary_stat_withGps": "Avec GPS", "diveType_builtin_altitude": "Altitude", + "diveType_builtin_altitude_short": "Altitude", "diveType_builtin_boat": "Depuis un bateau", + "diveType_builtin_boat_short": "Bateau", "diveType_builtin_cave": "Grotte", + "diveType_builtin_cave_short": "Grotte", "diveType_builtin_cavern": "Caverne", + "diveType_builtin_cavern_short": "Caverne", "diveType_builtin_deep": "Profonde", + "diveType_builtin_deep_short": "Profonde", "diveType_builtin_drift": "Dérive", + "diveType_builtin_drift_short": "Dérive", "diveType_builtin_freedive": "Apnée", + "diveType_builtin_freedive_short": "Apnée", "diveType_builtin_ice": "Sous glace", + "diveType_builtin_ice_short": "Glace", "diveType_builtin_liveaboard": "Croisière plongée", + "diveType_builtin_liveaboard_short": "Croisière", "diveType_builtin_night": "Nuit", + "diveType_builtin_night_short": "Nuit", "diveType_builtin_recreational": "Loisir", + "diveType_builtin_recreational_short": "Rec", "diveType_builtin_shore": "Depuis la côte", + "diveType_builtin_shore_short": "Côte", "diveType_builtin_technical": "Technique", + "diveType_builtin_technical_short": "Tec", "diveType_builtin_training": "Formation", + "diveType_builtin_training_short": "Formation", "diveType_builtin_wreck": "Épave", + "diveType_builtin_wreck_short": "Épave", "diveTypes_addDialog_addButton": "Ajouter", "diveTypes_addDialog_nameHint": "ex. Recherche et récupération", "diveTypes_addDialog_nameLabel": "Nom du type de plongée", "diveTypes_addDialog_nameValidation": "Veuillez entrer un nom", + "diveTypes_addDialog_shortNameHelper": "Affiché dans l'en-tête du détail de plongée quand la place manque", + "diveTypes_addDialog_shortNameHint": "ex. RR", + "diveTypes_addDialog_shortNameLabel": "Nom court (facultatif)", "diveTypes_addDialog_title": "Ajouter un type de plongée personnalisé", "diveTypes_addTooltip": "Ajouter un type de plongée", "diveTypes_appBar_title": "Types de plongée", diff --git a/lib/l10n/arb/app_he.arb b/lib/l10n/arb/app_he.arb index 15dcf9310c..3f9eab794f 100644 --- a/lib/l10n/arb/app_he.arb +++ b/lib/l10n/arb/app_he.arb @@ -2886,24 +2886,42 @@ "diveSites_summary_stat_totalSites": "סה\"כ אתרים", "diveSites_summary_stat_withGps": "עם GPS", "diveType_builtin_altitude": "גובה רב", + "diveType_builtin_altitude_short": "גובה", "diveType_builtin_boat": "מסירה", + "diveType_builtin_boat_short": "מסירה", "diveType_builtin_cave": "מערה", + "diveType_builtin_cave_short": "מערה", "diveType_builtin_cavern": "מערה פתוחה", + "diveType_builtin_cavern_short": "מערה פתוחה", "diveType_builtin_deep": "עמוקה", + "diveType_builtin_deep_short": "עמוקה", "diveType_builtin_drift": "סחף", + "diveType_builtin_drift_short": "סחף", "diveType_builtin_freedive": "צלילה חופשית", + "diveType_builtin_freedive_short": "חופשית", "diveType_builtin_ice": "קרח", + "diveType_builtin_ice_short": "קרח", "diveType_builtin_liveaboard": "שייט צלילה", + "diveType_builtin_liveaboard_short": "שייט", "diveType_builtin_night": "לילה", + "diveType_builtin_night_short": "לילה", "diveType_builtin_recreational": "ספורטיבי", + "diveType_builtin_recreational_short": "ספורט", "diveType_builtin_shore": "מהחוף", + "diveType_builtin_shore_short": "מהחוף", "diveType_builtin_technical": "טכני", + "diveType_builtin_technical_short": "טכני", "diveType_builtin_training": "הכשרה", + "diveType_builtin_training_short": "הכשרה", "diveType_builtin_wreck": "ספינה טבועה", + "diveType_builtin_wreck_short": "טבועה", "diveTypes_addDialog_addButton": "הוסף", "diveTypes_addDialog_nameHint": "לדוגמה: חיפוש ושחזור", "diveTypes_addDialog_nameLabel": "שם סוג צלילה", "diveTypes_addDialog_nameValidation": "נא להזין שם", + "diveTypes_addDialog_shortNameHelper": "מוצג בכותרת פרטי הצלילה כשאין הרבה מקום", + "diveTypes_addDialog_shortNameHint": "לדוגמה: ח.ש", + "diveTypes_addDialog_shortNameLabel": "שם קצר (אופציונלי)", "diveTypes_addDialog_title": "הוסף סוג צלילה מותאם", "diveTypes_addTooltip": "הוסף סוג צלילה", "diveTypes_appBar_title": "סוגי צלילה", diff --git a/lib/l10n/arb/app_hu.arb b/lib/l10n/arb/app_hu.arb index 9b4d9434f3..0f88d55527 100644 --- a/lib/l10n/arb/app_hu.arb +++ b/lib/l10n/arb/app_hu.arb @@ -2886,24 +2886,42 @@ "diveSites_summary_stat_totalSites": "Osszes helyszin", "diveSites_summary_stat_withGps": "GPS-szel", "diveType_builtin_altitude": "Magaslati", + "diveType_builtin_altitude_short": "Magaslati", "diveType_builtin_boat": "Hajóról", + "diveType_builtin_boat_short": "Hajó", "diveType_builtin_cave": "Barlang", + "diveType_builtin_cave_short": "Barlang", "diveType_builtin_cavern": "Barlangbejárat", + "diveType_builtin_cavern_short": "Bejárat", "diveType_builtin_deep": "Mély", + "diveType_builtin_deep_short": "Mély", "diveType_builtin_drift": "Sodrásos", + "diveType_builtin_drift_short": "Sodrásos", "diveType_builtin_freedive": "Szabadtüdős", + "diveType_builtin_freedive_short": "Szabad", "diveType_builtin_ice": "Jég", + "diveType_builtin_ice_short": "Jég", "diveType_builtin_liveaboard": "Búvárhajós", + "diveType_builtin_liveaboard_short": "Szafari", "diveType_builtin_night": "Éjszakai", + "diveType_builtin_night_short": "Éjszakai", "diveType_builtin_recreational": "Szabadidős", + "diveType_builtin_recreational_short": "Rec", "diveType_builtin_shore": "Partról", + "diveType_builtin_shore_short": "Part", "diveType_builtin_technical": "Technikai", + "diveType_builtin_technical_short": "Tec", "diveType_builtin_training": "Képzés", + "diveType_builtin_training_short": "Képzés", "diveType_builtin_wreck": "Roncs", + "diveType_builtin_wreck_short": "Roncs", "diveTypes_addDialog_addButton": "Hozzáadás", "diveTypes_addDialog_nameHint": "pl. Kutatás és mentés", "diveTypes_addDialog_nameLabel": "Merülés típus neve", "diveTypes_addDialog_nameValidation": "Adj meg egy nevet", + "diveTypes_addDialog_shortNameHelper": "A merülés részletek fejlécében jelenik meg, ha kevés a hely", + "diveTypes_addDialog_shortNameHint": "pl. K&M", + "diveTypes_addDialog_shortNameLabel": "Rövid név (opcionális)", "diveTypes_addDialog_title": "Egyedi merülés típus hozzáadása", "diveTypes_addTooltip": "Merülés típus hozzáadása", "diveTypes_appBar_title": "Merülés típusok", diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index 035b4a5986..012e8f5dff 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -2886,24 +2886,42 @@ "diveSites_summary_stat_totalSites": "Siti totali", "diveSites_summary_stat_withGps": "Con GPS", "diveType_builtin_altitude": "Altitudine", + "diveType_builtin_altitude_short": "Altitudine", "diveType_builtin_boat": "Da barca", + "diveType_builtin_boat_short": "Barca", "diveType_builtin_cave": "Grotta", + "diveType_builtin_cave_short": "Grotta", "diveType_builtin_cavern": "Caverna", + "diveType_builtin_cavern_short": "Caverna", "diveType_builtin_deep": "Profonda", + "diveType_builtin_deep_short": "Profonda", "diveType_builtin_drift": "Corrente", + "diveType_builtin_drift_short": "Corrente", "diveType_builtin_freedive": "Apnea", + "diveType_builtin_freedive_short": "Apnea", "diveType_builtin_ice": "Sotto ghiaccio", + "diveType_builtin_ice_short": "Ghiaccio", "diveType_builtin_liveaboard": "Crociera subacquea", + "diveType_builtin_liveaboard_short": "Crociera", "diveType_builtin_night": "Notturna", + "diveType_builtin_night_short": "Notturna", "diveType_builtin_recreational": "Ricreativa", + "diveType_builtin_recreational_short": "Rec", "diveType_builtin_shore": "Da riva", + "diveType_builtin_shore_short": "Riva", "diveType_builtin_technical": "Tecnica", + "diveType_builtin_technical_short": "Tec", "diveType_builtin_training": "Addestramento", + "diveType_builtin_training_short": "Addestramento", "diveType_builtin_wreck": "Relitto", + "diveType_builtin_wreck_short": "Relitto", "diveTypes_addDialog_addButton": "Aggiungi", "diveTypes_addDialog_nameHint": "es., Ricerca e Recupero", "diveTypes_addDialog_nameLabel": "Nome Tipo Immersione", "diveTypes_addDialog_nameValidation": "Inserisci un nome", + "diveTypes_addDialog_shortNameHelper": "Mostrato nell'intestazione del dettaglio immersione quando lo spazio è limitato", + "diveTypes_addDialog_shortNameHint": "es. RR", + "diveTypes_addDialog_shortNameLabel": "Nome breve (opzionale)", "diveTypes_addDialog_title": "Aggiungi Tipo Immersione Personalizzato", "diveTypes_addTooltip": "Aggiungi tipo immersione", "diveTypes_appBar_title": "Tipi di Immersione", diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index 16eb04d905..762edadfbc 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -15350,90 +15350,180 @@ abstract class AppLocalizations { /// **'Altitude'** String get diveType_builtin_altitude; + /// No description provided for @diveType_builtin_altitude_short. + /// + /// In en, this message translates to: + /// **'Alt'** + String get diveType_builtin_altitude_short; + /// No description provided for @diveType_builtin_boat. /// /// In en, this message translates to: /// **'Boat'** String get diveType_builtin_boat; + /// No description provided for @diveType_builtin_boat_short. + /// + /// In en, this message translates to: + /// **'Boat'** + String get diveType_builtin_boat_short; + /// No description provided for @diveType_builtin_cave. /// /// In en, this message translates to: /// **'Cave'** String get diveType_builtin_cave; + /// No description provided for @diveType_builtin_cave_short. + /// + /// In en, this message translates to: + /// **'Cave'** + String get diveType_builtin_cave_short; + /// No description provided for @diveType_builtin_cavern. /// /// In en, this message translates to: /// **'Cavern'** String get diveType_builtin_cavern; + /// No description provided for @diveType_builtin_cavern_short. + /// + /// In en, this message translates to: + /// **'Cavern'** + String get diveType_builtin_cavern_short; + /// No description provided for @diveType_builtin_deep. /// /// In en, this message translates to: /// **'Deep'** String get diveType_builtin_deep; + /// No description provided for @diveType_builtin_deep_short. + /// + /// In en, this message translates to: + /// **'Deep'** + String get diveType_builtin_deep_short; + /// No description provided for @diveType_builtin_drift. /// /// In en, this message translates to: /// **'Drift'** String get diveType_builtin_drift; + /// No description provided for @diveType_builtin_drift_short. + /// + /// In en, this message translates to: + /// **'Drift'** + String get diveType_builtin_drift_short; + /// No description provided for @diveType_builtin_freedive. /// /// In en, this message translates to: /// **'Freedive'** String get diveType_builtin_freedive; + /// No description provided for @diveType_builtin_freedive_short. + /// + /// In en, this message translates to: + /// **'Free'** + String get diveType_builtin_freedive_short; + /// No description provided for @diveType_builtin_ice. /// /// In en, this message translates to: /// **'Ice'** String get diveType_builtin_ice; + /// No description provided for @diveType_builtin_ice_short. + /// + /// In en, this message translates to: + /// **'Ice'** + String get diveType_builtin_ice_short; + /// No description provided for @diveType_builtin_liveaboard. /// /// In en, this message translates to: /// **'Liveaboard'** String get diveType_builtin_liveaboard; + /// No description provided for @diveType_builtin_liveaboard_short. + /// + /// In en, this message translates to: + /// **'Live'** + String get diveType_builtin_liveaboard_short; + /// No description provided for @diveType_builtin_night. /// /// In en, this message translates to: /// **'Night'** String get diveType_builtin_night; + /// No description provided for @diveType_builtin_night_short. + /// + /// In en, this message translates to: + /// **'Night'** + String get diveType_builtin_night_short; + /// No description provided for @diveType_builtin_recreational. /// /// In en, this message translates to: /// **'Recreational'** String get diveType_builtin_recreational; + /// No description provided for @diveType_builtin_recreational_short. + /// + /// In en, this message translates to: + /// **'Rec'** + String get diveType_builtin_recreational_short; + /// No description provided for @diveType_builtin_shore. /// /// In en, this message translates to: /// **'Shore'** String get diveType_builtin_shore; + /// No description provided for @diveType_builtin_shore_short. + /// + /// In en, this message translates to: + /// **'Shore'** + String get diveType_builtin_shore_short; + /// No description provided for @diveType_builtin_technical. /// /// In en, this message translates to: /// **'Technical'** String get diveType_builtin_technical; + /// No description provided for @diveType_builtin_technical_short. + /// + /// In en, this message translates to: + /// **'Tec'** + String get diveType_builtin_technical_short; + /// No description provided for @diveType_builtin_training. /// /// In en, this message translates to: /// **'Training'** String get diveType_builtin_training; + /// No description provided for @diveType_builtin_training_short. + /// + /// In en, this message translates to: + /// **'Training'** + String get diveType_builtin_training_short; + /// No description provided for @diveType_builtin_wreck. /// /// In en, this message translates to: /// **'Wreck'** String get diveType_builtin_wreck; + /// No description provided for @diveType_builtin_wreck_short. + /// + /// In en, this message translates to: + /// **'Wreck'** + String get diveType_builtin_wreck_short; + /// No description provided for @diveTypes_addDialog_addButton. /// /// In en, this message translates to: @@ -15458,6 +15548,24 @@ abstract class AppLocalizations { /// **'Please enter a name'** String get diveTypes_addDialog_nameValidation; + /// No description provided for @diveTypes_addDialog_shortNameHelper. + /// + /// In en, this message translates to: + /// **'Shown on the dive detail header when space is tight'** + String get diveTypes_addDialog_shortNameHelper; + + /// No description provided for @diveTypes_addDialog_shortNameHint. + /// + /// In en, this message translates to: + /// **'e.g., S&R'** + String get diveTypes_addDialog_shortNameHint; + + /// No description provided for @diveTypes_addDialog_shortNameLabel. + /// + /// In en, this message translates to: + /// **'Short name (optional)'** + String get diveTypes_addDialog_shortNameLabel; + /// No description provided for @diveTypes_addDialog_title. /// /// 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 e9063e094d..da74515a34 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -9016,48 +9016,93 @@ class AppLocalizationsAr extends AppLocalizations { @override String get diveType_builtin_altitude => 'ارتفاع'; + @override + String get diveType_builtin_altitude_short => 'ارتفاع'; + @override String get diveType_builtin_boat => 'من القارب'; + @override + String get diveType_builtin_boat_short => 'قارب'; + @override String get diveType_builtin_cave => 'كهف'; + @override + String get diveType_builtin_cave_short => 'كهف'; + @override String get diveType_builtin_cavern => 'كهف ضحل'; + @override + String get diveType_builtin_cavern_short => 'كهف ضحل'; + @override String get diveType_builtin_deep => 'عميق'; + @override + String get diveType_builtin_deep_short => 'عميق'; + @override String get diveType_builtin_drift => 'انجراف'; + @override + String get diveType_builtin_drift_short => 'انجراف'; + @override String get diveType_builtin_freedive => 'غطس حر'; + @override + String get diveType_builtin_freedive_short => 'غطس حر'; + @override String get diveType_builtin_ice => 'جليد'; + @override + String get diveType_builtin_ice_short => 'جليد'; + @override String get diveType_builtin_liveaboard => 'رحلة غوص بحرية'; + @override + String get diveType_builtin_liveaboard_short => 'رحلة غوص'; + @override String get diveType_builtin_night => 'ليلي'; + @override + String get diveType_builtin_night_short => 'ليلي'; + @override String get diveType_builtin_recreational => 'ترفيهي'; + @override + String get diveType_builtin_recreational_short => 'ترفيه'; + @override String get diveType_builtin_shore => 'من الشاطئ'; + @override + String get diveType_builtin_shore_short => 'شاطئ'; + @override String get diveType_builtin_technical => 'تقني'; + @override + String get diveType_builtin_technical_short => 'تقني'; + @override String get diveType_builtin_training => 'تدريب'; + @override + String get diveType_builtin_training_short => 'تدريب'; + @override String get diveType_builtin_wreck => 'حطام'; + @override + String get diveType_builtin_wreck_short => 'حطام'; + @override String get diveTypes_addDialog_addButton => 'إضافة'; @@ -9070,6 +9115,16 @@ class AppLocalizationsAr extends AppLocalizations { @override String get diveTypes_addDialog_nameValidation => 'الرجاء إدخال اسم'; + @override + String get diveTypes_addDialog_shortNameHelper => + 'يظهر في رأس تفاصيل الغطسة عند ضيق المساحة'; + + @override + String get diveTypes_addDialog_shortNameHint => 'مثال: ب.إ'; + + @override + String get diveTypes_addDialog_shortNameLabel => 'اسم مختصر (اختياري)'; + @override String get diveTypes_addDialog_title => 'إضافة نوع غوص مخصص'; diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index 23f700fea2..6df9ddbb6e 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -9187,48 +9187,93 @@ class AppLocalizationsDe extends AppLocalizations { @override String get diveType_builtin_altitude => 'Bergseetauchen'; + @override + String get diveType_builtin_altitude_short => 'Bergsee'; + @override String get diveType_builtin_boat => 'Bootstauchgang'; + @override + String get diveType_builtin_boat_short => 'Boot'; + @override String get diveType_builtin_cave => 'Höhlentauchen'; + @override + String get diveType_builtin_cave_short => 'Höhle'; + @override String get diveType_builtin_cavern => 'Cavern'; + @override + String get diveType_builtin_cavern_short => 'Cavern'; + @override String get diveType_builtin_deep => 'Tieftauchen'; + @override + String get diveType_builtin_deep_short => 'Tief'; + @override String get diveType_builtin_drift => 'Strömungstauchen'; + @override + String get diveType_builtin_drift_short => 'Strömung'; + @override String get diveType_builtin_freedive => 'Apnoetauchen'; + @override + String get diveType_builtin_freedive_short => 'Apnoe'; + @override String get diveType_builtin_ice => 'Eistauchen'; + @override + String get diveType_builtin_ice_short => 'Eis'; + @override String get diveType_builtin_liveaboard => 'Tauchsafari'; + @override + String get diveType_builtin_liveaboard_short => 'Safari'; + @override String get diveType_builtin_night => 'Nachttauchen'; + @override + String get diveType_builtin_night_short => 'Nacht'; + @override String get diveType_builtin_recreational => 'Sporttauchen'; + @override + String get diveType_builtin_recreational_short => 'Rec'; + @override String get diveType_builtin_shore => 'Ufertauchgang'; + @override + String get diveType_builtin_shore_short => 'Ufer'; + @override String get diveType_builtin_technical => 'Technisches Tauchen'; + @override + String get diveType_builtin_technical_short => 'Tec'; + @override String get diveType_builtin_training => 'Ausbildung'; + @override + String get diveType_builtin_training_short => 'Kurs'; + @override String get diveType_builtin_wreck => 'Wracktauchen'; + @override + String get diveType_builtin_wreck_short => 'Wrack'; + @override String get diveTypes_addDialog_addButton => 'Hinzufügen'; @@ -9242,6 +9287,16 @@ class AppLocalizationsDe extends AppLocalizations { String get diveTypes_addDialog_nameValidation => 'Bitte geben Sie einen Namen ein'; + @override + String get diveTypes_addDialog_shortNameHelper => + 'Wird im Tauchgang-Header angezeigt, wenn der Platz knapp ist'; + + @override + String get diveTypes_addDialog_shortNameHint => 'z.B. S&B'; + + @override + String get diveTypes_addDialog_shortNameLabel => 'Kurzname (optional)'; + @override String get diveTypes_addDialog_title => 'Benutzerdefinierten Tauchgangstyp hinzufügen'; diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index c618e3a65f..018049a48a 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -9034,48 +9034,93 @@ class AppLocalizationsEn extends AppLocalizations { @override String get diveType_builtin_altitude => 'Altitude'; + @override + String get diveType_builtin_altitude_short => 'Alt'; + @override String get diveType_builtin_boat => 'Boat'; + @override + String get diveType_builtin_boat_short => 'Boat'; + @override String get diveType_builtin_cave => 'Cave'; + @override + String get diveType_builtin_cave_short => 'Cave'; + @override String get diveType_builtin_cavern => 'Cavern'; + @override + String get diveType_builtin_cavern_short => 'Cavern'; + @override String get diveType_builtin_deep => 'Deep'; + @override + String get diveType_builtin_deep_short => 'Deep'; + @override String get diveType_builtin_drift => 'Drift'; + @override + String get diveType_builtin_drift_short => 'Drift'; + @override String get diveType_builtin_freedive => 'Freedive'; + @override + String get diveType_builtin_freedive_short => 'Free'; + @override String get diveType_builtin_ice => 'Ice'; + @override + String get diveType_builtin_ice_short => 'Ice'; + @override String get diveType_builtin_liveaboard => 'Liveaboard'; + @override + String get diveType_builtin_liveaboard_short => 'Live'; + @override String get diveType_builtin_night => 'Night'; + @override + String get diveType_builtin_night_short => 'Night'; + @override String get diveType_builtin_recreational => 'Recreational'; + @override + String get diveType_builtin_recreational_short => 'Rec'; + @override String get diveType_builtin_shore => 'Shore'; + @override + String get diveType_builtin_shore_short => 'Shore'; + @override String get diveType_builtin_technical => 'Technical'; + @override + String get diveType_builtin_technical_short => 'Tec'; + @override String get diveType_builtin_training => 'Training'; + @override + String get diveType_builtin_training_short => 'Training'; + @override String get diveType_builtin_wreck => 'Wreck'; + @override + String get diveType_builtin_wreck_short => 'Wreck'; + @override String get diveTypes_addDialog_addButton => 'Add'; @@ -9088,6 +9133,16 @@ class AppLocalizationsEn extends AppLocalizations { @override String get diveTypes_addDialog_nameValidation => 'Please enter a name'; + @override + String get diveTypes_addDialog_shortNameHelper => + 'Shown on the dive detail header when space is tight'; + + @override + String get diveTypes_addDialog_shortNameHint => 'e.g., S&R'; + + @override + String get diveTypes_addDialog_shortNameLabel => 'Short name (optional)'; + @override String get diveTypes_addDialog_title => 'Add Custom Dive Type'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index e1b5510924..6e034d0072 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -9187,48 +9187,93 @@ class AppLocalizationsEs extends AppLocalizations { @override String get diveType_builtin_altitude => 'Altitud'; + @override + String get diveType_builtin_altitude_short => 'Altitud'; + @override String get diveType_builtin_boat => 'Desde barco'; + @override + String get diveType_builtin_boat_short => 'Barco'; + @override String get diveType_builtin_cave => 'Cueva'; + @override + String get diveType_builtin_cave_short => 'Cueva'; + @override String get diveType_builtin_cavern => 'Caverna'; + @override + String get diveType_builtin_cavern_short => 'Caverna'; + @override String get diveType_builtin_deep => 'Profunda'; + @override + String get diveType_builtin_deep_short => 'Profunda'; + @override String get diveType_builtin_drift => 'Deriva'; + @override + String get diveType_builtin_drift_short => 'Deriva'; + @override String get diveType_builtin_freedive => 'Apnea'; + @override + String get diveType_builtin_freedive_short => 'Apnea'; + @override String get diveType_builtin_ice => 'Hielo'; + @override + String get diveType_builtin_ice_short => 'Hielo'; + @override String get diveType_builtin_liveaboard => 'Crucero de buceo'; + @override + String get diveType_builtin_liveaboard_short => 'Crucero'; + @override String get diveType_builtin_night => 'Nocturna'; + @override + String get diveType_builtin_night_short => 'Nocturna'; + @override String get diveType_builtin_recreational => 'Recreativa'; + @override + String get diveType_builtin_recreational_short => 'Rec'; + @override String get diveType_builtin_shore => 'Desde costa'; + @override + String get diveType_builtin_shore_short => 'Costa'; + @override String get diveType_builtin_technical => 'Técnica'; + @override + String get diveType_builtin_technical_short => 'Tec'; + @override String get diveType_builtin_training => 'Formación'; + @override + String get diveType_builtin_training_short => 'Formación'; + @override String get diveType_builtin_wreck => 'Pecio'; + @override + String get diveType_builtin_wreck_short => 'Pecio'; + @override String get diveTypes_addDialog_addButton => 'Agregar'; @@ -9242,6 +9287,16 @@ class AppLocalizationsEs extends AppLocalizations { String get diveTypes_addDialog_nameValidation => 'Por favor ingresa un nombre'; + @override + String get diveTypes_addDialog_shortNameHelper => + 'Se muestra en el encabezado del detalle de inmersión cuando hay poco espacio'; + + @override + String get diveTypes_addDialog_shortNameHint => 'ej., ByR'; + + @override + String get diveTypes_addDialog_shortNameLabel => 'Nombre corto (opcional)'; + @override String get diveTypes_addDialog_title => 'Agregar Tipo de Inmersión Personalizado'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index be387b69c7..78b5809c0e 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -9221,48 +9221,93 @@ class AppLocalizationsFr extends AppLocalizations { @override String get diveType_builtin_altitude => 'Altitude'; + @override + String get diveType_builtin_altitude_short => 'Altitude'; + @override String get diveType_builtin_boat => 'Depuis un bateau'; + @override + String get diveType_builtin_boat_short => 'Bateau'; + @override String get diveType_builtin_cave => 'Grotte'; + @override + String get diveType_builtin_cave_short => 'Grotte'; + @override String get diveType_builtin_cavern => 'Caverne'; + @override + String get diveType_builtin_cavern_short => 'Caverne'; + @override String get diveType_builtin_deep => 'Profonde'; + @override + String get diveType_builtin_deep_short => 'Profonde'; + @override String get diveType_builtin_drift => 'Dérive'; + @override + String get diveType_builtin_drift_short => 'Dérive'; + @override String get diveType_builtin_freedive => 'Apnée'; + @override + String get diveType_builtin_freedive_short => 'Apnée'; + @override String get diveType_builtin_ice => 'Sous glace'; + @override + String get diveType_builtin_ice_short => 'Glace'; + @override String get diveType_builtin_liveaboard => 'Croisière plongée'; + @override + String get diveType_builtin_liveaboard_short => 'Croisière'; + @override String get diveType_builtin_night => 'Nuit'; + @override + String get diveType_builtin_night_short => 'Nuit'; + @override String get diveType_builtin_recreational => 'Loisir'; + @override + String get diveType_builtin_recreational_short => 'Rec'; + @override String get diveType_builtin_shore => 'Depuis la côte'; + @override + String get diveType_builtin_shore_short => 'Côte'; + @override String get diveType_builtin_technical => 'Technique'; + @override + String get diveType_builtin_technical_short => 'Tec'; + @override String get diveType_builtin_training => 'Formation'; + @override + String get diveType_builtin_training_short => 'Formation'; + @override String get diveType_builtin_wreck => 'Épave'; + @override + String get diveType_builtin_wreck_short => 'Épave'; + @override String get diveTypes_addDialog_addButton => 'Ajouter'; @@ -9275,6 +9320,16 @@ class AppLocalizationsFr extends AppLocalizations { @override String get diveTypes_addDialog_nameValidation => 'Veuillez entrer un nom'; + @override + String get diveTypes_addDialog_shortNameHelper => + 'Affiché dans l\'en-tête du détail de plongée quand la place manque'; + + @override + String get diveTypes_addDialog_shortNameHint => 'ex. RR'; + + @override + String get diveTypes_addDialog_shortNameLabel => 'Nom court (facultatif)'; + @override String get diveTypes_addDialog_title => 'Ajouter un type de plongée personnalisé'; diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index 53506352f1..312d13cfb6 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -8958,48 +8958,93 @@ class AppLocalizationsHe extends AppLocalizations { @override String get diveType_builtin_altitude => 'גובה רב'; + @override + String get diveType_builtin_altitude_short => 'גובה'; + @override String get diveType_builtin_boat => 'מסירה'; + @override + String get diveType_builtin_boat_short => 'מסירה'; + @override String get diveType_builtin_cave => 'מערה'; + @override + String get diveType_builtin_cave_short => 'מערה'; + @override String get diveType_builtin_cavern => 'מערה פתוחה'; + @override + String get diveType_builtin_cavern_short => 'מערה פתוחה'; + @override String get diveType_builtin_deep => 'עמוקה'; + @override + String get diveType_builtin_deep_short => 'עמוקה'; + @override String get diveType_builtin_drift => 'סחף'; + @override + String get diveType_builtin_drift_short => 'סחף'; + @override String get diveType_builtin_freedive => 'צלילה חופשית'; + @override + String get diveType_builtin_freedive_short => 'חופשית'; + @override String get diveType_builtin_ice => 'קרח'; + @override + String get diveType_builtin_ice_short => 'קרח'; + @override String get diveType_builtin_liveaboard => 'שייט צלילה'; + @override + String get diveType_builtin_liveaboard_short => 'שייט'; + @override String get diveType_builtin_night => 'לילה'; + @override + String get diveType_builtin_night_short => 'לילה'; + @override String get diveType_builtin_recreational => 'ספורטיבי'; + @override + String get diveType_builtin_recreational_short => 'ספורט'; + @override String get diveType_builtin_shore => 'מהחוף'; + @override + String get diveType_builtin_shore_short => 'מהחוף'; + @override String get diveType_builtin_technical => 'טכני'; + @override + String get diveType_builtin_technical_short => 'טכני'; + @override String get diveType_builtin_training => 'הכשרה'; + @override + String get diveType_builtin_training_short => 'הכשרה'; + @override String get diveType_builtin_wreck => 'ספינה טבועה'; + @override + String get diveType_builtin_wreck_short => 'טבועה'; + @override String get diveTypes_addDialog_addButton => 'הוסף'; @@ -9012,6 +9057,16 @@ class AppLocalizationsHe extends AppLocalizations { @override String get diveTypes_addDialog_nameValidation => 'נא להזין שם'; + @override + String get diveTypes_addDialog_shortNameHelper => + 'מוצג בכותרת פרטי הצלילה כשאין הרבה מקום'; + + @override + String get diveTypes_addDialog_shortNameHint => 'לדוגמה: ח.ש'; + + @override + String get diveTypes_addDialog_shortNameLabel => 'שם קצר (אופציונלי)'; + @override String get diveTypes_addDialog_title => 'הוסף סוג צלילה מותאם'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index 4c51596901..5f0a5348b9 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -9162,48 +9162,93 @@ class AppLocalizationsHu extends AppLocalizations { @override String get diveType_builtin_altitude => 'Magaslati'; + @override + String get diveType_builtin_altitude_short => 'Magaslati'; + @override String get diveType_builtin_boat => 'Hajóról'; + @override + String get diveType_builtin_boat_short => 'Hajó'; + @override String get diveType_builtin_cave => 'Barlang'; + @override + String get diveType_builtin_cave_short => 'Barlang'; + @override String get diveType_builtin_cavern => 'Barlangbejárat'; + @override + String get diveType_builtin_cavern_short => 'Bejárat'; + @override String get diveType_builtin_deep => 'Mély'; + @override + String get diveType_builtin_deep_short => 'Mély'; + @override String get diveType_builtin_drift => 'Sodrásos'; + @override + String get diveType_builtin_drift_short => 'Sodrásos'; + @override String get diveType_builtin_freedive => 'Szabadtüdős'; + @override + String get diveType_builtin_freedive_short => 'Szabad'; + @override String get diveType_builtin_ice => 'Jég'; + @override + String get diveType_builtin_ice_short => 'Jég'; + @override String get diveType_builtin_liveaboard => 'Búvárhajós'; + @override + String get diveType_builtin_liveaboard_short => 'Szafari'; + @override String get diveType_builtin_night => 'Éjszakai'; + @override + String get diveType_builtin_night_short => 'Éjszakai'; + @override String get diveType_builtin_recreational => 'Szabadidős'; + @override + String get diveType_builtin_recreational_short => 'Rec'; + @override String get diveType_builtin_shore => 'Partról'; + @override + String get diveType_builtin_shore_short => 'Part'; + @override String get diveType_builtin_technical => 'Technikai'; + @override + String get diveType_builtin_technical_short => 'Tec'; + @override String get diveType_builtin_training => 'Képzés'; + @override + String get diveType_builtin_training_short => 'Képzés'; + @override String get diveType_builtin_wreck => 'Roncs'; + @override + String get diveType_builtin_wreck_short => 'Roncs'; + @override String get diveTypes_addDialog_addButton => 'Hozzáadás'; @@ -9216,6 +9261,16 @@ class AppLocalizationsHu extends AppLocalizations { @override String get diveTypes_addDialog_nameValidation => 'Adj meg egy nevet'; + @override + String get diveTypes_addDialog_shortNameHelper => + 'A merülés részletek fejlécében jelenik meg, ha kevés a hely'; + + @override + String get diveTypes_addDialog_shortNameHint => 'pl. K&M'; + + @override + String get diveTypes_addDialog_shortNameLabel => 'Rövid név (opcionális)'; + @override String get diveTypes_addDialog_title => 'Egyedi merülés típus hozzáadása'; diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index e5531df98e..379522f0ce 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -9187,48 +9187,93 @@ class AppLocalizationsIt extends AppLocalizations { @override String get diveType_builtin_altitude => 'Altitudine'; + @override + String get diveType_builtin_altitude_short => 'Altitudine'; + @override String get diveType_builtin_boat => 'Da barca'; + @override + String get diveType_builtin_boat_short => 'Barca'; + @override String get diveType_builtin_cave => 'Grotta'; + @override + String get diveType_builtin_cave_short => 'Grotta'; + @override String get diveType_builtin_cavern => 'Caverna'; + @override + String get diveType_builtin_cavern_short => 'Caverna'; + @override String get diveType_builtin_deep => 'Profonda'; + @override + String get diveType_builtin_deep_short => 'Profonda'; + @override String get diveType_builtin_drift => 'Corrente'; + @override + String get diveType_builtin_drift_short => 'Corrente'; + @override String get diveType_builtin_freedive => 'Apnea'; + @override + String get diveType_builtin_freedive_short => 'Apnea'; + @override String get diveType_builtin_ice => 'Sotto ghiaccio'; + @override + String get diveType_builtin_ice_short => 'Ghiaccio'; + @override String get diveType_builtin_liveaboard => 'Crociera subacquea'; + @override + String get diveType_builtin_liveaboard_short => 'Crociera'; + @override String get diveType_builtin_night => 'Notturna'; + @override + String get diveType_builtin_night_short => 'Notturna'; + @override String get diveType_builtin_recreational => 'Ricreativa'; + @override + String get diveType_builtin_recreational_short => 'Rec'; + @override String get diveType_builtin_shore => 'Da riva'; + @override + String get diveType_builtin_shore_short => 'Riva'; + @override String get diveType_builtin_technical => 'Tecnica'; + @override + String get diveType_builtin_technical_short => 'Tec'; + @override String get diveType_builtin_training => 'Addestramento'; + @override + String get diveType_builtin_training_short => 'Addestramento'; + @override String get diveType_builtin_wreck => 'Relitto'; + @override + String get diveType_builtin_wreck_short => 'Relitto'; + @override String get diveTypes_addDialog_addButton => 'Aggiungi'; @@ -9241,6 +9286,16 @@ class AppLocalizationsIt extends AppLocalizations { @override String get diveTypes_addDialog_nameValidation => 'Inserisci un nome'; + @override + String get diveTypes_addDialog_shortNameHelper => + 'Mostrato nell\'intestazione del dettaglio immersione quando lo spazio è limitato'; + + @override + String get diveTypes_addDialog_shortNameHint => 'es. RR'; + + @override + String get diveTypes_addDialog_shortNameLabel => 'Nome breve (opzionale)'; + @override String get diveTypes_addDialog_title => 'Aggiungi Tipo Immersione Personalizzato'; diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index 45b2eed352..4ca2c37766 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -9116,48 +9116,93 @@ class AppLocalizationsNl extends AppLocalizations { @override String get diveType_builtin_altitude => 'Hoogte'; + @override + String get diveType_builtin_altitude_short => 'Hoogte'; + @override String get diveType_builtin_boat => 'Vanaf boot'; + @override + String get diveType_builtin_boat_short => 'Boot'; + @override String get diveType_builtin_cave => 'Grot'; + @override + String get diveType_builtin_cave_short => 'Grot'; + @override String get diveType_builtin_cavern => 'Cavern'; + @override + String get diveType_builtin_cavern_short => 'Cavern'; + @override String get diveType_builtin_deep => 'Diep'; + @override + String get diveType_builtin_deep_short => 'Diep'; + @override String get diveType_builtin_drift => 'Stroming'; + @override + String get diveType_builtin_drift_short => 'Stroming'; + @override String get diveType_builtin_freedive => 'Vrijduiken'; + @override + String get diveType_builtin_freedive_short => 'Vrij'; + @override String get diveType_builtin_ice => 'IJs'; + @override + String get diveType_builtin_ice_short => 'IJs'; + @override String get diveType_builtin_liveaboard => 'Liveaboard'; + @override + String get diveType_builtin_liveaboard_short => 'Liveaboard'; + @override String get diveType_builtin_night => 'Nacht'; + @override + String get diveType_builtin_night_short => 'Nacht'; + @override String get diveType_builtin_recreational => 'Recreatief'; + @override + String get diveType_builtin_recreational_short => 'Rec'; + @override String get diveType_builtin_shore => 'Vanaf de kant'; + @override + String get diveType_builtin_shore_short => 'Kant'; + @override String get diveType_builtin_technical => 'Technisch'; + @override + String get diveType_builtin_technical_short => 'Tec'; + @override String get diveType_builtin_training => 'Opleiding'; + @override + String get diveType_builtin_training_short => 'Opleiding'; + @override String get diveType_builtin_wreck => 'Wrak'; + @override + String get diveType_builtin_wreck_short => 'Wrak'; + @override String get diveTypes_addDialog_addButton => 'Toevoegen'; @@ -9170,6 +9215,16 @@ class AppLocalizationsNl extends AppLocalizations { @override String get diveTypes_addDialog_nameValidation => 'Voer een naam in'; + @override + String get diveTypes_addDialog_shortNameHelper => + 'Wordt getoond in de duik-header wanneer er weinig ruimte is'; + + @override + String get diveTypes_addDialog_shortNameHint => 'bijv. Z&B'; + + @override + String get diveTypes_addDialog_shortNameLabel => 'Korte naam (optioneel)'; + @override String get diveTypes_addDialog_title => 'Aangepast duiktype toevoegen'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index d520bdd42e..10a31f10fd 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -9189,48 +9189,93 @@ class AppLocalizationsPt extends AppLocalizations { @override String get diveType_builtin_altitude => 'Altitude'; + @override + String get diveType_builtin_altitude_short => 'Altitude'; + @override String get diveType_builtin_boat => 'A partir de barco'; + @override + String get diveType_builtin_boat_short => 'Barco'; + @override String get diveType_builtin_cave => 'Gruta'; + @override + String get diveType_builtin_cave_short => 'Gruta'; + @override String get diveType_builtin_cavern => 'Caverna'; + @override + String get diveType_builtin_cavern_short => 'Caverna'; + @override String get diveType_builtin_deep => 'Profundo'; + @override + String get diveType_builtin_deep_short => 'Profundo'; + @override String get diveType_builtin_drift => 'Deriva'; + @override + String get diveType_builtin_drift_short => 'Deriva'; + @override String get diveType_builtin_freedive => 'Apneia'; + @override + String get diveType_builtin_freedive_short => 'Apneia'; + @override String get diveType_builtin_ice => 'Gelo'; + @override + String get diveType_builtin_ice_short => 'Gelo'; + @override String get diveType_builtin_liveaboard => 'Cruzeiro de mergulho'; + @override + String get diveType_builtin_liveaboard_short => 'Cruzeiro'; + @override String get diveType_builtin_night => 'Noturno'; + @override + String get diveType_builtin_night_short => 'Noturno'; + @override String get diveType_builtin_recreational => 'Recreativo'; + @override + String get diveType_builtin_recreational_short => 'Rec'; + @override String get diveType_builtin_shore => 'A partir da costa'; + @override + String get diveType_builtin_shore_short => 'Costa'; + @override String get diveType_builtin_technical => 'Técnico'; + @override + String get diveType_builtin_technical_short => 'Tec'; + @override String get diveType_builtin_training => 'Treinamento'; + @override + String get diveType_builtin_training_short => 'Treinamento'; + @override String get diveType_builtin_wreck => 'Naufrágio'; + @override + String get diveType_builtin_wreck_short => 'Naufrágio'; + @override String get diveTypes_addDialog_addButton => 'Adicionar'; @@ -9243,6 +9288,16 @@ class AppLocalizationsPt extends AppLocalizations { @override String get diveTypes_addDialog_nameValidation => 'Digite um nome'; + @override + String get diveTypes_addDialog_shortNameHelper => + 'Exibido no cabeçalho de detalhes do mergulho quando o espaço é limitado'; + + @override + String get diveTypes_addDialog_shortNameHint => 'ex: ByR'; + + @override + String get diveTypes_addDialog_shortNameLabel => 'Nome curto (opcional)'; + @override String get diveTypes_addDialog_title => 'Adicionar Tipo de Mergulho Personalizado'; diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index 5c376f850d..287437ceb0 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -8737,48 +8737,93 @@ class AppLocalizationsZh extends AppLocalizations { @override String get diveType_builtin_altitude => '高原潜水'; + @override + String get diveType_builtin_altitude_short => '高原'; + @override String get diveType_builtin_boat => '船潜'; + @override + String get diveType_builtin_boat_short => '船潜'; + @override String get diveType_builtin_cave => '洞穴潜水'; + @override + String get diveType_builtin_cave_short => '洞穴'; + @override String get diveType_builtin_cavern => '洞厅潜水'; + @override + String get diveType_builtin_cavern_short => '洞厅'; + @override String get diveType_builtin_deep => '深潜'; + @override + String get diveType_builtin_deep_short => '深潜'; + @override String get diveType_builtin_drift => '流潜'; + @override + String get diveType_builtin_drift_short => '流潜'; + @override String get diveType_builtin_freedive => '自由潜水'; + @override + String get diveType_builtin_freedive_short => '自由'; + @override String get diveType_builtin_ice => '冰潜'; + @override + String get diveType_builtin_ice_short => '冰潜'; + @override String get diveType_builtin_liveaboard => '船宿潜水'; + @override + String get diveType_builtin_liveaboard_short => '船宿'; + @override String get diveType_builtin_night => '夜潜'; + @override + String get diveType_builtin_night_short => '夜潜'; + @override String get diveType_builtin_recreational => '休闲潜水'; + @override + String get diveType_builtin_recreational_short => '休闲'; + @override String get diveType_builtin_shore => '岸潜'; + @override + String get diveType_builtin_shore_short => '岸潜'; + @override String get diveType_builtin_technical => '技术潜水'; + @override + String get diveType_builtin_technical_short => '技术'; + @override String get diveType_builtin_training => '训练潜水'; + @override + String get diveType_builtin_training_short => '训练'; + @override String get diveType_builtin_wreck => '沉船潜水'; + @override + String get diveType_builtin_wreck_short => '沉船'; + @override String get diveTypes_addDialog_addButton => '添加'; @@ -8791,6 +8836,15 @@ class AppLocalizationsZh extends AppLocalizations { @override String get diveTypes_addDialog_nameValidation => '请输入名称'; + @override + String get diveTypes_addDialog_shortNameHelper => '空间不足时显示在潜水详情标题中'; + + @override + String get diveTypes_addDialog_shortNameHint => '例如:搜救'; + + @override + String get diveTypes_addDialog_shortNameLabel => '简称(可选)'; + @override String get diveTypes_addDialog_title => '添加自定义潜水类型'; diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index 935958f996..4d0a439587 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -2959,24 +2959,42 @@ "diveSites_summary_stat_totalSites": "Totaal stekken", "diveSites_summary_stat_withGps": "Met GPS", "diveType_builtin_altitude": "Hoogte", + "diveType_builtin_altitude_short": "Hoogte", "diveType_builtin_boat": "Vanaf boot", + "diveType_builtin_boat_short": "Boot", "diveType_builtin_cave": "Grot", + "diveType_builtin_cave_short": "Grot", "diveType_builtin_cavern": "Cavern", + "diveType_builtin_cavern_short": "Cavern", "diveType_builtin_deep": "Diep", + "diveType_builtin_deep_short": "Diep", "diveType_builtin_drift": "Stroming", + "diveType_builtin_drift_short": "Stroming", "diveType_builtin_freedive": "Vrijduiken", + "diveType_builtin_freedive_short": "Vrij", "diveType_builtin_ice": "IJs", + "diveType_builtin_ice_short": "IJs", "diveType_builtin_liveaboard": "Liveaboard", + "diveType_builtin_liveaboard_short": "Liveaboard", "diveType_builtin_night": "Nacht", + "diveType_builtin_night_short": "Nacht", "diveType_builtin_recreational": "Recreatief", + "diveType_builtin_recreational_short": "Rec", "diveType_builtin_shore": "Vanaf de kant", + "diveType_builtin_shore_short": "Kant", "diveType_builtin_technical": "Technisch", + "diveType_builtin_technical_short": "Tec", "diveType_builtin_training": "Opleiding", + "diveType_builtin_training_short": "Opleiding", "diveType_builtin_wreck": "Wrak", + "diveType_builtin_wreck_short": "Wrak", "diveTypes_addDialog_addButton": "Toevoegen", "diveTypes_addDialog_nameHint": "bijv. Zoeken & Bergen", "diveTypes_addDialog_nameLabel": "Duiktype naam", "diveTypes_addDialog_nameValidation": "Voer een naam in", + "diveTypes_addDialog_shortNameHelper": "Wordt getoond in de duik-header wanneer er weinig ruimte is", + "diveTypes_addDialog_shortNameHint": "bijv. Z&B", + "diveTypes_addDialog_shortNameLabel": "Korte naam (optioneel)", "diveTypes_addDialog_title": "Aangepast duiktype toevoegen", "diveTypes_addTooltip": "Duiktype toevoegen", "diveTypes_appBar_title": "Duiktypes", diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index 68760866cd..2cf9f16e81 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -2959,24 +2959,42 @@ "diveSites_summary_stat_totalSites": "Total de Pontos", "diveSites_summary_stat_withGps": "Com GPS", "diveType_builtin_altitude": "Altitude", + "diveType_builtin_altitude_short": "Altitude", "diveType_builtin_boat": "A partir de barco", + "diveType_builtin_boat_short": "Barco", "diveType_builtin_cave": "Gruta", + "diveType_builtin_cave_short": "Gruta", "diveType_builtin_cavern": "Caverna", + "diveType_builtin_cavern_short": "Caverna", "diveType_builtin_deep": "Profundo", + "diveType_builtin_deep_short": "Profundo", "diveType_builtin_drift": "Deriva", + "diveType_builtin_drift_short": "Deriva", "diveType_builtin_freedive": "Apneia", + "diveType_builtin_freedive_short": "Apneia", "diveType_builtin_ice": "Gelo", + "diveType_builtin_ice_short": "Gelo", "diveType_builtin_liveaboard": "Cruzeiro de mergulho", + "diveType_builtin_liveaboard_short": "Cruzeiro", "diveType_builtin_night": "Noturno", + "diveType_builtin_night_short": "Noturno", "diveType_builtin_recreational": "Recreativo", + "diveType_builtin_recreational_short": "Rec", "diveType_builtin_shore": "A partir da costa", + "diveType_builtin_shore_short": "Costa", "diveType_builtin_technical": "Técnico", + "diveType_builtin_technical_short": "Tec", "diveType_builtin_training": "Treinamento", + "diveType_builtin_training_short": "Treinamento", "diveType_builtin_wreck": "Naufrágio", + "diveType_builtin_wreck_short": "Naufrágio", "diveTypes_addDialog_addButton": "Adicionar", "diveTypes_addDialog_nameHint": "ex: Busca e Recuperação", "diveTypes_addDialog_nameLabel": "Nome do Tipo de Mergulho", "diveTypes_addDialog_nameValidation": "Digite um nome", + "diveTypes_addDialog_shortNameHelper": "Exibido no cabeçalho de detalhes do mergulho quando o espaço é limitado", + "diveTypes_addDialog_shortNameHint": "ex: ByR", + "diveTypes_addDialog_shortNameLabel": "Nome curto (opcional)", "diveTypes_addDialog_title": "Adicionar Tipo de Mergulho Personalizado", "diveTypes_addTooltip": "Adicionar tipo de mergulho", "diveTypes_appBar_title": "Tipos de Mergulho", diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index 7e786149b9..a7ad960bc0 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -3092,24 +3092,42 @@ "diveSites_summary_stat_totalSites": "总计潜水点", "diveSites_summary_stat_withGps": "与 GPS", "diveType_builtin_altitude": "高原潜水", + "diveType_builtin_altitude_short": "高原", "diveType_builtin_boat": "船潜", + "diveType_builtin_boat_short": "船潜", "diveType_builtin_cave": "洞穴潜水", + "diveType_builtin_cave_short": "洞穴", "diveType_builtin_cavern": "洞厅潜水", + "diveType_builtin_cavern_short": "洞厅", "diveType_builtin_deep": "深潜", + "diveType_builtin_deep_short": "深潜", "diveType_builtin_drift": "流潜", + "diveType_builtin_drift_short": "流潜", "diveType_builtin_freedive": "自由潜水", + "diveType_builtin_freedive_short": "自由", "diveType_builtin_ice": "冰潜", + "diveType_builtin_ice_short": "冰潜", "diveType_builtin_liveaboard": "船宿潜水", + "diveType_builtin_liveaboard_short": "船宿", "diveType_builtin_night": "夜潜", + "diveType_builtin_night_short": "夜潜", "diveType_builtin_recreational": "休闲潜水", + "diveType_builtin_recreational_short": "休闲", "diveType_builtin_shore": "岸潜", + "diveType_builtin_shore_short": "岸潜", "diveType_builtin_technical": "技术潜水", + "diveType_builtin_technical_short": "技术", "diveType_builtin_training": "训练潜水", + "diveType_builtin_training_short": "训练", "diveType_builtin_wreck": "沉船潜水", + "diveType_builtin_wreck_short": "沉船", "diveTypes_addDialog_addButton": "添加", "diveTypes_addDialog_nameHint": "例如:搜索与救援", "diveTypes_addDialog_nameLabel": "潜水类型名称", "diveTypes_addDialog_nameValidation": "请输入名称", + "diveTypes_addDialog_shortNameHelper": "空间不足时显示在潜水详情标题中", + "diveTypes_addDialog_shortNameHint": "例如:搜救", + "diveTypes_addDialog_shortNameLabel": "简称(可选)", "diveTypes_addDialog_title": "添加自定义潜水类型", "diveTypes_addTooltip": "添加潜水类型", "diveTypes_appBar_title": "潜水类型", diff --git a/test/core/database/migration_v173_dive_type_short_name_test.dart b/test/core/database/migration_v173_dive_type_short_name_test.dart new file mode 100644 index 0000000000..b741c95dac --- /dev/null +++ b/test/core/database/migration_v173_dive_type_short_name_test.dart @@ -0,0 +1,68 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; + +/// Minimal pre-v173 shape: a dive_types table without the short_name column, +/// stamped at v161 so the 161->173 upgrade runs. +NativeDatabase _dbAt161() { + return NativeDatabase.memory( + setup: (rawDb) { + rawDb.execute('PRAGMA user_version = 161'); + rawDb.execute(''' + CREATE TABLE dive_types ( + id TEXT NOT NULL PRIMARY KEY, + diver_id TEXT, + name TEXT NOT NULL, + is_built_in INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + hlc TEXT + ) + '''); + rawDb.execute( + "INSERT INTO dive_types (id, name, is_built_in, created_at, updated_at) " + "VALUES ('wreck', 'Wreck', 1, 1000, 1000)", + ); + }, + ); +} + +void main() { + test('v173 adds dive_types.short_name, preserving existing rows', () async { + final db = AppDatabase(_dbAt161()); + addTearDown(() => db.close()); + + final cols = await db.customSelect("PRAGMA table_info('dive_types')").get(); + final names = cols.map((c) => c.read('name')).toSet(); + expect(names, contains('short_name')); + + final row = await db + .customSelect("SELECT short_name FROM dive_types WHERE id = 'wreck'") + .getSingle(); + expect(row.data['short_name'], isNull); + }); + + test('fresh databases get the dive_types.short_name column', () async { + final db = AppDatabase(NativeDatabase.memory()); + addTearDown(db.close); + final cols = await db.customSelect("PRAGMA table_info('dive_types')").get(); + final names = cols.map((c) => c.read('name')).toSet(); + expect(names, contains('short_name')); + }); + + test('the helper no-ops when dive_types is absent', () async { + final native = NativeDatabase.memory( + setup: (rawDb) => rawDb.execute('PRAGMA user_version = 161'), + ); + final db = AppDatabase(native); + addTearDown(db.close); + + await expectLater(db.customSelect('SELECT 1').get(), completes); + }); + + test('v173 is present in the migration ladder', () { + expect(AppDatabase.currentSchemaVersion, greaterThanOrEqualTo(173)); + expect(AppDatabase.migrationVersions, contains(173)); + }); +} diff --git a/test/features/dive_log/presentation/formatters/dive_type_label_test.dart b/test/features/dive_log/presentation/formatters/dive_type_label_test.dart index 27a19ece35..3ba4213d82 100644 --- a/test/features/dive_log/presentation/formatters/dive_type_label_test.dart +++ b/test/features/dive_log/presentation/formatters/dive_type_label_test.dart @@ -63,4 +63,29 @@ void main() { expect(diveTypeLabels(en, ['wreck', 'night']), 'Wreck, Night'); expect(diveTypeLabels(en, const []), ''); }); + + test('diveTypeShortLabel uses the built-in abbreviation', () { + expect(diveTypeShortLabel(de, 'technical'), 'Tec'); + expect(diveTypeShortLabel(de, 'recreational'), 'Rec'); + }); + + test('diveTypeShortLabel falls back to the full label for a custom type', () { + final byId = { + 'my-tag': entity('my-tag', 'Suche & Bergung', isBuiltIn: false), + }; + expect( + diveTypeShortLabel(de, 'my-tag', typesById: byId), + 'Suche & Bergung', + ); + }); + + test('diveTypeShortLabel keeps the diver label for a custom row on a ' + 'built-in slug', () { + final byId = {'wreck': entity('wreck', 'Hausriff-Wrack', isBuiltIn: false)}; + expect( + diveTypeShortLabel(de, 'wreck', typesById: byId), + 'Hausriff-Wrack', + reason: 'the entity guard must win over the built-in abbreviation', + ); + }); } diff --git a/test/features/dive_log/presentation/pages/dive_detail_page_test.dart b/test/features/dive_log/presentation/pages/dive_detail_page_test.dart index e438ee020f..03b40f26f1 100644 --- a/test/features/dive_log/presentation/pages/dive_detail_page_test.dart +++ b/test/features/dive_log/presentation/pages/dive_detail_page_test.dart @@ -28,6 +28,7 @@ import 'package:submersion/features/dive_log/presentation/widgets/compact_tissue import 'package:submersion/features/dive_log/domain/entities/source_profile.dart'; import 'package:submersion/features/dive_log/presentation/widgets/source_bar.dart'; import 'package:submersion/features/dive_log/presentation/widgets/dive_profile_chart.dart'; +import 'package:submersion/features/dive_log/presentation/widgets/dive_type_badge.dart'; import 'package:submersion/features/dive_log/presentation/widgets/field_attribution_badge.dart'; import 'package:submersion/features/dive_log/presentation/widgets/o2_toxicity_card.dart'; import 'package:submersion/l10n/arb/app_localizations.dart'; @@ -48,6 +49,7 @@ Widget _buildDetailPage(Dive dive, List overrides) { ).overrideWith((ref) async => []), ], child: MaterialApp( + locale: const Locale('en'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: DiveDetailPage(diveId: dive.id, embedded: true), @@ -1583,4 +1585,23 @@ void main() { expect(find.text('OC'), findsOneWidget); }); }); + + group('DiveDetailPage dive type badges', () { + testWidgets('shows a badge for each of the dive\'s types', (tester) async { + final dive = createTestDiveWithBottomTime().copyWith( + diveTypeIds: ['wreck', 'night'], + ); + await _pumpDetailPage(tester, dive); + + expect(find.text('Wreck'), findsOneWidget); + expect(find.text('Night'), findsOneWidget); + }); + + testWidgets('shows no badges for a dive with no types', (tester) async { + final dive = createTestDiveWithBottomTime().copyWith(diveTypeIds: []); + await _pumpDetailPage(tester, dive); + + expect(find.byType(DiveTypeBadge), findsNothing); + }); + }); } diff --git a/test/features/dive_types/data/repositories/dive_type_repository_short_name_test.dart b/test/features/dive_types/data/repositories/dive_type_repository_short_name_test.dart new file mode 100644 index 0000000000..4be9b2f4e8 --- /dev/null +++ b/test/features/dive_types/data/repositories/dive_type_repository_short_name_test.dart @@ -0,0 +1,68 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/services/database_service.dart'; +import 'package:submersion/features/dive_types/data/repositories/dive_type_repository.dart'; +import 'package:submersion/features/dive_types/domain/entities/dive_type_entity.dart'; + +import '../../../../helpers/test_database.dart'; + +void main() { + late DiveTypeRepository repository; + + setUp(() async { + await setUpTestDatabase(); + repository = DiveTypeRepository(); + // Custom dive types carry a diverId FK, so a row must exist for it to + // reference. + await DatabaseService.instance.database.customStatement( + "INSERT INTO divers (id, name, created_at, updated_at) " + "VALUES ('diver-1', 'Test Diver', 1000, 1000)", + ); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + test('createDiveType persists and round-trips a short name', () async { + final created = await repository.createDiveType( + DiveTypeEntity.create( + id: '', + name: 'Search & Recovery', + diverId: 'diver-1', + shortName: 'S&R', + ), + ); + + expect(created.shortName, 'S&R'); + + final reloaded = await repository.getDiveTypeById(created.id); + expect(reloaded?.shortName, 'S&R'); + }); + + test('createDiveType with no short name leaves it null', () async { + final created = await repository.createDiveType( + DiveTypeEntity.create(id: '', name: 'Cenote', diverId: 'diver-1'), + ); + + expect(created.shortName, isNull); + + final reloaded = await repository.getDiveTypeById(created.id); + expect(reloaded?.shortName, isNull); + }); + + test('updateDiveType changes the persisted short name', () async { + final created = await repository.createDiveType( + DiveTypeEntity.create( + id: '', + name: 'Cenote', + diverId: 'diver-1', + shortName: 'Cen', + ), + ); + + await repository.updateDiveType(created.copyWith(shortName: 'CEN')); + + final reloaded = await repository.getDiveTypeById(created.id); + expect(reloaded?.shortName, 'CEN'); + }); +} diff --git a/test/features/dive_types/presentation/pages/dive_types_page_test.dart b/test/features/dive_types/presentation/pages/dive_types_page_test.dart index 7900b9fe54..3117c73c3e 100644 --- a/test/features/dive_types/presentation/pages/dive_types_page_test.dart +++ b/test/features/dive_types/presentation/pages/dive_types_page_test.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:submersion/core/services/database_service.dart'; +import 'package:submersion/features/dive_log/presentation/widgets/dive_type_badge.dart'; import 'package:submersion/features/dive_types/presentation/pages/dive_types_page.dart'; import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; import 'package:submersion/l10n/arb/app_localizations.dart'; @@ -67,4 +68,57 @@ void main() { expect(find.text('Wreck'), findsNothing); expect(find.text('Night'), findsNothing); }); + + group('custom dive type short name', () { + testWidgets( + 'adding a custom type with a short name shows both on its tile', + (tester) async { + await tester.pumpWidget( + _buildPage(diverIdNotifier, const Locale('en')), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byType(TextFormField).at(0), + 'Search & Recovery', + ); + await tester.enterText(find.byType(TextFormField).at(1), 'S&R'); + await tester.tap(find.widgetWithText(FilledButton, 'Add')); + await tester.pumpAndSettle(); + + expect(find.text('Search & Recovery'), findsOneWidget); + expect(find.text('S&R'), findsOneWidget); + }, + ); + + testWidgets('a custom type added with no short name shows no badge', ( + tester, + ) async { + await tester.pumpWidget(_buildPage(diverIdNotifier, const Locale('en'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextFormField).at(0), 'Cenote'); + await tester.tap(find.widgetWithText(FilledButton, 'Add')); + await tester.pumpAndSettle(); + + final tile = find.ancestor( + of: find.text('Cenote'), + matching: find.byType(ListTile), + ); + expect(tile, findsOneWidget); + // Built-in types elsewhere on the page (Recreational -> "Rec", etc.) + // legitimately carry their own badge, so the check is scoped to this + // tile rather than asserting no DiveTypeBadge exists anywhere. + expect( + find.descendant(of: tile, matching: find.byType(DiveTypeBadge)), + findsNothing, + ); + }); + }); } From 0a202057cd25e6401e4debfe164cbe1478d6f956 Mon Sep 17 00:00:00 2001 From: Cornelius Schmale Date: Thu, 27 Aug 2026 23:31:52 +0200 Subject: [PATCH 2/5] feat: show dive type badges in the dive list, matching header sizing Adds the dive detail header's type-badge row (Wreck, Night, etc.) to both the compact and detailed dive list cards, right-aligned on the stat row next to depth/duration, reusing DiveTypeBadgeRow/DiveTypeBadge so styling stays consistent across the app. Badges now support a dense variant that matches DiveModeBadge's list-row size exactly. Restructures the detailed card's stat/tag row so tags always get their own line instead of sharing space with the stat row and badges -- a long tag name (e.g. an auto-generated import-source tag) was previously able to force a RenderFlex overflow once the badge row added an enclosing Row that checks horizontal overflow, something nothing there checked before. Also replaces a wasteful 50/50 Spacer+Flexible split with a single Expanded so badges get the genuine remaining width instead of only half of it, and makes the dive detail header's badge cap scale with the header's own width instead of a flat 200px constant. --- .../widgets/recent_dives_card.dart | 5 + .../formatters/dive_type_label_resolver.dart | 15 + .../presentation/pages/dive_detail_page.dart | 277 +++++++++--------- .../presentation/pages/dive_list_page.dart | 52 +++- .../widgets/compact_dive_list_tile.dart | 23 ++ .../widgets/dive_list_content.dart | 5 + .../presentation/widgets/dive_list_item.dart | 10 + .../presentation/widgets/dive_type_badge.dart | 33 ++- .../widgets/dive_type_badge_row.dart | 22 +- .../widgets/story/trip_story_day_card.dart | 5 + .../pages/dive_detail_page_test.dart | 17 ++ ...list_tile_dive_type_localization_test.dart | 19 +- ...list_tile_dive_type_localization_test.dart | 46 ++- .../dive_list_tile_type_badges_test.dart | 234 +++++++++++++++ 14 files changed, 586 insertions(+), 177 deletions(-) create mode 100644 test/features/dive_log/presentation/widgets/dive_list_tile_type_badges_test.dart diff --git a/lib/features/dashboard/presentation/widgets/recent_dives_card.dart b/lib/features/dashboard/presentation/widgets/recent_dives_card.dart index da70ac5e6d..982f438bf2 100644 --- a/lib/features/dashboard/presentation/widgets/recent_dives_card.dart +++ b/lib/features/dashboard/presentation/widgets/recent_dives_card.dart @@ -91,6 +91,10 @@ class RecentDivesCard extends ConsumerWidget { ref, context.l10n, ); + final diveTypeShortLabelResolver = watchDiveTypeShortLabelResolver( + ref, + context.l10n, + ); final list = Column( children: dives.asMap().entries.map((entry) { @@ -103,6 +107,7 @@ class RecentDivesCard extends ConsumerWidget { return DiveListItem( summary: DiveSummary.fromDive(dive), diveTypeLabelResolver: diveTypeLabelResolver, + diveTypeShortLabelResolver: diveTypeShortLabelResolver, fullDive: dive, diveNumber: dive.diveNumber ?? index + 1, colorValue: getCardColorValueFromDive(dive, colorAttribute), diff --git a/lib/features/dive_log/presentation/formatters/dive_type_label_resolver.dart b/lib/features/dive_log/presentation/formatters/dive_type_label_resolver.dart index 6096d9951c..f2fef30d18 100644 --- a/lib/features/dive_log/presentation/formatters/dive_type_label_resolver.dart +++ b/lib/features/dive_log/presentation/formatters/dive_type_label_resolver.dart @@ -35,3 +35,18 @@ DiveTypeLabelResolver watchDiveTypeLabelResolver( }; return (id) => diveTypeLabel(l10n, id, typesById: typesById); } + +/// Short-form counterpart to [watchDiveTypeLabelResolver], for space +/// -constrained surfaces like list-row type badges. Same call-once-per-list +/// contract and empty-map-before-load fallback. +DiveTypeLabelResolver watchDiveTypeShortLabelResolver( + WidgetRef ref, + AppLocalizations l10n, +) { + final typesById = { + for (final t + in ref.watch(diveTypesProvider).value ?? const []) + t.id: t, + }; + return (id) => diveTypeShortLabel(l10n, id, typesById: typesById); +} diff --git a/lib/features/dive_log/presentation/pages/dive_detail_page.dart b/lib/features/dive_log/presentation/pages/dive_detail_page.dart index 535adaacf6..62cf45f98a 100644 --- a/lib/features/dive_log/presentation/pages/dive_detail_page.dart +++ b/lib/features/dive_log/presentation/pages/dive_detail_page.dart @@ -1237,151 +1237,166 @@ class _DiveDetailPageState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - CircleAvatar( - radius: 24, - backgroundColor: colorScheme.primaryContainer, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: FittedBox( - fit: BoxFit.scaleDown, - child: Text( - '#${dive.diveNumber ?? '-'}', - maxLines: 1, - style: TextStyle( - color: colorScheme.onPrimaryContainer, - fontWeight: FontWeight.bold, - // Pin the pre-scale size (CircleAvatar's implicit - // titleMedium default) so FittedBox scales from a - // theme-independent baseline. - fontSize: 16, - ), - ), - ), - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - dive.effectiveName ?? - dive.site?.name ?? - context.l10n.diveLog_listPage_unknownSite, - style: Theme.of(context).textTheme.titleLarge, - ), - Consumer( - builder: (context, ref, _) { - final count = - ref - .watch(diveOpenFindingsCountProvider(dive.id)) - .value ?? - 0; - if (count == 0) return const SizedBox.shrink(); - return Padding( - padding: const EdgeInsets.only(top: 4), - child: ActionChip( - avatar: Icon( - Icons.rule, - size: 16, - color: colorScheme.tertiary, - ), - label: Text( - context.l10n.dataQuality_detail_chipCount(count), - ), - onPressed: () => - context.push('/dives/quality?dive=${dive.id}'), + LayoutBuilder( + builder: (context, headerConstraints) { + // Scales with the header's own width instead of a flat cap, so + // a wide detail pane can spell out more type badges before + // collapsing to "+N" while a narrow one still reserves enough + // room for the site name/dates column on the left. + final badgeMaxWidth = (headerConstraints.maxWidth * 0.35).clamp( + 120.0, + 280.0, + ); + return Row( + children: [ + CircleAvatar( + radius: 24, + backgroundColor: colorScheme.primaryContainer, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + '#${dive.diveNumber ?? '-'}', + maxLines: 1, + style: TextStyle( + color: colorScheme.onPrimaryContainer, + fontWeight: FontWeight.bold, + // Pin the pre-scale size (CircleAvatar's implicit + // titleMedium default) so FittedBox scales from a + // theme-independent baseline. + fontSize: 16, ), - ); - }, - ), - if (dive.effectiveName != null && dive.site != null) - Text( - dive.site!.name, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - if (dive.site?.locationString.isNotEmpty == true) - Text( - dive.site!.locationString, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, ), ), - Text( - '${context.l10n.diveLog_detail_label_entry} ${units.formatDateTimeBullet(dive.effectiveEntryTime)}', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), ), - if (dive.exitTime != null) - Text( - '${context.l10n.diveLog_detail_label_exit} ${units.formatDateTimeBullet(dive.exitTime!)}', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + dive.effectiveName ?? + dive.site?.name ?? + context.l10n.diveLog_listPage_unknownSite, + style: Theme.of(context).textTheme.titleLarge, ), - ), - ], - ), - ), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - if (dive.rating != null) ...[ - ExcludeSemantics( - child: Icon( - Icons.star, - color: Colors.amber.shade600, - size: 20, - ), + Consumer( + builder: (context, ref, _) { + final count = + ref + .watch( + diveOpenFindingsCountProvider(dive.id), + ) + .value ?? + 0; + if (count == 0) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(top: 4), + child: ActionChip( + avatar: Icon( + Icons.rule, + size: 16, + color: colorScheme.tertiary, + ), + label: Text( + context.l10n.dataQuality_detail_chipCount( + count, + ), + ), + onPressed: () => context.push( + '/dives/quality?dive=${dive.id}', + ), + ), + ); + }, ), - const SizedBox(width: 4), + if (dive.effectiveName != null && dive.site != null) + Text( + dive.site!.name, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + if (dive.site?.locationString.isNotEmpty == true) + Text( + dive.site!.locationString, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), Text( - '${dive.rating}', - // Same ink-centering fix as DiveModeBadge: without - // it this number's default line leading isn't - // split evenly around its own glyph, so it - // doesn't sit on the same visual line as the star - // icon next to it. - style: Theme.of( - context, - ).textTheme.titleMedium?.inkCentered, - textHeightBehavior: inkCenteredTextHeightBehavior, + '${context.l10n.diveLog_detail_label_entry} ${units.formatDateTimeBullet(dive.effectiveEntryTime)}', + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: colorScheme.onSurfaceVariant), ), - const SizedBox(width: 8), + if (dive.exitTime != null) + Text( + '${context.l10n.diveLog_detail_label_exit} ${units.formatDateTimeBullet(dive.exitTime!)}', + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), ], - DiveModeBadge(mode: dive.diveMode), - ], + ), ), - if (dive.diveTypeIds.isNotEmpty) ...[ - const SizedBox(height: 8), - // Capped rather than left unbounded: Row hands a - // non-flex child unbounded width, which would let a - // long run of type badges grow without limit instead of - // wrapping under the rating/mode row. - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 200), - child: DiveTypeBadgeRow( - labels: [ - for (final typeId in dive.diveTypeIds) - diveTypeShortLabel( - context.l10n, - typeId, - typesById: diveTypesById, + Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + if (dive.rating != null) ...[ + ExcludeSemantics( + child: Icon( + Icons.star, + color: Colors.amber.shade600, + size: 20, + ), + ), + const SizedBox(width: 4), + Text( + '${dive.rating}', + // Same ink-centering fix as DiveModeBadge: without + // it this number's default line leading isn't + // split evenly around its own glyph, so it + // doesn't sit on the same visual line as the star + // icon next to it. + style: Theme.of( + context, + ).textTheme.titleMedium?.inkCentered, + textHeightBehavior: inkCenteredTextHeightBehavior, ), + const SizedBox(width: 8), + ], + DiveModeBadge(mode: dive.diveMode), ], ), - ), - ], + if (dive.diveTypeIds.isNotEmpty) ...[ + const SizedBox(height: 8), + // Capped rather than left unbounded: Row hands a + // non-flex child unbounded width, which would let a + // long run of type badges grow without limit instead of + // wrapping under the rating/mode row. The cap itself + // scales with the header's width (see badgeMaxWidth) + // rather than a flat constant. + ConstrainedBox( + constraints: BoxConstraints(maxWidth: badgeMaxWidth), + child: DiveTypeBadgeRow( + labels: [ + for (final typeId in dive.diveTypeIds) + diveTypeShortLabel( + context.l10n, + typeId, + typesById: diveTypesById, + ), + ], + ), + ), + ], + ], + ), ], - ), - ], + ); + }, ), const SizedBox(height: 16), Row( diff --git a/lib/features/dive_log/presentation/pages/dive_list_page.dart b/lib/features/dive_log/presentation/pages/dive_list_page.dart index 695e203a0e..f78b3b5e68 100644 --- a/lib/features/dive_log/presentation/pages/dive_list_page.dart +++ b/lib/features/dive_log/presentation/pages/dive_list_page.dart @@ -34,6 +34,7 @@ import 'package:submersion/features/dive_log/presentation/widgets/dive_numbering import 'package:submersion/features/dive_log/presentation/widgets/dive_profile_chart.dart'; import 'package:submersion/features/dive_log/presentation/widgets/dive_profile_panel.dart'; import 'package:submersion/features/dive_log/presentation/widgets/dive_summary_widget.dart'; +import 'package:submersion/features/dive_log/presentation/widgets/dive_type_badge_row.dart'; import 'package:submersion/features/dive_log/presentation/widgets/table_column_picker.dart'; import 'package:submersion/features/media/presentation/providers/lightroom_providers.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; @@ -738,6 +739,11 @@ class DiveListTile extends ConsumerWidget { /// English slug capitalization, matching the locale-independent export path. final DiveTypeLabelResolver? diveTypeLabelResolver; + /// Resolves a dive-type slug to its short-form abbreviation, for the badge + /// row on the stat line (mirrors the dive detail header's type badges). + /// When omitted, badges fall back to the slug's capitalization. + final DiveTypeLabelResolver? diveTypeShortLabelResolver; + const DiveListTile({ super.key, required this.diveId, @@ -767,6 +773,7 @@ class DiveListTile extends ConsumerWidget { this.summary, this.fullDive, this.diveTypeLabelResolver, + this.diveTypeShortLabelResolver, }); /// Calculate background color based on the active color attribute @@ -853,6 +860,11 @@ class DiveListTile extends ConsumerWidget { final titleField = slotField('title', DiveField.siteName); final dateField = slotField('date', DiveField.dateTime); + final diveTypeLabels = [ + for (final id in summary?.diveTypeIds ?? const []) + (diveTypeShortLabelResolver ?? Dive.diveTypeDisplayName)(id), + ]; + // Resolve the title and date lines from their slot assignments, keeping // the legacy rendering when the slot holds its default field (mirrors // CompactDiveListTile). @@ -1061,15 +1073,16 @@ class DiveListTile extends ConsumerWidget { ], ), const SizedBox(height: 6), - // Stats row plus tags. Tags flow onto the same line as the - // stats (in the space under the mini chart) when they fit, and - // wrap to the next line otherwise, keeping cards compact. + // Stat row (protected: sized to its own content first) plus + // the dive-type badges, right-aligned on the same line. Tags + // get their own line below instead of sharing this one, so + // neither can squeeze the other -- DiveTypeBadgeRow still + // collapses into a single "+N" badge if the stat row alone + // leaves it little room. Padding( padding: const EdgeInsetsDirectional.only(start: 52), - child: Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - spacing: 16, - runSpacing: 6, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ Row( mainAxisSize: MainAxisSize.min, @@ -1093,11 +1106,32 @@ class DiveListTile extends ConsumerWidget { ), ], ), - if (tags.isNotEmpty && detailedConfig.showTags) - TagChips(tags: tags, maxTags: 3), + if (diveTypeLabels.isNotEmpty) ...[ + const SizedBox(width: 8), + Expanded( + child: Align( + alignment: AlignmentDirectional.centerEnd, + child: DiveTypeBadgeRow( + labels: diveTypeLabels, + dense: true, + ), + ), + ), + ] else + const Spacer(), ], ), ), + // Tags, on their own line so a long tag name never competes + // with the stat row or the type badges for width (each chip + // is itself capped with an ellipsis as a defensive backstop). + if (tags.isNotEmpty && detailedConfig.showTags) ...[ + const SizedBox(height: 4), + Padding( + padding: const EdgeInsetsDirectional.only(start: 52), + child: TagChips(tags: tags, maxTags: 3), + ), + ], // Extra configurable fields area if (extraFields.isNotEmpty && (fullDive != null || summary != null)) ...[ diff --git a/lib/features/dive_log/presentation/widgets/compact_dive_list_tile.dart b/lib/features/dive_log/presentation/widgets/compact_dive_list_tile.dart index a83296ff06..69661669c9 100644 --- a/lib/features/dive_log/presentation/widgets/compact_dive_list_tile.dart +++ b/lib/features/dive_log/presentation/widgets/compact_dive_list_tile.dart @@ -5,9 +5,11 @@ import 'package:submersion/core/constants/dive_field.dart'; import 'package:submersion/core/constants/enums.dart'; import 'package:submersion/core/providers/provider.dart'; import 'package:submersion/core/utils/unit_formatter.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; import 'package:submersion/features/dive_log/domain/entities/dive_summary.dart'; import 'package:submersion/features/dive_log/presentation/formatters/dive_type_label_resolver.dart'; import 'package:submersion/features/dive_log/presentation/widgets/dive_mode_badge.dart'; +import 'package:submersion/features/dive_log/presentation/widgets/dive_type_badge_row.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; import 'package:submersion/l10n/l10n_extension.dart'; import 'package:submersion/shared/selection/selection_leading.dart'; @@ -58,6 +60,11 @@ class CompactDiveListTile extends ConsumerWidget { /// capitalization, matching the locale-independent export path. final DiveTypeLabelResolver? diveTypeLabelResolver; + /// Resolves a dive-type slug to its short-form abbreviation, for the badge + /// row on the stat line (mirrors the dive detail header's type badges). + /// When omitted, badges fall back to the slug's capitalization. + final DiveTypeLabelResolver? diveTypeShortLabelResolver; + const CompactDiveListTile({ super.key, required this.diveId, @@ -82,6 +89,7 @@ class CompactDiveListTile extends ConsumerWidget { this.stat1Field = DiveField.maxDepth, this.stat2Field = DiveField.bottomTime, this.diveTypeLabelResolver, + this.diveTypeShortLabelResolver, }); Color? _getAttributeBackgroundColor() { @@ -245,6 +253,11 @@ class CompactDiveListTile extends ConsumerWidget { ? duration != null : maxDepth != null); + final diveTypeLabels = [ + for (final id in summary?.diveTypeIds ?? const []) + (diveTypeShortLabelResolver ?? Dive.diveTypeDisplayName)(id), + ]; + // The highlight is the fill above, not an edge stripe -- the key marks the // row for tests without decorating it. return Container( @@ -366,6 +379,16 @@ class CompactDiveListTile extends ConsumerWidget { stat2HasValue ? accentColor : secondaryTextColor, ), ), + if (diveTypeLabels.isNotEmpty) + Expanded( + child: Align( + alignment: AlignmentDirectional.centerEnd, + child: DiveTypeBadgeRow( + labels: diveTypeLabels, + dense: true, + ), + ), + ), ], ), ), diff --git a/lib/features/dive_log/presentation/widgets/dive_list_content.dart b/lib/features/dive_log/presentation/widgets/dive_list_content.dart index 402452201c..80f2a6091c 100644 --- a/lib/features/dive_log/presentation/widgets/dive_list_content.dart +++ b/lib/features/dive_log/presentation/widgets/dive_list_content.dart @@ -1365,6 +1365,10 @@ class _DiveListContentState extends ConsumerState { // Built once for the whole list, not per row: the lookup map is shared by // every tile and only this widget subscribes to the dive-type list. final diveTypeLabelResolver = watchDiveTypeLabelResolver(ref, context.l10n); + final diveTypeShortLabelResolver = watchDiveTypeShortLabelResolver( + ref, + context.l10n, + ); // Check if detailed mode needs full Dive objects for non-summary fields final detailedConfig = ref.watch(detailedCardConfigProvider); @@ -1418,6 +1422,7 @@ class _DiveListContentState extends ConsumerState { return DiveListItem( summary: dive, diveTypeLabelResolver: diveTypeLabelResolver, + diveTypeShortLabelResolver: diveTypeShortLabelResolver, fullDive: fullDiveLookup[dive.id], diveNumber: dive.diveNumber ?? index + 1, colorValue: getCardColorValue(dive, colorAttribute), diff --git a/lib/features/dive_log/presentation/widgets/dive_list_item.dart b/lib/features/dive_log/presentation/widgets/dive_list_item.dart index 8ec92ae8fc..b993f47b6b 100644 --- a/lib/features/dive_log/presentation/widgets/dive_list_item.dart +++ b/lib/features/dive_log/presentation/widgets/dive_list_item.dart @@ -59,11 +59,19 @@ class DiveListItem extends ConsumerWidget { /// names, which is the bug #643 fixed. final DiveTypeLabelResolver diveTypeLabelResolver; + /// Resolves a dive-type slug to its short-form abbreviation, for the + /// compact and detailed cards' type-badge row (issue #1269 follow-up). + /// Required for the same reason as [diveTypeLabelResolver]: a badge row + /// falling back to English slugs under a non-English locale is the exact + /// bug #643 fixed for the Dive Type slot. + final DiveTypeLabelResolver diveTypeShortLabelResolver; + const DiveListItem({ super.key, required this.summary, required this.diveNumber, required this.diveTypeLabelResolver, + required this.diveTypeShortLabelResolver, this.fullDive, this.colorValue, this.minValueInList, @@ -118,6 +126,7 @@ class DiveListItem extends ConsumerWidget { stat1Field: _slotField(slots, 'stat1', DiveField.maxDepth), stat2Field: _slotField(slots, 'stat2', DiveField.bottomTime), diveTypeLabelResolver: diveTypeLabelResolver, + diveTypeShortLabelResolver: diveTypeShortLabelResolver, onTap: onTap, ); case ListViewMode.detailed: @@ -150,6 +159,7 @@ class DiveListItem extends ConsumerWidget { summary: summary, fullDive: fullDive, diveTypeLabelResolver: diveTypeLabelResolver, + diveTypeShortLabelResolver: diveTypeShortLabelResolver, ); } } diff --git a/lib/features/dive_log/presentation/widgets/dive_type_badge.dart b/lib/features/dive_log/presentation/widgets/dive_type_badge.dart index ed21b17c75..f818ac5be6 100644 --- a/lib/features/dive_log/presentation/widgets/dive_type_badge.dart +++ b/lib/features/dive_log/presentation/widgets/dive_type_badge.dart @@ -27,12 +27,19 @@ import 'package:submersion/shared/utils/ink_centered_text_style.dart'; class DiveTypeBadge extends StatelessWidget { final String label; - const DiveTypeBadge({super.key, required this.label}); + /// Tighter padding and type scale for list rows, matching + /// [DiveModeBadge]'s own `dense` variant so the two badge families read as + /// the same size next to each other in a dive-list card. + final bool dense; - /// Matches [DiveModeBadge]'s non-dense font size: close to the header's - /// titleMedium rating number, but a touch under it. - static double fontSizeOf(BuildContext context) => - (Theme.of(context).textTheme.titleMedium?.fontSize ?? 16) - 3; + const DiveTypeBadge({super.key, required this.label, this.dense = false}); + + /// Matches [DiveModeBadge]'s font size for the same [dense] value: close to + /// the header's titleMedium rating number (a touch under it) when not + /// dense, or the fixed 10.0 list-row size when dense. + static double fontSizeOf(BuildContext context, {bool dense = false}) => dense + ? 10.0 + : (Theme.of(context).textTheme.titleMedium?.fontSize ?? 16) - 3; /// Midpoint between outlineVariant and onSurfaceVariant: dim enough not to /// shout next to the mode badge, but still legible as text (outlineVariant @@ -49,10 +56,16 @@ class DiveTypeBadge extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; return Container( - // Matches DiveModeBadge's padding, including the asymmetric vertical - // split that compensates for the ink sitting a hair low within the - // tight ascent/descent box textHeightBehavior forces. - padding: const EdgeInsets.only(left: 4, right: 4, top: 2.5, bottom: 3.5), + // Matches DiveModeBadge's padding for the same dense value, including + // the asymmetric vertical split that compensates for the ink sitting a + // hair low within the tight ascent/descent box textHeightBehavior + // forces. + padding: EdgeInsets.only( + left: dense ? 3 : 4, + right: dense ? 3 : 4, + top: dense ? 1.5 : 2.5, + bottom: dense ? 2.5 : 3.5, + ), decoration: BoxDecoration( color: colorScheme.surface.withValues(alpha: 0.3), border: Border.all(color: colorScheme.outlineVariant), @@ -62,7 +75,7 @@ class DiveTypeBadge extends StatelessWidget { label, style: Theme.of(context).textTheme.labelSmall ?.copyWith( - fontSize: fontSizeOf(context), + fontSize: fontSizeOf(context, dense: dense), color: _textColor(colorScheme), fontWeight: FontWeight.bold, ) diff --git a/lib/features/dive_log/presentation/widgets/dive_type_badge_row.dart b/lib/features/dive_log/presentation/widgets/dive_type_badge_row.dart index 3aa2b0cb9d..8878a8473e 100644 --- a/lib/features/dive_log/presentation/widgets/dive_type_badge_row.dart +++ b/lib/features/dive_log/presentation/widgets/dive_type_badge_row.dart @@ -13,20 +13,26 @@ import 'package:submersion/features/dive_log/presentation/widgets/dive_type_badg class DiveTypeBadgeRow extends StatelessWidget { final List labels; - const DiveTypeBadgeRow({super.key, required this.labels}); + /// Renders every badge (including the "+N" overflow badge) in + /// [DiveTypeBadge]'s dense size, matching [DiveModeBadge]'s own `dense` + /// variant for list rows. + final bool dense; + + const DiveTypeBadgeRow({super.key, required this.labels, this.dense = false}); static const _spacing = 6.0; - // DiveTypeBadge's horizontal padding (4 each side) plus its 1px border - // each side -- the width a badge adds on top of its text. - static const _badgeChrome = 4.0 * 2 + 1.0 * 2; + // DiveTypeBadge's horizontal padding each side (dense: 3, non-dense: 4) + // plus its 1px border each side -- the width a badge adds on top of its + // text. + static double _badgeChrome(bool dense) => (dense ? 3.0 : 4.0) * 2 + 1.0 * 2; @override Widget build(BuildContext context) { if (labels.isEmpty) return const SizedBox.shrink(); final style = Theme.of(context).textTheme.labelSmall?.copyWith( - fontSize: DiveTypeBadge.fontSizeOf(context), + fontSize: DiveTypeBadge.fontSizeOf(context, dense: dense), fontWeight: FontWeight.bold, ); final direction = Directionality.of(context); @@ -37,7 +43,7 @@ class DiveTypeBadgeRow extends StatelessWidget { textDirection: direction, maxLines: 1, )..layout(); - return painter.width + _badgeChrome; + return painter.width + _badgeChrome(dense); } return LayoutBuilder( @@ -65,13 +71,13 @@ class DiveTypeBadgeRow extends StatelessWidget { children: [ for (var i = 0; i < visibleCount; i++) ...[ if (i > 0) const SizedBox(width: _spacing), - DiveTypeBadge(label: labels[i]), + DiveTypeBadge(label: labels[i], dense: dense), ], if (hidden.isNotEmpty) ...[ if (visibleCount > 0) const SizedBox(width: _spacing), Tooltip( message: hidden.join(', '), - child: DiveTypeBadge(label: '+${hidden.length}'), + child: DiveTypeBadge(label: '+${hidden.length}', dense: dense), ), ], ], diff --git a/lib/features/trips/presentation/widgets/story/trip_story_day_card.dart b/lib/features/trips/presentation/widgets/story/trip_story_day_card.dart index 410140d779..eb993523ae 100644 --- a/lib/features/trips/presentation/widgets/story/trip_story_day_card.dart +++ b/lib/features/trips/presentation/widgets/story/trip_story_day_card.dart @@ -36,6 +36,10 @@ class TripStoryDayCard extends ConsumerWidget { // Built once for the day's dives rather than per row. final diveTypeLabelResolver = watchDiveTypeLabelResolver(ref, context.l10n); + final diveTypeShortLabelResolver = watchDiveTypeShortLabelResolver( + ref, + context.l10n, + ); // The day title, subtitle, and Planned chip live in the sticky // TripStoryDayHeader above this card; the card is body-only. A planned @@ -84,6 +88,7 @@ class TripStoryDayCard extends ConsumerWidget { (index, dive) => DiveListItem( summary: DiveSummary.fromDive(dive), diveTypeLabelResolver: diveTypeLabelResolver, + diveTypeShortLabelResolver: diveTypeShortLabelResolver, // The story already holds the full Dive; pass it so the // configurable card can resolve fields absent from the // summary (tanks, SAC, buddies, weights). diff --git a/test/features/dive_log/presentation/pages/dive_detail_page_test.dart b/test/features/dive_log/presentation/pages/dive_detail_page_test.dart index 03b40f26f1..62a074976d 100644 --- a/test/features/dive_log/presentation/pages/dive_detail_page_test.dart +++ b/test/features/dive_log/presentation/pages/dive_detail_page_test.dart @@ -1603,5 +1603,22 @@ void main() { expect(find.byType(DiveTypeBadge), findsNothing); }); + + testWidgets( + 'spells out more badges instead of collapsing when the header is wide', + (tester) async { + // The cap scales with the header's own width (issue #1269 follow-up) + // rather than a flat constant, so a comfortably wide header should + // show several real labels, not immediately fall back to "+N". + final dive = createTestDiveWithBottomTime().copyWith( + diveTypeIds: ['wreck', 'night', 'drift', 'cave'], + ); + await _pumpDetailPage(tester, dive); + + expect(find.text('Wreck'), findsOneWidget); + expect(find.text('Night'), findsOneWidget); + expect(tester.takeException(), isNull); + }, + ); }); } diff --git a/test/features/dive_log/presentation/pages/dive_list_tile_dive_type_localization_test.dart b/test/features/dive_log/presentation/pages/dive_list_tile_dive_type_localization_test.dart index 12158d14a4..dabe135c9f 100644 --- a/test/features/dive_log/presentation/pages/dive_list_tile_dive_type_localization_test.dart +++ b/test/features/dive_log/presentation/pages/dive_list_tile_dive_type_localization_test.dart @@ -99,8 +99,8 @@ void main() { ), diveTypesProvider.overrideWith((ref) async => types ?? loadedTypes), ], - // The resolver is built through the production helper, so these cases - // still cover the provider -> label seam the tile no longer owns. + // The resolvers are built through the production helpers, so these + // cases still cover the provider -> label seam the tile no longer owns. child: Consumer( builder: (context, ref, _) => DiveListTile( diveId: 'd1', @@ -112,6 +112,10 @@ void main() { summary: summary, fullDive: fullDive, diveTypeLabelResolver: watchDiveTypeLabelResolver(ref, context.l10n), + diveTypeShortLabelResolver: watchDiveTypeShortLabelResolver( + ref, + context.l10n, + ), ), ), ); @@ -223,7 +227,9 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.text('Muck'), findsOneWidget); + // A custom type has no short form, so the slot and the type-badge row + // both fall back to the diver's own name -- both are expected here. + expect(find.text('Muck'), findsWidgets); }); testWidgets( @@ -238,7 +244,7 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.text('Hausriff-Wrack'), findsOneWidget); + expect(find.text('Hausriff-Wrack'), findsWidgets); expect(find.text('Wracktauchen'), findsNothing); }, ); @@ -267,7 +273,10 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.text('Wreck'), findsOneWidget); + // The slot renders "Wreck" and the new type-badge row (issue #1269) + // renders its own short label alongside it, which for this built-in + // slug happens to be the same English word -- so both are expected. + expect(find.text('Wreck'), findsWidgets); }); }); } diff --git a/test/features/dive_log/presentation/widgets/dive_list_tile_dive_type_localization_test.dart b/test/features/dive_log/presentation/widgets/dive_list_tile_dive_type_localization_test.dart index 46cbb7fc2e..34717f630b 100644 --- a/test/features/dive_log/presentation/widgets/dive_list_tile_dive_type_localization_test.dart +++ b/test/features/dive_log/presentation/widgets/dive_list_tile_dive_type_localization_test.dart @@ -67,7 +67,11 @@ void main() { ); Widget harness({ - required Widget Function(DiveTypeLabelResolver resolve) builder, + required Widget Function( + DiveTypeLabelResolver resolve, + DiveTypeLabelResolver resolveShort, + ) + builder, required Locale locale, List? types, }) { @@ -77,11 +81,13 @@ void main() { settingsProvider.overrideWith((ref) => _TestSettingsNotifier()), diveTypesProvider.overrideWith((ref) async => types ?? loadedTypes), ], - // The resolver is built through the production helper, so these cases - // still cover the provider -> label seam the tiles no longer own. + // The resolvers are built through the production helpers, so these + // cases still cover the provider -> label seam the tiles no longer own. child: Consumer( - builder: (context, ref, _) => - builder(watchDiveTypeLabelResolver(ref, context.l10n)), + builder: (context, ref, _) => builder( + watchDiveTypeLabelResolver(ref, context.l10n), + watchDiveTypeShortLabelResolver(ref, context.l10n), + ), ), ); } @@ -97,7 +103,7 @@ void main() { }) => harness( locale: locale, types: types, - builder: (resolve) => CompactDiveListTile( + builder: (resolve, resolveShort) => CompactDiveListTile( diveId: 'd1', diveNumber: 7, dateTime: DateTime(2026, 3, 15), @@ -110,6 +116,7 @@ void main() { stat1Field: stat1Field, onTap: () {}, diveTypeLabelResolver: resolve, + diveTypeShortLabelResolver: resolveShort, ), ); @@ -178,7 +185,9 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.textContaining('Muck'), findsOneWidget); + // A custom type has no short form, so the slot and the type-badge row + // both fall back to the diver's own name -- both are expected here. + expect(find.textContaining('Muck'), findsWidgets); }); testWidgets( @@ -196,7 +205,9 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.textContaining('Hausriff-Wrack'), findsOneWidget); + // A custom type has no short form, so the slot and the type-badge + // row both fall back to the diver's own name -- both are expected. + expect(find.textContaining('Hausriff-Wrack'), findsWidgets); expect(find.textContaining('Wracktauchen'), findsNothing); }, ); @@ -210,7 +221,7 @@ void main() { harness( locale: const Locale('de'), types: const [], - builder: (resolve) => CompactDiveListTile( + builder: (resolve, resolveShort) => CompactDiveListTile( diveId: 'd1', diveNumber: 7, dateTime: DateTime(2026, 3, 15), @@ -219,6 +230,7 @@ void main() { stat1Field: DiveField.diveTypeName, onTap: () {}, diveTypeLabelResolver: resolve, + diveTypeShortLabelResolver: resolveShort, ), ), ); @@ -235,7 +247,9 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.textContaining('Deep wreck'), findsOneWidget); + // An unknown slug has no short form either, so the slot and the + // type-badge row both fall back to the same slug capitalization. + expect(find.textContaining('Deep wreck'), findsWidgets); }); testWidgets('English still shows the English built-in label', ( @@ -246,7 +260,10 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.textContaining('Wreck'), findsOneWidget); + // The slot renders "Wreck" and the new type-badge row (issue #1269) + // renders its own short label alongside it, which for this built-in + // slug happens to be the same English word -- so both are expected. + expect(find.textContaining('Wreck'), findsWidgets); expect(find.textContaining('Wracktauchen'), findsNothing); }); @@ -259,7 +276,7 @@ void main() { await tester.pumpWidget( harness( locale: const Locale('de'), - builder: (resolve) => CompactDiveListTile( + builder: (resolve, resolveShort) => CompactDiveListTile( diveId: 'd1', diveNumber: 7, dateTime: DateTime(2026, 3, 15), @@ -269,6 +286,7 @@ void main() { stat1Field: DiveField.waterTemp, onTap: () {}, diveTypeLabelResolver: resolve, + diveTypeShortLabelResolver: resolveShort, ), ), ); @@ -289,7 +307,7 @@ void main() { }) => harness( locale: locale, types: types, - builder: (resolve) => DenseDiveListTile( + builder: (resolve, _) => DenseDiveListTile( diveId: 'd1', diveNumber: 7, dateTime: DateTime(2026, 3, 15), @@ -383,7 +401,7 @@ void main() { await tester.pumpWidget( harness( locale: const Locale('de'), - builder: (resolve) => DenseDiveListTile( + builder: (resolve, _) => DenseDiveListTile( diveId: 'd1', diveNumber: 7, dateTime: DateTime(2026, 3, 15), diff --git a/test/features/dive_log/presentation/widgets/dive_list_tile_type_badges_test.dart b/test/features/dive_log/presentation/widgets/dive_list_tile_type_badges_test.dart new file mode 100644 index 0000000000..a2a66fe4af --- /dev/null +++ b/test/features/dive_log/presentation/widgets/dive_list_tile_type_badges_test.dart @@ -0,0 +1,234 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:submersion/core/constants/list_view_mode.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive_summary.dart'; +import 'package:submersion/features/dive_log/presentation/formatters/dive_type_label_resolver.dart'; +import 'package:submersion/features/dive_log/presentation/pages/dive_list_page.dart'; +import 'package:submersion/features/dive_log/presentation/providers/view_config_providers.dart'; +import 'package:submersion/features/dive_log/presentation/widgets/compact_dive_list_tile.dart'; +import 'package:submersion/features/dive_log/presentation/widgets/dive_mode_badge.dart'; +import 'package:submersion/features/dive_log/presentation/widgets/dive_type_badge.dart'; +import 'package:submersion/features/dive_log/presentation/widgets/dive_type_badge_row.dart'; +import 'package:submersion/features/dive_types/presentation/providers/dive_type_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/features/tags/domain/entities/tag.dart'; +import 'package:submersion/features/tags/presentation/widgets/tag_input_widget.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +import '../../../../helpers/test_app.dart'; + +/// Issue #1269 follow-up: the dive list's compact and detailed cards show a +/// row of dive-type badges on the same line as the depth stat, right-aligned, +/// matching the badges already shown in the dive detail header. +class _TestSettingsNotifier extends StateNotifier + implements SettingsNotifier { + _TestSettingsNotifier() : super(const AppSettings()); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _TestCardConfigNotifier extends CardViewConfigNotifier { + _TestCardConfigNotifier() : super.withMode(ListViewMode.detailed) { + state = CardViewConfig.defaultDetailed(); + } +} + +void main() { + DiveSummary summaryWith(List ids) => DiveSummary( + id: 'd1', + diveNumber: 7, + dateTime: DateTime(2026, 3, 15), + siteName: 'Blue Hole', + maxDepth: 20.0, + bottomTime: const Duration(minutes: 30), + diveTypeIds: ids, + sortTimestamp: 0, + ); + + Widget harness({ + required Widget Function(DiveTypeLabelResolver resolve) builder, + }) { + return testApp( + overrides: [ + settingsProvider.overrideWith((ref) => _TestSettingsNotifier()), + diveTypesProvider.overrideWith((ref) async => const []), + detailedCardConfigProvider.overrideWith( + (ref) => _TestCardConfigNotifier(), + ), + ], + child: Consumer( + builder: (context, ref, _) => + builder(watchDiveTypeShortLabelResolver(ref, context.l10n)), + ), + ); + } + + group('CompactDiveListTile type badges', () { + Widget tile(List diveTypeIds) => harness( + builder: (resolve) => CompactDiveListTile( + diveId: 'd1', + diveNumber: 7, + dateTime: DateTime(2026, 3, 15), + siteName: 'Blue Hole', + maxDepth: 20.0, + duration: const Duration(minutes: 30), + summary: summaryWith(diveTypeIds), + onTap: () {}, + diveTypeShortLabelResolver: resolve, + ), + ); + + testWidgets('shows a badge for each of the dive\'s types', (tester) async { + await tester.pumpWidget(tile(['wreck', 'night'])); + await tester.pumpAndSettle(); + + expect(find.byType(DiveTypeBadge), findsNWidgets(2)); + expect(find.text('Wreck'), findsOneWidget); + expect(find.text('Night'), findsOneWidget); + }); + + testWidgets('shows no badges for a dive with no types', (tester) async { + await tester.pumpWidget(tile([])); + await tester.pumpAndSettle(); + + expect(find.byType(DiveTypeBadge), findsNothing); + }); + + testWidgets('the badge row sits right of the stat row midpoint', ( + tester, + ) async { + await tester.pumpWidget(tile(['wreck'])); + await tester.pumpAndSettle(); + + final cardRect = tester.getRect(find.byType(Card)); + final badgeRect = tester.getRect(find.byType(DiveTypeBadge)); + expect(badgeRect.center.dx, greaterThan(cardRect.center.dx)); + }); + + testWidgets('renders at the same size as the dense OC/CCR badge', ( + tester, + ) async { + await tester.pumpWidget(tile(['wreck'])); + await tester.pumpAndSettle(); + + final typeBadgeSize = tester.getSize(find.byType(DiveTypeBadge)); + final modeBadgeSize = tester.getSize(find.byType(DiveModeBadge)); + expect(typeBadgeSize.height, modeBadgeSize.height); + }); + }); + + group('DiveListTile detailed-view type badges', () { + Widget tile(List diveTypeIds) => harness( + builder: (resolve) => DiveListTile( + diveId: 'd1', + diveNumber: 7, + dateTime: DateTime(2026, 3, 15), + siteName: 'Blue Hole', + maxDepth: 20.0, + duration: const Duration(minutes: 30), + summary: summaryWith(diveTypeIds), + onTap: () {}, + diveTypeShortLabelResolver: resolve, + ), + ); + + testWidgets('shows a badge for each of the dive\'s types', (tester) async { + await tester.pumpWidget(tile(['wreck', 'night'])); + await tester.pumpAndSettle(); + + expect(find.byType(DiveTypeBadge), findsNWidgets(2)); + expect(find.text('Wreck'), findsOneWidget); + expect(find.text('Night'), findsOneWidget); + }); + + testWidgets('shows no badges for a dive with no types', (tester) async { + await tester.pumpWidget(tile([])); + await tester.pumpAndSettle(); + + expect(find.byType(DiveTypeBadge), findsNothing); + }); + + testWidgets('the badge row sits right of the stat row midpoint', ( + tester, + ) async { + await tester.pumpWidget(tile(['wreck'])); + await tester.pumpAndSettle(); + + final cardRect = tester.getRect(find.byType(Card)); + final badgeRect = tester.getRect(find.byType(DiveTypeBadge)); + expect(badgeRect.center.dx, greaterThan(cardRect.center.dx)); + }); + + testWidgets('renders at the same size as the dense OC/CCR badge', ( + tester, + ) async { + await tester.pumpWidget(tile(['wreck'])); + await tester.pumpAndSettle(); + + final typeBadgeSize = tester.getSize(find.byType(DiveTypeBadge)); + final modeBadgeSize = tester.getSize(find.byType(DiveModeBadge)); + expect(typeBadgeSize.height, modeBadgeSize.height); + }); + + testWidgets('does not overflow with several types and a long tag name', ( + tester, + ) async { + // Regression: a long tag (e.g. an import-source tag) used to render + // unclipped in the stats/tags Wrap. Nothing there checked horizontal + // overflow until the type-badge row added an enclosing Row, which + // surfaced the pre-existing "tag too wide" case as a RenderFlex + // overflow (issue #1269 follow-up). + await tester.pumpWidget( + harness( + builder: (resolve) => Align( + alignment: Alignment.topLeft, + // Matches the narrow list panel in the real master-detail + // layout where this overflow was observed -- the default test + // surface is wide enough to hide it. + child: SizedBox( + width: 380, + child: DiveListTile( + diveId: 'd1', + diveNumber: 7, + dateTime: DateTime(2026, 3, 15), + siteName: 'Blue Hole', + maxDepth: 20.0, + duration: const Duration(minutes: 30), + summary: summaryWith([ + 'wreck', + 'night', + 'drift', + 'cave', + 'ice', + ]), + tags: [ + Tag( + id: 't1', + name: + '100_shearwater_cloud_export_with_one_ccr_dive.db.export', + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + ), + ], + onTap: () {}, + diveTypeShortLabelResolver: resolve, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + + // Tags render on their own line below the stat/badge row, so a long + // tag name never competes with them for width. + final badgeRect = tester.getRect(find.byType(DiveTypeBadgeRow)); + final tagRect = tester.getRect(find.byType(TagChips)); + expect(tagRect.top, greaterThanOrEqualTo(badgeRect.bottom)); + }); + }); +} From 936262f88b79dfd44f82083b615b08f2464d845b Mon Sep 17 00:00:00 2001 From: Cornelius Schmale Date: Fri, 28 Aug 2026 00:29:33 +0200 Subject: [PATCH 3/5] feat: per-type visibility toggles for dive type badges (schema v172) Adds show_in_detail_header and show_in_list_view columns to dive_types, both defaulting to shown so existing dives keep their current badges. Each type -- built-in or custom -- gets an edit dialog (tap its row on the Manage Dive Types page) with two checkboxes controlling whether its badge appears in the dive detail header, the dive list, both, or neither. Name and short-name editing stay disabled for built-in types, which remain protected from edits to their core definition; visibility is a separate per-diver display preference and is editable on every type via a new repository method that bypasses that protection. The detail header and both list-view badge rows now filter dive.diveTypeIds against these flags before building their label lists. --- lib/core/database/database.dart | 55 ++++- .../widgets/recent_dives_card.dart | 4 + .../formatters/dive_type_label_resolver.dart | 23 +++ .../presentation/pages/dive_detail_page.dart | 10 +- .../presentation/pages/dive_list_page.dart | 8 +- .../widgets/compact_dive_list_tile.dart | 8 +- .../widgets/dive_list_content.dart | 4 + .../presentation/widgets/dive_list_item.dart | 8 + .../repositories/dive_type_repository.dart | 46 +++++ .../domain/entities/dive_type_entity.dart | 20 ++ .../presentation/pages/dive_types_page.dart | 189 +++++++++++++++++- .../providers/dive_type_providers.dart | 19 ++ .../widgets/story/trip_story_day_card.dart | 4 + lib/l10n/arb/app_de.arb | 9 + lib/l10n/arb/app_en.arb | 23 +++ lib/l10n/arb/app_localizations.dart | 54 +++++ lib/l10n/arb/app_localizations_ar.dart | 34 ++++ lib/l10n/arb/app_localizations_de.dart | 34 ++++ lib/l10n/arb/app_localizations_en.dart | 34 ++++ lib/l10n/arb/app_localizations_es.dart | 34 ++++ lib/l10n/arb/app_localizations_fr.dart | 34 ++++ lib/l10n/arb/app_localizations_he.dart | 34 ++++ lib/l10n/arb/app_localizations_hu.dart | 34 ++++ lib/l10n/arb/app_localizations_it.dart | 34 ++++ lib/l10n/arb/app_localizations_nl.dart | 34 ++++ lib/l10n/arb/app_localizations_pt.dart | 34 ++++ lib/l10n/arb/app_localizations_zh.dart | 34 ++++ ...ration_v174_dive_type_visibility_test.dart | 79 ++++++++ .../pages/dive_detail_page_test.dart | 30 +++ .../dive_list_tile_type_badges_test.dart | 86 +++++++- .../dive_type_repository_visibility_test.dart | 98 +++++++++ .../pages/dive_types_page_test.dart | 128 ++++++++++++ 32 files changed, 1262 insertions(+), 17 deletions(-) create mode 100644 test/core/database/migration_v174_dive_type_visibility_test.dart create mode 100644 test/features/dive_types/data/repositories/dive_type_repository_visibility_test.dart diff --git a/lib/core/database/database.dart b/lib/core/database/database.dart index 99c2464f50..b5da5fb595 100644 --- a/lib/core/database/database.dart +++ b/lib/core/database/database.dart @@ -2135,6 +2135,18 @@ class DiveTypes extends Table { /// builtInDiveTypeShortName instead. Null means the diver hasn't set one. TextColumn get shortName => text().nullable()(); + /// Whether this type's badge appears in the dive detail header's type-badge + /// row (v174). Defaults to shown, so existing dives keep their current + /// badges after the upgrade. + BoolColumn get showInDetailHeader => + boolean().withDefault(const Constant(true))(); + + /// Whether this type's badge appears in the dive list card's type-badge + /// row (v174). Independent of [showInDetailHeader] -- a diver may want a + /// type visible in the detail header but not cluttering every list row. + BoolColumn get showInListView => + boolean().withDefault(const Constant(true))(); + @override Set get primaryKey => {id}; } @@ -3258,7 +3270,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 = 173; + static const int currentSchemaVersion = 174; /// The oldest schema whose reader can apply this build's sync payloads /// without loss or misinterpretation (the compatibility floor). @@ -3604,6 +3616,10 @@ class AppDatabase extends _$AppDatabase { // comment above already reserves 173 for this PR, so that's the number // landed here directly. 173, + // v174: dive_types.show_in_detail_header and dive_types.show_in_list_view, + // per-type toggles for which badge rows a diver's types appear in. + // Issue #1269 follow-up. + 174, ]; /// Idempotent DDL for the v106 connector-suggestion columns (Lightroom @@ -5416,6 +5432,32 @@ class AppDatabase extends _$AppDatabase { await customStatement('ALTER TABLE dive_types ADD COLUMN short_name TEXT'); } + /// Idempotent DDL for the v174 dive_types.show_in_detail_header and + /// dive_types.show_in_list_view columns: per-type toggles for which + /// badge rows a diver's types appear in (issue #1269 follow-up). Both + /// default to shown (1) so existing dives keep their current badges. + /// Called from the v174 onUpgrade step and the beforeOpen backstop, + /// matching the _assertDiveTypeShortNameColumn pattern so a schema-version + /// collision cannot strand a database without them. Self-guarding when the + /// table is absent (minimal migration-test fixtures). + Future _assertDiveTypeVisibilityColumns() async { + final cols = await customSelect("PRAGMA table_info('dive_types')").get(); + if (cols.isEmpty) return; + final names = cols.map((c) => c.read('name')).toSet(); + if (!names.contains('show_in_detail_header')) { + await customStatement( + 'ALTER TABLE dive_types ADD COLUMN show_in_detail_header ' + 'INTEGER NOT NULL DEFAULT 1 CHECK (show_in_detail_header IN (0, 1))', + ); + } + if (!names.contains('show_in_list_view')) { + await customStatement( + 'ALTER TABLE dive_types ADD COLUMN show_in_list_view ' + 'INTEGER NOT NULL DEFAULT 1 CHECK (show_in_list_view IN (0, 1))', + ); + } + } + /// One-time attribution of existing dive_profiles rows to their owning /// dive_data_sources row (issue #1149). /// @@ -8947,6 +8989,12 @@ class AppDatabase extends _$AppDatabase { await _assertDiveTypeShortNameColumn(); } if (from < 173) await reportProgress(); + // v174: dive_types.show_in_detail_header and + // dive_types.show_in_list_view, per-type badge-row visibility. + if (from < 174) { + await _assertDiveTypeVisibilityColumns(); + } + if (from < 174) await reportProgress(); }, beforeOpen: (details) async { // Enable foreign keys @@ -9172,6 +9220,11 @@ class AppDatabase extends _$AppDatabase { // parallel-branch version-collision self-heal). await _assertDiveTypeShortNameColumn(); + // v174 backstop: re-assert dive_types.show_in_detail_header and + // dive_types.show_in_list_view (same parallel-branch + // version-collision self-heal). + await _assertDiveTypeVisibilityColumns(); + // v145 backstop: re-assert the gps_tracks provenance and trim columns. await _assertGpsTrackColumns(); diff --git a/lib/features/dashboard/presentation/widgets/recent_dives_card.dart b/lib/features/dashboard/presentation/widgets/recent_dives_card.dart index 982f438bf2..75d7978927 100644 --- a/lib/features/dashboard/presentation/widgets/recent_dives_card.dart +++ b/lib/features/dashboard/presentation/widgets/recent_dives_card.dart @@ -95,6 +95,8 @@ class RecentDivesCard extends ConsumerWidget { ref, context.l10n, ); + final diveTypeListVisibilityPredicate = + watchDiveTypeListVisibilityPredicate(ref); final list = Column( children: dives.asMap().entries.map((entry) { @@ -108,6 +110,8 @@ class RecentDivesCard extends ConsumerWidget { summary: DiveSummary.fromDive(dive), diveTypeLabelResolver: diveTypeLabelResolver, diveTypeShortLabelResolver: diveTypeShortLabelResolver, + diveTypeListVisibilityPredicate: + diveTypeListVisibilityPredicate, fullDive: dive, diveNumber: dive.diveNumber ?? index + 1, colorValue: getCardColorValueFromDive(dive, colorAttribute), diff --git a/lib/features/dive_log/presentation/formatters/dive_type_label_resolver.dart b/lib/features/dive_log/presentation/formatters/dive_type_label_resolver.dart index f2fef30d18..8c629b0034 100644 --- a/lib/features/dive_log/presentation/formatters/dive_type_label_resolver.dart +++ b/lib/features/dive_log/presentation/formatters/dive_type_label_resolver.dart @@ -50,3 +50,26 @@ DiveTypeLabelResolver watchDiveTypeShortLabelResolver( }; return (id) => diveTypeShortLabel(l10n, id, typesById: typesById); } + +/// Whether a dive-type slug's badge should appear in the dive list's +/// type-badge row (issue #1269 follow-up). Independent of the header's own +/// visibility toggle -- a diver may want a type in one badge row but not +/// the other. +typedef DiveTypeListVisibilityPredicate = bool Function(String id); + +/// Builds a [DiveTypeListVisibilityPredicate] from the currently loaded dive +/// types. Same call-once-per-list contract as [watchDiveTypeLabelResolver]. +/// +/// A slug absent from the loaded types (not yet loaded, or deleted out from +/// under a still-referencing dive) resolves to visible -- unknown is not the +/// same as explicitly hidden. +DiveTypeListVisibilityPredicate watchDiveTypeListVisibilityPredicate( + WidgetRef ref, +) { + final typesById = { + for (final t + in ref.watch(diveTypesProvider).value ?? const []) + t.id: t, + }; + return (id) => typesById[id]?.showInListView ?? true; +} diff --git a/lib/features/dive_log/presentation/pages/dive_detail_page.dart b/lib/features/dive_log/presentation/pages/dive_detail_page.dart index 62cf45f98a..f722026938 100644 --- a/lib/features/dive_log/presentation/pages/dive_detail_page.dart +++ b/lib/features/dive_log/presentation/pages/dive_detail_page.dart @@ -1231,6 +1231,12 @@ class _DiveDetailPageState extends ConsumerState { in ref.watch(diveTypesProvider).value ?? const []) t.id: t, }; + // A type absent from diveTypesById (not yet loaded, or deleted out from + // under a still-referencing dive) stays shown -- unknown is not the same + // as explicitly hidden. + final visibleHeaderTypeIds = dive.diveTypeIds + .where((id) => diveTypesById[id]?.showInDetailHeader ?? true) + .toList(); final content = Padding( padding: const EdgeInsets.all(16), @@ -1370,7 +1376,7 @@ class _DiveDetailPageState extends ConsumerState { DiveModeBadge(mode: dive.diveMode), ], ), - if (dive.diveTypeIds.isNotEmpty) ...[ + if (visibleHeaderTypeIds.isNotEmpty) ...[ const SizedBox(height: 8), // Capped rather than left unbounded: Row hands a // non-flex child unbounded width, which would let a @@ -1382,7 +1388,7 @@ class _DiveDetailPageState extends ConsumerState { constraints: BoxConstraints(maxWidth: badgeMaxWidth), child: DiveTypeBadgeRow( labels: [ - for (final typeId in dive.diveTypeIds) + for (final typeId in visibleHeaderTypeIds) diveTypeShortLabel( context.l10n, typeId, diff --git a/lib/features/dive_log/presentation/pages/dive_list_page.dart b/lib/features/dive_log/presentation/pages/dive_list_page.dart index f78b3b5e68..e5fa8c2b15 100644 --- a/lib/features/dive_log/presentation/pages/dive_list_page.dart +++ b/lib/features/dive_log/presentation/pages/dive_list_page.dart @@ -744,6 +744,10 @@ class DiveListTile extends ConsumerWidget { /// When omitted, badges fall back to the slug's capitalization. final DiveTypeLabelResolver? diveTypeShortLabelResolver; + /// Whether a dive-type slug's badge should appear in the badge row + /// (issue #1269 follow-up). When omitted, every type is shown. + final DiveTypeListVisibilityPredicate? diveTypeListVisibilityPredicate; + const DiveListTile({ super.key, required this.diveId, @@ -774,6 +778,7 @@ class DiveListTile extends ConsumerWidget { this.fullDive, this.diveTypeLabelResolver, this.diveTypeShortLabelResolver, + this.diveTypeListVisibilityPredicate, }); /// Calculate background color based on the active color attribute @@ -862,7 +867,8 @@ class DiveListTile extends ConsumerWidget { final diveTypeLabels = [ for (final id in summary?.diveTypeIds ?? const []) - (diveTypeShortLabelResolver ?? Dive.diveTypeDisplayName)(id), + if (diveTypeListVisibilityPredicate?.call(id) ?? true) + (diveTypeShortLabelResolver ?? Dive.diveTypeDisplayName)(id), ]; // Resolve the title and date lines from their slot assignments, keeping diff --git a/lib/features/dive_log/presentation/widgets/compact_dive_list_tile.dart b/lib/features/dive_log/presentation/widgets/compact_dive_list_tile.dart index 69661669c9..4b8186ed7b 100644 --- a/lib/features/dive_log/presentation/widgets/compact_dive_list_tile.dart +++ b/lib/features/dive_log/presentation/widgets/compact_dive_list_tile.dart @@ -65,6 +65,10 @@ class CompactDiveListTile extends ConsumerWidget { /// When omitted, badges fall back to the slug's capitalization. final DiveTypeLabelResolver? diveTypeShortLabelResolver; + /// Whether a dive-type slug's badge should appear in the badge row + /// (issue #1269 follow-up). When omitted, every type is shown. + final DiveTypeListVisibilityPredicate? diveTypeListVisibilityPredicate; + const CompactDiveListTile({ super.key, required this.diveId, @@ -90,6 +94,7 @@ class CompactDiveListTile extends ConsumerWidget { this.stat2Field = DiveField.bottomTime, this.diveTypeLabelResolver, this.diveTypeShortLabelResolver, + this.diveTypeListVisibilityPredicate, }); Color? _getAttributeBackgroundColor() { @@ -255,7 +260,8 @@ class CompactDiveListTile extends ConsumerWidget { final diveTypeLabels = [ for (final id in summary?.diveTypeIds ?? const []) - (diveTypeShortLabelResolver ?? Dive.diveTypeDisplayName)(id), + if (diveTypeListVisibilityPredicate?.call(id) ?? true) + (diveTypeShortLabelResolver ?? Dive.diveTypeDisplayName)(id), ]; // The highlight is the fill above, not an edge stripe -- the key marks the diff --git a/lib/features/dive_log/presentation/widgets/dive_list_content.dart b/lib/features/dive_log/presentation/widgets/dive_list_content.dart index 80f2a6091c..8c03d1017b 100644 --- a/lib/features/dive_log/presentation/widgets/dive_list_content.dart +++ b/lib/features/dive_log/presentation/widgets/dive_list_content.dart @@ -1369,6 +1369,8 @@ class _DiveListContentState extends ConsumerState { ref, context.l10n, ); + final diveTypeListVisibilityPredicate = + watchDiveTypeListVisibilityPredicate(ref); // Check if detailed mode needs full Dive objects for non-summary fields final detailedConfig = ref.watch(detailedCardConfigProvider); @@ -1423,6 +1425,8 @@ class _DiveListContentState extends ConsumerState { summary: dive, diveTypeLabelResolver: diveTypeLabelResolver, diveTypeShortLabelResolver: diveTypeShortLabelResolver, + diveTypeListVisibilityPredicate: + diveTypeListVisibilityPredicate, fullDive: fullDiveLookup[dive.id], diveNumber: dive.diveNumber ?? index + 1, colorValue: getCardColorValue(dive, colorAttribute), diff --git a/lib/features/dive_log/presentation/widgets/dive_list_item.dart b/lib/features/dive_log/presentation/widgets/dive_list_item.dart index b993f47b6b..b2773e6a1a 100644 --- a/lib/features/dive_log/presentation/widgets/dive_list_item.dart +++ b/lib/features/dive_log/presentation/widgets/dive_list_item.dart @@ -66,12 +66,18 @@ class DiveListItem extends ConsumerWidget { /// bug #643 fixed for the Dive Type slot. final DiveTypeLabelResolver diveTypeShortLabelResolver; + /// Whether a dive-type slug's badge should appear in the compact and + /// detailed cards' type-badge row (issue #1269 follow-up). Required for + /// the same reason as the resolvers above. + final DiveTypeListVisibilityPredicate diveTypeListVisibilityPredicate; + const DiveListItem({ super.key, required this.summary, required this.diveNumber, required this.diveTypeLabelResolver, required this.diveTypeShortLabelResolver, + required this.diveTypeListVisibilityPredicate, this.fullDive, this.colorValue, this.minValueInList, @@ -127,6 +133,7 @@ class DiveListItem extends ConsumerWidget { stat2Field: _slotField(slots, 'stat2', DiveField.bottomTime), diveTypeLabelResolver: diveTypeLabelResolver, diveTypeShortLabelResolver: diveTypeShortLabelResolver, + diveTypeListVisibilityPredicate: diveTypeListVisibilityPredicate, onTap: onTap, ); case ListViewMode.detailed: @@ -160,6 +167,7 @@ class DiveListItem extends ConsumerWidget { fullDive: fullDive, diveTypeLabelResolver: diveTypeLabelResolver, diveTypeShortLabelResolver: diveTypeShortLabelResolver, + diveTypeListVisibilityPredicate: diveTypeListVisibilityPredicate, ); } } diff --git a/lib/features/dive_types/data/repositories/dive_type_repository.dart b/lib/features/dive_types/data/repositories/dive_type_repository.dart index a93338bba7..77457403e3 100644 --- a/lib/features/dive_types/data/repositories/dive_type_repository.dart +++ b/lib/features/dive_types/data/repositories/dive_type_repository.dart @@ -193,6 +193,8 @@ class DiveTypeRepository { createdAt: Value(now), updatedAt: Value(now), shortName: Value(diveType.shortName), + showInDetailHeader: Value(diveType.showInDetailHeader), + showInListView: Value(diveType.showInListView), ), ); @@ -242,6 +244,8 @@ class DiveTypeRepository { sortOrder: Value(diveType.sortOrder), updatedAt: Value(now), shortName: Value(diveType.shortName), + showInDetailHeader: Value(diveType.showInDetailHeader), + showInListView: Value(diveType.showInListView), ), ); await _syncRepository.markRecordPending( @@ -261,6 +265,43 @@ class DiveTypeRepository { } } + /// Set which badge rows a type's badge appears in. Unlike [updateDiveType], + /// this is allowed on built-in types too: badge-row visibility is a + /// per-diver display preference, not part of the type's core definition + /// (name/shortName/sortOrder), which built-ins still protect from edits. + Future setDiveTypeVisibility( + String id, { + required bool showInDetailHeader, + required bool showInListView, + }) async { + try { + _log.info('Setting dive type visibility: $id'); + final now = DateTime.now().millisecondsSinceEpoch; + + await (_db.update(_db.diveTypes)..where((t) => t.id.equals(id))).write( + DiveTypesCompanion( + updatedAt: Value(now), + showInDetailHeader: Value(showInDetailHeader), + showInListView: Value(showInListView), + ), + ); + await _syncRepository.markRecordPending( + entityType: 'diveTypes', + recordId: id, + localUpdatedAt: now, + ); + SyncEventBus.notifyLocalChange(); + _log.info('Set dive type visibility: $id'); + } catch (e, stackTrace) { + _log.error( + 'Failed to set dive type visibility: $id', + error: e, + stackTrace: stackTrace, + ); + rethrow; + } + } + /// Delete a custom dive type (built-in types cannot be deleted) Future deleteDiveType(String id) async { try { @@ -346,6 +387,9 @@ class DiveTypeRepository { row.data['updated_at'] as int, ), shortName: row.data['short_name'] as String?, + showInDetailHeader: + (row.data['show_in_detail_header'] as int) == 1, + showInListView: (row.data['show_in_list_view'] as int) == 1, ), diveCount: row.data['dive_count'] as int, ), @@ -404,6 +448,8 @@ class DiveTypeRepository { createdAt: DateTime.fromMillisecondsSinceEpoch(row.createdAt), updatedAt: DateTime.fromMillisecondsSinceEpoch(row.updatedAt), shortName: row.shortName, + showInDetailHeader: row.showInDetailHeader, + showInListView: row.showInListView, ); } } diff --git a/lib/features/dive_types/domain/entities/dive_type_entity.dart b/lib/features/dive_types/domain/entities/dive_type_entity.dart index d28d221158..c3c6f436da 100644 --- a/lib/features/dive_types/domain/entities/dive_type_entity.dart +++ b/lib/features/dive_types/domain/entities/dive_type_entity.dart @@ -17,6 +17,14 @@ class DiveTypeEntity extends Equatable { /// means the diver hasn't set one, so callers fall back to [name]. final String? shortName; + /// Whether this type's badge appears in the dive detail header's + /// type-badge row. Defaults to shown. + final bool showInDetailHeader; + + /// Whether this type's badge appears in the dive list card's type-badge + /// row. Independent of [showInDetailHeader]. Defaults to shown. + final bool showInListView; + const DiveTypeEntity({ required this.id, this.diverId, @@ -26,6 +34,8 @@ class DiveTypeEntity extends Equatable { required this.createdAt, required this.updatedAt, this.shortName, + this.showInDetailHeader = true, + this.showInListView = true, }); /// Create a new custom dive type @@ -35,6 +45,8 @@ class DiveTypeEntity extends Equatable { String? diverId, int sortOrder = 0, String? shortName, + bool showInDetailHeader = true, + bool showInListView = true, }) { final now = DateTime.now(); return DiveTypeEntity( @@ -46,6 +58,8 @@ class DiveTypeEntity extends Equatable { createdAt: now, updatedAt: now, shortName: shortName, + showInDetailHeader: showInDetailHeader, + showInListView: showInListView, ); } @@ -67,6 +81,8 @@ class DiveTypeEntity extends Equatable { DateTime? createdAt, DateTime? updatedAt, String? shortName, + bool? showInDetailHeader, + bool? showInListView, }) { return DiveTypeEntity( id: id ?? this.id, @@ -77,6 +93,8 @@ class DiveTypeEntity extends Equatable { createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, shortName: shortName ?? this.shortName, + showInDetailHeader: showInDetailHeader ?? this.showInDetailHeader, + showInListView: showInListView ?? this.showInListView, ); } @@ -90,5 +108,7 @@ class DiveTypeEntity extends Equatable { createdAt, updatedAt, shortName, + showInDetailHeader, + showInListView, ]; } diff --git a/lib/features/dive_types/presentation/pages/dive_types_page.dart b/lib/features/dive_types/presentation/pages/dive_types_page.dart index 1f9ed32577..f1d227b32c 100644 --- a/lib/features/dive_types/presentation/pages/dive_types_page.dart +++ b/lib/features/dive_types/presentation/pages/dive_types_page.dart @@ -107,9 +107,12 @@ class DiveTypesPage extends ConsumerWidget { : Theme.of(context).colorScheme.primary, ), title: Text(fullName), - subtitle: canDelete - ? Text(context.l10n.diveTypes_custom) - : Text(context.l10n.diveTypes_builtIn), + subtitle: Text( + canDelete + ? context.l10n.diveTypes_custom + : context.l10n.diveTypes_builtIn, + ), + onTap: () => _showEditDiveTypeDialog(context, ref, diveType), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -230,6 +233,186 @@ class DiveTypesPage extends ConsumerWidget { } } + /// Edit an existing dive type: name and short name for custom types (the + /// name field is disabled for built-ins, whose protected core definition + /// updateDiveType refuses to touch), plus the two badge-row visibility + /// checkboxes, which are editable on every type regardless of isBuiltIn -- + /// visibility is a per-diver display preference, not part of that + /// protected definition. + Future _showEditDiveTypeDialog( + BuildContext context, + WidgetRef ref, + DiveTypeEntity diveType, + ) async { + final canEditName = !diveType.isBuiltIn; + final nameController = TextEditingController(text: diveType.name); + final shortNameController = TextEditingController( + text: diveType.shortName ?? '', + ); + final formKey = GlobalKey(); + var showInDetailHeader = diveType.showInDetailHeader; + var showInListView = diveType.showInListView; + + final result = + await showDialog< + ({ + String name, + String? shortName, + bool showInDetailHeader, + bool showInListView, + }) + >( + context: context, + builder: (dialogContext) => StatefulBuilder( + builder: (dialogContext, setState) => AlertDialog( + title: Text(dialogContext.l10n.diveTypes_editDialog_title), + content: Form( + key: formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + controller: nameController, + enabled: canEditName, + autofocus: canEditName, + decoration: InputDecoration( + labelText: + dialogContext.l10n.diveTypes_addDialog_nameLabel, + helperText: canEditName + ? null + : dialogContext + .l10n + .diveTypes_editDialog_builtInNameHelper, + helperMaxLines: 2, + ), + textCapitalization: TextCapitalization.words, + validator: canEditName + ? (value) { + if (value == null || value.trim().isEmpty) { + return dialogContext + .l10n + .diveTypes_addDialog_nameValidation; + } + return null; + } + : null, + ), + if (canEditName) ...[ + const SizedBox(height: 12), + TextFormField( + controller: shortNameController, + decoration: InputDecoration( + labelText: dialogContext + .l10n + .diveTypes_addDialog_shortNameLabel, + hintText: dialogContext + .l10n + .diveTypes_addDialog_shortNameHint, + helperText: dialogContext + .l10n + .diveTypes_addDialog_shortNameHelper, + helperMaxLines: 2, + ), + textCapitalization: TextCapitalization.words, + ), + ], + const SizedBox(height: 8), + CheckboxListTile( + value: showInDetailHeader, + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + title: Text( + dialogContext.l10n.diveTypes_showInHeaderLabel, + ), + subtitle: Text( + dialogContext.l10n.diveTypes_showInHeaderTooltip, + ), + onChanged: (value) => + setState(() => showInDetailHeader = value ?? true), + ), + CheckboxListTile( + value: showInListView, + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + title: Text(dialogContext.l10n.diveTypes_showInListLabel), + subtitle: Text( + dialogContext.l10n.diveTypes_showInListTooltip, + ), + onChanged: (value) => + setState(() => showInListView = value ?? true), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(dialogContext.l10n.common_action_cancel), + ), + FilledButton( + onPressed: () { + if (!canEditName || formKey.currentState!.validate()) { + Navigator.of(dialogContext).pop(( + name: nameController.text.trim(), + shortName: shortNameController.text.trim().isEmpty + ? null + : shortNameController.text.trim(), + showInDetailHeader: showInDetailHeader, + showInListView: showInListView, + )); + } + }, + child: Text( + dialogContext.l10n.diveTypes_editDialog_saveButton, + ), + ), + ], + ), + ), + ); + + if (result == null) return; + + try { + final notifier = ref.read(diveTypeListNotifierProvider.notifier); + if (canEditName) { + await notifier.updateDiveType( + diveType.copyWith( + name: result.name, + shortName: result.shortName, + showInDetailHeader: result.showInDetailHeader, + showInListView: result.showInListView, + ), + ); + } else { + await notifier.setDiveTypeVisibility( + diveType.id, + showInDetailHeader: result.showInDetailHeader, + showInListView: result.showInListView, + ); + } + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.diveTypes_snackbar_updated(result.name)), + ), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.diveTypes_snackbar_errorUpdating(e.toString()), + ), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } + } + Future _confirmDelete( BuildContext context, WidgetRef ref, diff --git a/lib/features/dive_types/presentation/providers/dive_type_providers.dart b/lib/features/dive_types/presentation/providers/dive_type_providers.dart index d6ef7be202..05cc201cb0 100644 --- a/lib/features/dive_types/presentation/providers/dive_type_providers.dart +++ b/lib/features/dive_types/presentation/providers/dive_type_providers.dart @@ -209,6 +209,25 @@ class DiveTypeListNotifier _ref.invalidate(customDiveTypesProvider); } + /// Set which badge rows a type's badge appears in. Allowed on built-in + /// types too, unlike [updateDiveType] (see + /// [DiveTypeRepository.setDiveTypeVisibility]). + Future setDiveTypeVisibility( + String id, { + required bool showInDetailHeader, + required bool showInListView, + }) async { + await _repository.setDiveTypeVisibility( + id, + showInDetailHeader: showInDetailHeader, + showInListView: showInListView, + ); + await _loadDiveTypes(); + _ref.invalidate(diveTypesProvider); + _ref.invalidate(diveTypeStatisticsProvider); + _ref.invalidate(customDiveTypesProvider); + } + /// Delete a custom dive type (built-in types cannot be deleted) Future deleteDiveType(String id) async { await _repository.deleteDiveType(id); diff --git a/lib/features/trips/presentation/widgets/story/trip_story_day_card.dart b/lib/features/trips/presentation/widgets/story/trip_story_day_card.dart index eb993523ae..c7f7ceac1f 100644 --- a/lib/features/trips/presentation/widgets/story/trip_story_day_card.dart +++ b/lib/features/trips/presentation/widgets/story/trip_story_day_card.dart @@ -40,6 +40,8 @@ class TripStoryDayCard extends ConsumerWidget { ref, context.l10n, ); + final diveTypeListVisibilityPredicate = + watchDiveTypeListVisibilityPredicate(ref); // The day title, subtitle, and Planned chip live in the sticky // TripStoryDayHeader above this card; the card is body-only. A planned @@ -89,6 +91,8 @@ class TripStoryDayCard extends ConsumerWidget { summary: DiveSummary.fromDive(dive), diveTypeLabelResolver: diveTypeLabelResolver, diveTypeShortLabelResolver: diveTypeShortLabelResolver, + diveTypeListVisibilityPredicate: + diveTypeListVisibilityPredicate, // The story already holds the full Dive; pass it so the // configurable card can resolve fields absent from the // summary (tanks, SAC, buddies, weights). diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 88419c87b7..cf068f5300 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -3005,11 +3005,20 @@ "diveTypes_deleteDialog_content": "Möchten Sie \"{name}\" wirklich löschen?", "diveTypes_deleteDialog_title": "Tauchgangstyp löschen?", "diveTypes_deleteTooltip": "Tauchgangstyp löschen", + "diveTypes_editDialog_builtInNameHelper": "Interne Namen können nicht geändert werden", + "diveTypes_editDialog_saveButton": "Speichern", + "diveTypes_editDialog_title": "Tauchgangstyp bearbeiten", + "diveTypes_showInHeaderLabel": "Kopfzeile", + "diveTypes_showInHeaderTooltip": "Badge dieses Typs in der Tauchgang-Kopfzeile anzeigen", + "diveTypes_showInListLabel": "Liste", + "diveTypes_showInListTooltip": "Badge dieses Typs in der Tauchgangsliste anzeigen", "diveTypes_snackbar_added": "Tauchgangstyp hinzugefügt: {name}", "diveTypes_snackbar_cannotDelete": "\"{name}\" kann nicht gelöscht werden - wird von vorhandenen Tauchgängen verwendet", "diveTypes_snackbar_deleted": "\"{name}\" gelöscht", "diveTypes_snackbar_errorAdding": "Fehler beim Hinzufügen des Tauchgangstyps: {error}", "diveTypes_snackbar_errorDeleting": "Fehler beim Löschen des Tauchgangstyps: {error}", + "diveTypes_snackbar_errorUpdating": "Fehler beim Aktualisieren des Tauchgangstyps: {error}", + "diveTypes_snackbar_updated": "\"{name}\" aktualisiert", "divers_detail_activeDiver": "Aktiver Taucher", "divers_detail_allergiesLabel": "Allergien", "divers_detail_appBarTitle": "Taucher", diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 7c4f2cc6f5..4bfc17885e 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -5486,11 +5486,34 @@ "diveTypes_deleteDialog_content": "Are you sure you want to delete \"{name}\"?", "diveTypes_deleteDialog_title": "Delete Dive Type?", "diveTypes_deleteTooltip": "Delete dive type", + "diveTypes_editDialog_builtInNameHelper": "Built-in names can't be changed", + "diveTypes_editDialog_saveButton": "Save", + "diveTypes_editDialog_title": "Edit Dive Type", + "diveTypes_showInHeaderLabel": "Header", + "diveTypes_showInHeaderTooltip": "Show this type's badge in the dive detail header", + "diveTypes_showInListLabel": "List", + "diveTypes_showInListTooltip": "Show this type's badge in the dive list", "diveTypes_snackbar_added": "Added dive type: {name}", "diveTypes_snackbar_cannotDelete": "Cannot delete \"{name}\" - it is used by existing dives", "diveTypes_snackbar_deleted": "Deleted \"{name}\"", "diveTypes_snackbar_errorAdding": "Error adding dive type: {error}", "diveTypes_snackbar_errorDeleting": "Error deleting dive type: {error}", + "diveTypes_snackbar_errorUpdating": "Error updating dive type: {error}", + "diveTypes_snackbar_updated": "Updated \"{name}\"", + "@diveTypes_snackbar_errorUpdating": { + "placeholders": { + "error": { + "type": "Object" + } + } + }, + "@diveTypes_snackbar_updated": { + "placeholders": { + "name": { + "type": "Object" + } + } + }, "@diveTypes_deleteDialog_content": { "placeholders": { "name": { diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index 762edadfbc..b5a0482554 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -15626,6 +15626,48 @@ abstract class AppLocalizations { /// **'Delete dive type'** String get diveTypes_deleteTooltip; + /// No description provided for @diveTypes_editDialog_builtInNameHelper. + /// + /// In en, this message translates to: + /// **'Built-in names can\'t be changed'** + String get diveTypes_editDialog_builtInNameHelper; + + /// No description provided for @diveTypes_editDialog_saveButton. + /// + /// In en, this message translates to: + /// **'Save'** + String get diveTypes_editDialog_saveButton; + + /// No description provided for @diveTypes_editDialog_title. + /// + /// In en, this message translates to: + /// **'Edit Dive Type'** + String get diveTypes_editDialog_title; + + /// No description provided for @diveTypes_showInHeaderLabel. + /// + /// In en, this message translates to: + /// **'Header'** + String get diveTypes_showInHeaderLabel; + + /// No description provided for @diveTypes_showInHeaderTooltip. + /// + /// In en, this message translates to: + /// **'Show this type\'s badge in the dive detail header'** + String get diveTypes_showInHeaderTooltip; + + /// No description provided for @diveTypes_showInListLabel. + /// + /// In en, this message translates to: + /// **'List'** + String get diveTypes_showInListLabel; + + /// No description provided for @diveTypes_showInListTooltip. + /// + /// In en, this message translates to: + /// **'Show this type\'s badge in the dive list'** + String get diveTypes_showInListTooltip; + /// No description provided for @diveTypes_snackbar_added. /// /// In en, this message translates to: @@ -15656,6 +15698,18 @@ abstract class AppLocalizations { /// **'Error deleting dive type: {error}'** String diveTypes_snackbar_errorDeleting(Object error); + /// No description provided for @diveTypes_snackbar_errorUpdating. + /// + /// In en, this message translates to: + /// **'Error updating dive type: {error}'** + String diveTypes_snackbar_errorUpdating(Object error); + + /// No description provided for @diveTypes_snackbar_updated. + /// + /// In en, this message translates to: + /// **'Updated \"{name}\"'** + String diveTypes_snackbar_updated(Object name); + /// No description provided for @divers_detail_activeDiver. /// /// 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 da74515a34..a9410e5c74 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -9157,6 +9157,30 @@ class AppLocalizationsAr extends AppLocalizations { @override String get diveTypes_deleteTooltip => 'حذف نوع الغوص'; + @override + String get diveTypes_editDialog_builtInNameHelper => + 'Built-in names can\'t be changed'; + + @override + String get diveTypes_editDialog_saveButton => 'Save'; + + @override + String get diveTypes_editDialog_title => 'Edit Dive Type'; + + @override + String get diveTypes_showInHeaderLabel => 'Header'; + + @override + String get diveTypes_showInHeaderTooltip => + 'Show this type\'s badge in the dive detail header'; + + @override + String get diveTypes_showInListLabel => 'List'; + + @override + String get diveTypes_showInListTooltip => + 'Show this type\'s badge in the dive list'; + @override String diveTypes_snackbar_added(Object name) { return 'تمت إضافة نوع الغوص: $name'; @@ -9182,6 +9206,16 @@ class AppLocalizationsAr extends AppLocalizations { return 'خطأ في حذف نوع الغوص: $error'; } + @override + String diveTypes_snackbar_errorUpdating(Object error) { + return 'Error updating dive type: $error'; + } + + @override + String diveTypes_snackbar_updated(Object name) { + return 'Updated \"$name\"'; + } + @override String get divers_detail_activeDiver => 'الغواص النشط'; diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index 6df9ddbb6e..9c6c7042cc 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -9330,6 +9330,30 @@ class AppLocalizationsDe extends AppLocalizations { @override String get diveTypes_deleteTooltip => 'Tauchgangstyp löschen'; + @override + String get diveTypes_editDialog_builtInNameHelper => + 'Interne Namen können nicht geändert werden'; + + @override + String get diveTypes_editDialog_saveButton => 'Speichern'; + + @override + String get diveTypes_editDialog_title => 'Tauchgangstyp bearbeiten'; + + @override + String get diveTypes_showInHeaderLabel => 'Kopfzeile'; + + @override + String get diveTypes_showInHeaderTooltip => + 'Badge dieses Typs in der Tauchgang-Kopfzeile anzeigen'; + + @override + String get diveTypes_showInListLabel => 'Liste'; + + @override + String get diveTypes_showInListTooltip => + 'Badge dieses Typs in der Tauchgangsliste anzeigen'; + @override String diveTypes_snackbar_added(Object name) { return 'Tauchgangstyp hinzugefügt: $name'; @@ -9355,6 +9379,16 @@ class AppLocalizationsDe extends AppLocalizations { return 'Fehler beim Löschen des Tauchgangstyps: $error'; } + @override + String diveTypes_snackbar_errorUpdating(Object error) { + return 'Fehler beim Aktualisieren des Tauchgangstyps: $error'; + } + + @override + String diveTypes_snackbar_updated(Object name) { + return '\"$name\" aktualisiert'; + } + @override String get divers_detail_activeDiver => 'Aktiver Taucher'; diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index 018049a48a..de6a28db6b 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -9175,6 +9175,30 @@ class AppLocalizationsEn extends AppLocalizations { @override String get diveTypes_deleteTooltip => 'Delete dive type'; + @override + String get diveTypes_editDialog_builtInNameHelper => + 'Built-in names can\'t be changed'; + + @override + String get diveTypes_editDialog_saveButton => 'Save'; + + @override + String get diveTypes_editDialog_title => 'Edit Dive Type'; + + @override + String get diveTypes_showInHeaderLabel => 'Header'; + + @override + String get diveTypes_showInHeaderTooltip => + 'Show this type\'s badge in the dive detail header'; + + @override + String get diveTypes_showInListLabel => 'List'; + + @override + String get diveTypes_showInListTooltip => + 'Show this type\'s badge in the dive list'; + @override String diveTypes_snackbar_added(Object name) { return 'Added dive type: $name'; @@ -9200,6 +9224,16 @@ class AppLocalizationsEn extends AppLocalizations { return 'Error deleting dive type: $error'; } + @override + String diveTypes_snackbar_errorUpdating(Object error) { + return 'Error updating dive type: $error'; + } + + @override + String diveTypes_snackbar_updated(Object name) { + return 'Updated \"$name\"'; + } + @override String get divers_detail_activeDiver => 'Active Diver'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index 6e034d0072..7123690058 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -9330,6 +9330,30 @@ class AppLocalizationsEs extends AppLocalizations { @override String get diveTypes_deleteTooltip => 'Eliminar tipo de inmersión'; + @override + String get diveTypes_editDialog_builtInNameHelper => + 'Built-in names can\'t be changed'; + + @override + String get diveTypes_editDialog_saveButton => 'Save'; + + @override + String get diveTypes_editDialog_title => 'Edit Dive Type'; + + @override + String get diveTypes_showInHeaderLabel => 'Header'; + + @override + String get diveTypes_showInHeaderTooltip => + 'Show this type\'s badge in the dive detail header'; + + @override + String get diveTypes_showInListLabel => 'List'; + + @override + String get diveTypes_showInListTooltip => + 'Show this type\'s badge in the dive list'; + @override String diveTypes_snackbar_added(Object name) { return 'Tipo de inmersión agregado: $name'; @@ -9355,6 +9379,16 @@ class AppLocalizationsEs extends AppLocalizations { return 'Error al eliminar tipo de inmersión: $error'; } + @override + String diveTypes_snackbar_errorUpdating(Object error) { + return 'Error updating dive type: $error'; + } + + @override + String diveTypes_snackbar_updated(Object name) { + return 'Updated \"$name\"'; + } + @override String get divers_detail_activeDiver => 'Buceador activo'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index 78b5809c0e..3a59b1f6a8 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -9363,6 +9363,30 @@ class AppLocalizationsFr extends AppLocalizations { @override String get diveTypes_deleteTooltip => 'Supprimer le type de plongée'; + @override + String get diveTypes_editDialog_builtInNameHelper => + 'Built-in names can\'t be changed'; + + @override + String get diveTypes_editDialog_saveButton => 'Save'; + + @override + String get diveTypes_editDialog_title => 'Edit Dive Type'; + + @override + String get diveTypes_showInHeaderLabel => 'Header'; + + @override + String get diveTypes_showInHeaderTooltip => + 'Show this type\'s badge in the dive detail header'; + + @override + String get diveTypes_showInListLabel => 'List'; + + @override + String get diveTypes_showInListTooltip => + 'Show this type\'s badge in the dive list'; + @override String diveTypes_snackbar_added(Object name) { return 'Type de plongée ajouté : $name'; @@ -9388,6 +9412,16 @@ class AppLocalizationsFr extends AppLocalizations { return 'Erreur lors de la suppression du type de plongée : $error'; } + @override + String diveTypes_snackbar_errorUpdating(Object error) { + return 'Error updating dive type: $error'; + } + + @override + String diveTypes_snackbar_updated(Object name) { + return 'Updated \"$name\"'; + } + @override String get divers_detail_activeDiver => 'Plongeur actif'; diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index 312d13cfb6..395d01cad6 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -9099,6 +9099,30 @@ class AppLocalizationsHe extends AppLocalizations { @override String get diveTypes_deleteTooltip => 'מחק סוג צלילה'; + @override + String get diveTypes_editDialog_builtInNameHelper => + 'Built-in names can\'t be changed'; + + @override + String get diveTypes_editDialog_saveButton => 'Save'; + + @override + String get diveTypes_editDialog_title => 'Edit Dive Type'; + + @override + String get diveTypes_showInHeaderLabel => 'Header'; + + @override + String get diveTypes_showInHeaderTooltip => + 'Show this type\'s badge in the dive detail header'; + + @override + String get diveTypes_showInListLabel => 'List'; + + @override + String get diveTypes_showInListTooltip => + 'Show this type\'s badge in the dive list'; + @override String diveTypes_snackbar_added(Object name) { return 'סוג צלילה נוסף: $name'; @@ -9124,6 +9148,16 @@ class AppLocalizationsHe extends AppLocalizations { return 'שגיאה במחיקת סוג צלילה: $error'; } + @override + String diveTypes_snackbar_errorUpdating(Object error) { + return 'Error updating dive type: $error'; + } + + @override + String diveTypes_snackbar_updated(Object name) { + return 'Updated \"$name\"'; + } + @override String get divers_detail_activeDiver => 'צולל פעיל'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index 5f0a5348b9..7f20aee45f 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -9303,6 +9303,30 @@ class AppLocalizationsHu extends AppLocalizations { @override String get diveTypes_deleteTooltip => 'Merülés típus törlése'; + @override + String get diveTypes_editDialog_builtInNameHelper => + 'Built-in names can\'t be changed'; + + @override + String get diveTypes_editDialog_saveButton => 'Save'; + + @override + String get diveTypes_editDialog_title => 'Edit Dive Type'; + + @override + String get diveTypes_showInHeaderLabel => 'Header'; + + @override + String get diveTypes_showInHeaderTooltip => + 'Show this type\'s badge in the dive detail header'; + + @override + String get diveTypes_showInListLabel => 'List'; + + @override + String get diveTypes_showInListTooltip => + 'Show this type\'s badge in the dive list'; + @override String diveTypes_snackbar_added(Object name) { return 'Merülés típus hozzáadva: $name'; @@ -9328,6 +9352,16 @@ class AppLocalizationsHu extends AppLocalizations { return 'Hiba a merülés típus törlésekor: $error'; } + @override + String diveTypes_snackbar_errorUpdating(Object error) { + return 'Error updating dive type: $error'; + } + + @override + String diveTypes_snackbar_updated(Object name) { + return 'Updated \"$name\"'; + } + @override String get divers_detail_activeDiver => 'Aktiv merülo'; diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index 379522f0ce..bd87543af9 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -9329,6 +9329,30 @@ class AppLocalizationsIt extends AppLocalizations { @override String get diveTypes_deleteTooltip => 'Elimina tipo immersione'; + @override + String get diveTypes_editDialog_builtInNameHelper => + 'Built-in names can\'t be changed'; + + @override + String get diveTypes_editDialog_saveButton => 'Save'; + + @override + String get diveTypes_editDialog_title => 'Edit Dive Type'; + + @override + String get diveTypes_showInHeaderLabel => 'Header'; + + @override + String get diveTypes_showInHeaderTooltip => + 'Show this type\'s badge in the dive detail header'; + + @override + String get diveTypes_showInListLabel => 'List'; + + @override + String get diveTypes_showInListTooltip => + 'Show this type\'s badge in the dive list'; + @override String diveTypes_snackbar_added(Object name) { return 'Tipo immersione aggiunto: $name'; @@ -9354,6 +9378,16 @@ class AppLocalizationsIt extends AppLocalizations { return 'Errore durante l\'eliminazione del tipo immersione: $error'; } + @override + String diveTypes_snackbar_errorUpdating(Object error) { + return 'Error updating dive type: $error'; + } + + @override + String diveTypes_snackbar_updated(Object name) { + return 'Updated \"$name\"'; + } + @override String get divers_detail_activeDiver => 'Subacqueo attivo'; diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index 4ca2c37766..6f5a778145 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -9257,6 +9257,30 @@ class AppLocalizationsNl extends AppLocalizations { @override String get diveTypes_deleteTooltip => 'Duiktype verwijderen'; + @override + String get diveTypes_editDialog_builtInNameHelper => + 'Built-in names can\'t be changed'; + + @override + String get diveTypes_editDialog_saveButton => 'Save'; + + @override + String get diveTypes_editDialog_title => 'Edit Dive Type'; + + @override + String get diveTypes_showInHeaderLabel => 'Header'; + + @override + String get diveTypes_showInHeaderTooltip => + 'Show this type\'s badge in the dive detail header'; + + @override + String get diveTypes_showInListLabel => 'List'; + + @override + String get diveTypes_showInListTooltip => + 'Show this type\'s badge in the dive list'; + @override String diveTypes_snackbar_added(Object name) { return 'Duiktype toegevoegd: $name'; @@ -9282,6 +9306,16 @@ class AppLocalizationsNl extends AppLocalizations { return 'Fout bij verwijderen duiktype: $error'; } + @override + String diveTypes_snackbar_errorUpdating(Object error) { + return 'Error updating dive type: $error'; + } + + @override + String diveTypes_snackbar_updated(Object name) { + return 'Updated \"$name\"'; + } + @override String get divers_detail_activeDiver => 'Actieve duiker'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index 10a31f10fd..380bc7f13b 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -9331,6 +9331,30 @@ class AppLocalizationsPt extends AppLocalizations { @override String get diveTypes_deleteTooltip => 'Excluir tipo de mergulho'; + @override + String get diveTypes_editDialog_builtInNameHelper => + 'Built-in names can\'t be changed'; + + @override + String get diveTypes_editDialog_saveButton => 'Save'; + + @override + String get diveTypes_editDialog_title => 'Edit Dive Type'; + + @override + String get diveTypes_showInHeaderLabel => 'Header'; + + @override + String get diveTypes_showInHeaderTooltip => + 'Show this type\'s badge in the dive detail header'; + + @override + String get diveTypes_showInListLabel => 'List'; + + @override + String get diveTypes_showInListTooltip => + 'Show this type\'s badge in the dive list'; + @override String diveTypes_snackbar_added(Object name) { return 'Tipo de mergulho adicionado: $name'; @@ -9356,6 +9380,16 @@ class AppLocalizationsPt extends AppLocalizations { return 'Erro ao excluir tipo de mergulho: $error'; } + @override + String diveTypes_snackbar_errorUpdating(Object error) { + return 'Error updating dive type: $error'; + } + + @override + String diveTypes_snackbar_updated(Object name) { + return 'Updated \"$name\"'; + } + @override String get divers_detail_activeDiver => 'Mergulhador Ativo'; diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index 287437ceb0..0a011f4301 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -8877,6 +8877,30 @@ class AppLocalizationsZh extends AppLocalizations { @override String get diveTypes_deleteTooltip => '删除潜水类型'; + @override + String get diveTypes_editDialog_builtInNameHelper => + 'Built-in names can\'t be changed'; + + @override + String get diveTypes_editDialog_saveButton => 'Save'; + + @override + String get diveTypes_editDialog_title => 'Edit Dive Type'; + + @override + String get diveTypes_showInHeaderLabel => 'Header'; + + @override + String get diveTypes_showInHeaderTooltip => + 'Show this type\'s badge in the dive detail header'; + + @override + String get diveTypes_showInListLabel => 'List'; + + @override + String get diveTypes_showInListTooltip => + 'Show this type\'s badge in the dive list'; + @override String diveTypes_snackbar_added(Object name) { return '已添加潜水类型:$name'; @@ -8902,6 +8926,16 @@ class AppLocalizationsZh extends AppLocalizations { return '删除出错潜水类型: $error'; } + @override + String diveTypes_snackbar_errorUpdating(Object error) { + return 'Error updating dive type: $error'; + } + + @override + String diveTypes_snackbar_updated(Object name) { + return 'Updated \"$name\"'; + } + @override String get divers_detail_activeDiver => '当前潜水员'; diff --git a/test/core/database/migration_v174_dive_type_visibility_test.dart b/test/core/database/migration_v174_dive_type_visibility_test.dart new file mode 100644 index 0000000000..4dd99b4291 --- /dev/null +++ b/test/core/database/migration_v174_dive_type_visibility_test.dart @@ -0,0 +1,79 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; + +/// Minimal pre-v174 shape: a dive_types table without the two visibility +/// columns, stamped at v161 so the 161->174 upgrade runs. +NativeDatabase _dbAt161() { + return NativeDatabase.memory( + setup: (rawDb) { + rawDb.execute('PRAGMA user_version = 161'); + rawDb.execute(''' + CREATE TABLE dive_types ( + id TEXT NOT NULL PRIMARY KEY, + diver_id TEXT, + name TEXT NOT NULL, + is_built_in INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + hlc TEXT + ) + '''); + rawDb.execute( + "INSERT INTO dive_types (id, name, is_built_in, created_at, updated_at) " + "VALUES ('wreck', 'Wreck', 1, 1000, 1000)", + ); + }, + ); +} + +void main() { + test( + 'v174 adds the two visibility columns, defaulting existing rows to shown', + () async { + final db = AppDatabase(_dbAt161()); + addTearDown(() => db.close()); + + final cols = await db + .customSelect("PRAGMA table_info('dive_types')") + .get(); + final names = cols.map((c) => c.read('name')).toSet(); + expect(names, contains('show_in_detail_header')); + expect(names, contains('show_in_list_view')); + + final row = await db + .customSelect( + 'SELECT show_in_detail_header, show_in_list_view ' + "FROM dive_types WHERE id = 'wreck'", + ) + .getSingle(); + expect(row.data['show_in_detail_header'], 1); + expect(row.data['show_in_list_view'], 1); + }, + ); + + test('fresh databases get both visibility columns', () async { + final db = AppDatabase(NativeDatabase.memory()); + addTearDown(db.close); + final cols = await db.customSelect("PRAGMA table_info('dive_types')").get(); + final names = cols.map((c) => c.read('name')).toSet(); + expect(names, contains('show_in_detail_header')); + expect(names, contains('show_in_list_view')); + }); + + test('the helper no-ops when dive_types is absent', () async { + final native = NativeDatabase.memory( + setup: (rawDb) => rawDb.execute('PRAGMA user_version = 161'), + ); + final db = AppDatabase(native); + addTearDown(db.close); + + await expectLater(db.customSelect('SELECT 1').get(), completes); + }); + + test('v174 is present in the migration ladder', () { + expect(AppDatabase.currentSchemaVersion, greaterThanOrEqualTo(174)); + expect(AppDatabase.migrationVersions, contains(174)); + }); +} diff --git a/test/features/dive_log/presentation/pages/dive_detail_page_test.dart b/test/features/dive_log/presentation/pages/dive_detail_page_test.dart index 62a074976d..e7f4128b21 100644 --- a/test/features/dive_log/presentation/pages/dive_detail_page_test.dart +++ b/test/features/dive_log/presentation/pages/dive_detail_page_test.dart @@ -29,6 +29,8 @@ import 'package:submersion/features/dive_log/domain/entities/source_profile.dart import 'package:submersion/features/dive_log/presentation/widgets/source_bar.dart'; import 'package:submersion/features/dive_log/presentation/widgets/dive_profile_chart.dart'; import 'package:submersion/features/dive_log/presentation/widgets/dive_type_badge.dart'; +import 'package:submersion/features/dive_types/domain/entities/dive_type_entity.dart'; +import 'package:submersion/features/dive_types/presentation/providers/dive_type_providers.dart'; import 'package:submersion/features/dive_log/presentation/widgets/field_attribution_badge.dart'; import 'package:submersion/features/dive_log/presentation/widgets/o2_toxicity_card.dart'; import 'package:submersion/l10n/arb/app_localizations.dart'; @@ -1620,5 +1622,33 @@ void main() { expect(tester.takeException(), isNull); }, ); + + testWidgets( + 'hides a type whose showInDetailHeader is false, keeps the rest', + (tester) async { + final dive = createTestDiveWithBottomTime().copyWith( + diveTypeIds: ['wreck', 'night'], + ); + final hiddenWreck = DiveTypeEntity( + id: 'wreck', + name: 'Wreck', + isBuiltIn: true, + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + showInDetailHeader: false, + ); + final overrides = await getBaseOverrides(); + await tester.pumpWidget( + _buildDetailPage(dive, [ + ...overrides, + diveTypesProvider.overrideWith((ref) async => [hiddenWreck]), + ]), + ); + await tester.pumpAndSettle(); + + expect(find.text('Wreck'), findsNothing); + expect(find.text('Night'), findsOneWidget); + }, + ); }); } diff --git a/test/features/dive_log/presentation/widgets/dive_list_tile_type_badges_test.dart b/test/features/dive_log/presentation/widgets/dive_list_tile_type_badges_test.dart index a2a66fe4af..2aacdf6d1c 100644 --- a/test/features/dive_log/presentation/widgets/dive_list_tile_type_badges_test.dart +++ b/test/features/dive_log/presentation/widgets/dive_list_tile_type_badges_test.dart @@ -11,6 +11,7 @@ import 'package:submersion/features/dive_log/presentation/widgets/compact_dive_l import 'package:submersion/features/dive_log/presentation/widgets/dive_mode_badge.dart'; import 'package:submersion/features/dive_log/presentation/widgets/dive_type_badge.dart'; import 'package:submersion/features/dive_log/presentation/widgets/dive_type_badge_row.dart'; +import 'package:submersion/features/dive_types/domain/entities/dive_type_entity.dart'; import 'package:submersion/features/dive_types/presentation/providers/dive_type_providers.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; import 'package:submersion/features/tags/domain/entities/tag.dart'; @@ -49,26 +50,37 @@ void main() { ); Widget harness({ - required Widget Function(DiveTypeLabelResolver resolve) builder, + required Widget Function( + DiveTypeLabelResolver resolve, + DiveTypeListVisibilityPredicate isVisible, + ) + builder, + List diveTypes = const [], }) { return testApp( overrides: [ settingsProvider.overrideWith((ref) => _TestSettingsNotifier()), - diveTypesProvider.overrideWith((ref) async => const []), + diveTypesProvider.overrideWith((ref) async => diveTypes), detailedCardConfigProvider.overrideWith( (ref) => _TestCardConfigNotifier(), ), ], child: Consumer( - builder: (context, ref, _) => - builder(watchDiveTypeShortLabelResolver(ref, context.l10n)), + builder: (context, ref, _) => builder( + watchDiveTypeShortLabelResolver(ref, context.l10n), + watchDiveTypeListVisibilityPredicate(ref), + ), ), ); } group('CompactDiveListTile type badges', () { - Widget tile(List diveTypeIds) => harness( - builder: (resolve) => CompactDiveListTile( + Widget tile( + List diveTypeIds, { + List diveTypes = const [], + }) => harness( + diveTypes: diveTypes, + builder: (resolve, isVisible) => CompactDiveListTile( diveId: 'd1', diveNumber: 7, dateTime: DateTime(2026, 3, 15), @@ -78,6 +90,7 @@ void main() { summary: summaryWith(diveTypeIds), onTap: () {}, diveTypeShortLabelResolver: resolve, + diveTypeListVisibilityPredicate: isVisible, ), ); @@ -118,11 +131,40 @@ void main() { final modeBadgeSize = tester.getSize(find.byType(DiveModeBadge)); expect(typeBadgeSize.height, modeBadgeSize.height); }); + + testWidgets('hides a type whose showInListView is false, keeps the rest', ( + tester, + ) async { + await tester.pumpWidget( + tile( + ['wreck', 'night'], + diveTypes: [ + DiveTypeEntity( + id: 'wreck', + name: 'Wreck', + isBuiltIn: true, + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + showInListView: false, + ), + ], + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(DiveTypeBadge), findsNWidgets(1)); + expect(find.text('Night'), findsOneWidget); + expect(find.text('Wreck'), findsNothing); + }); }); group('DiveListTile detailed-view type badges', () { - Widget tile(List diveTypeIds) => harness( - builder: (resolve) => DiveListTile( + Widget tile( + List diveTypeIds, { + List diveTypes = const [], + }) => harness( + diveTypes: diveTypes, + builder: (resolve, isVisible) => DiveListTile( diveId: 'd1', diveNumber: 7, dateTime: DateTime(2026, 3, 15), @@ -132,6 +174,7 @@ void main() { summary: summaryWith(diveTypeIds), onTap: () {}, diveTypeShortLabelResolver: resolve, + diveTypeListVisibilityPredicate: isVisible, ), ); @@ -173,6 +216,31 @@ void main() { expect(typeBadgeSize.height, modeBadgeSize.height); }); + testWidgets('hides a type whose showInListView is false, keeps the rest', ( + tester, + ) async { + await tester.pumpWidget( + tile( + ['wreck', 'night'], + diveTypes: [ + DiveTypeEntity( + id: 'wreck', + name: 'Wreck', + isBuiltIn: true, + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + showInListView: false, + ), + ], + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(DiveTypeBadge), findsNWidgets(1)); + expect(find.text('Night'), findsOneWidget); + expect(find.text('Wreck'), findsNothing); + }); + testWidgets('does not overflow with several types and a long tag name', ( tester, ) async { @@ -183,7 +251,7 @@ void main() { // overflow (issue #1269 follow-up). await tester.pumpWidget( harness( - builder: (resolve) => Align( + builder: (resolve, isVisible) => Align( alignment: Alignment.topLeft, // Matches the narrow list panel in the real master-detail // layout where this overflow was observed -- the default test diff --git a/test/features/dive_types/data/repositories/dive_type_repository_visibility_test.dart b/test/features/dive_types/data/repositories/dive_type_repository_visibility_test.dart new file mode 100644 index 0000000000..195230f1b0 --- /dev/null +++ b/test/features/dive_types/data/repositories/dive_type_repository_visibility_test.dart @@ -0,0 +1,98 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/services/database_service.dart'; +import 'package:submersion/features/dive_types/data/repositories/dive_type_repository.dart'; +import 'package:submersion/features/dive_types/domain/entities/dive_type_entity.dart'; + +import '../../../../helpers/test_database.dart'; + +void main() { + late DiveTypeRepository repository; + + setUp(() async { + await setUpTestDatabase(); + repository = DiveTypeRepository(); + // Custom dive types carry a diverId FK, so a row must exist for it to + // reference. + await DatabaseService.instance.database.customStatement( + "INSERT INTO divers (id, name, created_at, updated_at) " + "VALUES ('diver-1', 'Test Diver', 1000, 1000)", + ); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + test('createDiveType defaults both visibility flags to shown', () async { + final created = await repository.createDiveType( + DiveTypeEntity.create(id: '', name: 'Cenote', diverId: 'diver-1'), + ); + + expect(created.showInDetailHeader, isTrue); + expect(created.showInListView, isTrue); + + final reloaded = await repository.getDiveTypeById(created.id); + expect(reloaded?.showInDetailHeader, isTrue); + expect(reloaded?.showInListView, isTrue); + }); + + test('createDiveType persists explicit visibility flags', () async { + final created = await repository.createDiveType( + DiveTypeEntity.create( + id: '', + name: 'Cenote', + diverId: 'diver-1', + showInDetailHeader: false, + showInListView: true, + ), + ); + + expect(created.showInDetailHeader, isFalse); + expect(created.showInListView, isTrue); + + final reloaded = await repository.getDiveTypeById(created.id); + expect(reloaded?.showInDetailHeader, isFalse); + expect(reloaded?.showInListView, isTrue); + }); + + test( + 'setDiveTypeVisibility updates both flags independently of each other', + () async { + final created = await repository.createDiveType( + DiveTypeEntity.create(id: '', name: 'Cenote', diverId: 'diver-1'), + ); + + await repository.setDiveTypeVisibility( + created.id, + showInDetailHeader: false, + showInListView: true, + ); + + final reloaded = await repository.getDiveTypeById(created.id); + expect(reloaded?.showInDetailHeader, isFalse); + expect(reloaded?.showInListView, isTrue); + }, + ); + + test('setDiveTypeVisibility is allowed on built-in types', () async { + // Built-in types are seeded on a fresh database (see kSeedBuiltInDiveTypesSql); + // 'wreck' is one of the seeded slugs. + final before = await repository.getDiveTypeById('wreck'); + expect(before?.isBuiltIn, isTrue); + + await expectLater( + repository.setDiveTypeVisibility( + 'wreck', + showInDetailHeader: false, + showInListView: false, + ), + completes, + ); + + final reloaded = await repository.getDiveTypeById('wreck'); + expect(reloaded?.showInDetailHeader, isFalse); + expect(reloaded?.showInListView, isFalse); + // The protected core definition is untouched. + expect(reloaded?.name, before?.name); + }); +} diff --git a/test/features/dive_types/presentation/pages/dive_types_page_test.dart b/test/features/dive_types/presentation/pages/dive_types_page_test.dart index 3117c73c3e..a7f65ffd3c 100644 --- a/test/features/dive_types/presentation/pages/dive_types_page_test.dart +++ b/test/features/dive_types/presentation/pages/dive_types_page_test.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:submersion/core/services/database_service.dart'; import 'package:submersion/features/dive_log/presentation/widgets/dive_type_badge.dart'; +import 'package:submersion/features/dive_types/data/repositories/dive_type_repository.dart'; import 'package:submersion/features/dive_types/presentation/pages/dive_types_page.dart'; import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; import 'package:submersion/l10n/arb/app_localizations.dart'; @@ -121,4 +122,131 @@ void main() { ); }); }); + + group('edit dialog', () { + Future openEditDialogFor(WidgetTester tester, String name) async { + await tester.tap(find.text(name)); + await tester.pumpAndSettle(); + } + + testWidgets('tapping a built-in type opens the edit dialog', ( + tester, + ) async { + await tester.pumpWidget(_buildPage(diverIdNotifier, const Locale('en'))); + await tester.pumpAndSettle(); + + await openEditDialogFor(tester, 'Wreck'); + + expect(find.byType(AlertDialog), findsOneWidget); + expect(find.byType(Checkbox), findsNWidgets(2)); + for (final element in tester.widgetList( + find.byType(Checkbox), + )) { + expect(element.value, isTrue); + } + // The name field exists but is disabled for a built-in type. + final nameField = tester.widget( + find.byType(TextFormField).first, + ); + expect(nameField.enabled, isFalse); + // No short-name field for built-ins -- it's never used for them. + expect(find.byType(TextFormField), findsOneWidget); + }); + + testWidgets( + 'unchecking "Header" and saving persists and survives a rebuild', + (tester) async { + await tester.pumpWidget( + _buildPage(diverIdNotifier, const Locale('en')), + ); + await tester.pumpAndSettle(); + + await openEditDialogFor(tester, 'Wreck'); + await tester.tap(find.byType(Checkbox).first); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + final updated = await DiveTypeRepository().getDiveTypeById('wreck'); + expect(updated?.showInDetailHeader, isFalse); + expect(updated?.showInListView, isTrue); + + // Rebuilding the page (simulating navigating away and back) must not + // silently revert the toggle. + await tester.pumpWidget( + _buildPage(diverIdNotifier, const Locale('en')), + ); + await tester.pumpAndSettle(); + await openEditDialogFor(tester, 'Wreck'); + final reloadedCheckbox = tester + .widgetList(find.byType(Checkbox)) + .first; + expect(reloadedCheckbox.value, isFalse); + }, + ); + + testWidgets('unchecking "List" and saving leaves "Header" untouched', ( + tester, + ) async { + await tester.pumpWidget(_buildPage(diverIdNotifier, const Locale('en'))); + await tester.pumpAndSettle(); + + await openEditDialogFor(tester, 'Wreck'); + await tester.tap(find.byType(Checkbox).last); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + final updated = await DiveTypeRepository().getDiveTypeById('wreck'); + expect(updated?.showInListView, isFalse); + expect(updated?.showInDetailHeader, isTrue); + }); + + testWidgets('cancelling the dialog persists nothing', (tester) async { + await tester.pumpWidget(_buildPage(diverIdNotifier, const Locale('en'))); + await tester.pumpAndSettle(); + + await openEditDialogFor(tester, 'Wreck'); + await tester.tap(find.byType(Checkbox).first); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + final updated = await DiveTypeRepository().getDiveTypeById('wreck'); + expect(updated?.showInDetailHeader, isTrue); + }); + + testWidgets('editing a custom type\'s name and short name persists', ( + tester, + ) async { + await tester.pumpWidget(_buildPage(diverIdNotifier, const Locale('en'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextFormField).at(0), 'Cenote'); + await tester.tap(find.widgetWithText(FilledButton, 'Add')); + await tester.pumpAndSettle(); + + await openEditDialogFor(tester, 'Cenote'); + // The name field is enabled for a custom type, and a short-name field + // is offered too (unlike the built-in dialog above). + expect(find.byType(TextFormField), findsNWidgets(2)); + final nameField = tester.widget( + find.byType(TextFormField).at(0), + ); + expect(nameField.enabled, isTrue); + + await tester.enterText(find.byType(TextFormField).at(0), 'Cave System'); + await tester.enterText(find.byType(TextFormField).at(1), 'Cave'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(find.text('Cave System'), findsOneWidget); + expect(find.text('Cenote'), findsNothing); + + final dive = await DiveTypeRepository().getAllDiveTypes( + diverId: 'diver-1', + ); + final saved = dive.firstWhere((t) => t.name == 'Cave System'); + expect(saved.shortName, 'Cave'); + }); + }); } From 027bc9c51a2b23b3106680a30e32dffb2408143c Mon Sep 17 00:00:00 2001 From: Cornelius Schmale Date: Fri, 28 Aug 2026 01:13:54 +0200 Subject: [PATCH 4/5] fix: add missing locale translations for dive-type visibility strings The nine new keys added for the visibility-toggle edit dialog (checkbox labels/tooltips, dialog title/save button, built-in-name helper, and the two update snackbars) only landed in English and German, which broke arb_parity_test's requirement that every locale define every English key. Translated them into the remaining nine locales. --- lib/l10n/arb/app_ar.arb | 9 +++++++++ lib/l10n/arb/app_es.arb | 9 +++++++++ lib/l10n/arb/app_fr.arb | 9 +++++++++ lib/l10n/arb/app_he.arb | 9 +++++++++ lib/l10n/arb/app_hu.arb | 9 +++++++++ lib/l10n/arb/app_it.arb | 9 +++++++++ lib/l10n/arb/app_localizations_ar.dart | 18 +++++++++--------- lib/l10n/arb/app_localizations_es.dart | 18 +++++++++--------- lib/l10n/arb/app_localizations_fr.dart | 18 +++++++++--------- lib/l10n/arb/app_localizations_he.dart | 18 +++++++++--------- lib/l10n/arb/app_localizations_hu.dart | 18 +++++++++--------- lib/l10n/arb/app_localizations_it.dart | 18 +++++++++--------- lib/l10n/arb/app_localizations_nl.dart | 18 +++++++++--------- lib/l10n/arb/app_localizations_pt.dart | 18 +++++++++--------- lib/l10n/arb/app_localizations_zh.dart | 21 +++++++++------------ lib/l10n/arb/app_nl.arb | 9 +++++++++ lib/l10n/arb/app_pt.arb | 9 +++++++++ lib/l10n/arb/app_zh.arb | 9 +++++++++ 18 files changed, 162 insertions(+), 84 deletions(-) diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 07ec85c50b..9c1294d51e 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -3005,6 +3005,15 @@ "diveTypes_deleteDialog_content": "هل أنت متأكد من حذف \"{name}\"؟", "diveTypes_deleteDialog_title": "حذف نوع الغوص؟", "diveTypes_deleteTooltip": "حذف نوع الغوص", + "diveTypes_editDialog_builtInNameHelper": "لا يمكن تغيير الأسماء المدمجة", + "diveTypes_editDialog_saveButton": "حفظ", + "diveTypes_editDialog_title": "تعديل نوع الغوص", + "diveTypes_showInHeaderLabel": "الترويسة", + "diveTypes_showInHeaderTooltip": "إظهار شارة هذا النوع في ترويسة تفاصيل الغطسة", + "diveTypes_showInListLabel": "القائمة", + "diveTypes_showInListTooltip": "إظهار شارة هذا النوع في قائمة الغطسات", + "diveTypes_snackbar_errorUpdating": "خطأ في تحديث نوع الغوص: {error}", + "diveTypes_snackbar_updated": "تم تحديث \"{name}\"", "diveTypes_snackbar_added": "تمت إضافة نوع الغوص: {name}", "diveTypes_snackbar_cannotDelete": "لا يمكن حذف \"{name}\" - مستخدم في غطسات موجودة", "diveTypes_snackbar_deleted": "تم حذف \"{name}\"", diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index a7f28486c2..bec0b421e1 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -3005,6 +3005,15 @@ "diveTypes_deleteDialog_content": "¿Estás seguro de que deseas eliminar \"{name}\"?", "diveTypes_deleteDialog_title": "¿Eliminar Tipo de Inmersión?", "diveTypes_deleteTooltip": "Eliminar tipo de inmersión", + "diveTypes_editDialog_builtInNameHelper": "Los nombres integrados no se pueden cambiar", + "diveTypes_editDialog_saveButton": "Guardar", + "diveTypes_editDialog_title": "Editar tipo de inmersión", + "diveTypes_showInHeaderLabel": "Encabezado", + "diveTypes_showInHeaderTooltip": "Mostrar la insignia de este tipo en el encabezado de detalles de la inmersión", + "diveTypes_showInListLabel": "Lista", + "diveTypes_showInListTooltip": "Mostrar la insignia de este tipo en la lista de inmersiones", + "diveTypes_snackbar_errorUpdating": "Error al actualizar el tipo de inmersión: {error}", + "diveTypes_snackbar_updated": "\"{name}\" actualizado", "diveTypes_snackbar_added": "Tipo de inmersión agregado: {name}", "diveTypes_snackbar_cannotDelete": "No se puede eliminar \"{name}\" - está siendo usado por inmersiones existentes", "diveTypes_snackbar_deleted": "Eliminado \"{name}\"", diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index 10d0190314..9682bb3689 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -2932,6 +2932,15 @@ "diveTypes_deleteDialog_content": "Voulez-vous vraiment supprimer « {name} » ?", "diveTypes_deleteDialog_title": "Supprimer le type de plongée ?", "diveTypes_deleteTooltip": "Supprimer le type de plongée", + "diveTypes_editDialog_builtInNameHelper": "Les noms intégrés ne peuvent pas être modifiés", + "diveTypes_editDialog_saveButton": "Enregistrer", + "diveTypes_editDialog_title": "Modifier le type de plongée", + "diveTypes_showInHeaderLabel": "En-tête", + "diveTypes_showInHeaderTooltip": "Afficher le badge de ce type dans l'en-tête des détails de la plongée", + "diveTypes_showInListLabel": "Liste", + "diveTypes_showInListTooltip": "Afficher le badge de ce type dans la liste des plongées", + "diveTypes_snackbar_errorUpdating": "Erreur lors de la mise à jour du type de plongée : {error}", + "diveTypes_snackbar_updated": "\"{name}\" mis à jour", "diveTypes_snackbar_added": "Type de plongée ajouté : {name}", "diveTypes_snackbar_cannotDelete": "Impossible de supprimer « {name} » - il est utilisé par des plongées existantes", "diveTypes_snackbar_deleted": "« {name} » supprimé", diff --git a/lib/l10n/arb/app_he.arb b/lib/l10n/arb/app_he.arb index 3f9eab794f..e214eec86b 100644 --- a/lib/l10n/arb/app_he.arb +++ b/lib/l10n/arb/app_he.arb @@ -2932,6 +2932,15 @@ "diveTypes_deleteDialog_content": "האם אתה בטוח שברצונך למחוק את \"{name}\"?", "diveTypes_deleteDialog_title": "למחוק סוג צלילה?", "diveTypes_deleteTooltip": "מחק סוג צלילה", + "diveTypes_editDialog_builtInNameHelper": "לא ניתן לשנות שמות מובנים", + "diveTypes_editDialog_saveButton": "שמירה", + "diveTypes_editDialog_title": "עריכת סוג צלילה", + "diveTypes_showInHeaderLabel": "כותרת", + "diveTypes_showInHeaderTooltip": "הצג את התג של סוג זה בכותרת פרטי הצלילה", + "diveTypes_showInListLabel": "רשימה", + "diveTypes_showInListTooltip": "הצג את התג של סוג זה ברשימת הצלילות", + "diveTypes_snackbar_errorUpdating": "שגיאה בעדכון סוג הצלילה: {error}", + "diveTypes_snackbar_updated": "\"{name}\" עודכן", "diveTypes_snackbar_added": "סוג צלילה נוסף: {name}", "diveTypes_snackbar_cannotDelete": "לא ניתן למחוק את \"{name}\" - הוא משמש צלילות קיימות", "diveTypes_snackbar_deleted": "נמחק \"{name}\"", diff --git a/lib/l10n/arb/app_hu.arb b/lib/l10n/arb/app_hu.arb index 0f88d55527..d43699d696 100644 --- a/lib/l10n/arb/app_hu.arb +++ b/lib/l10n/arb/app_hu.arb @@ -2932,6 +2932,15 @@ "diveTypes_deleteDialog_content": "Biztosan törölni szeretnéd: \"{name}\"?", "diveTypes_deleteDialog_title": "Merülés típus törlése?", "diveTypes_deleteTooltip": "Merülés típus törlése", + "diveTypes_editDialog_builtInNameHelper": "A beépített nevek nem módosíthatók", + "diveTypes_editDialog_saveButton": "Mentés", + "diveTypes_editDialog_title": "Merülési típus szerkesztése", + "diveTypes_showInHeaderLabel": "Fejléc", + "diveTypes_showInHeaderTooltip": "Ezen típus jelvényének megjelenítése a merülés részleteinek fejlécében", + "diveTypes_showInListLabel": "Lista", + "diveTypes_showInListTooltip": "Ezen típus jelvényének megjelenítése a merülési listában", + "diveTypes_snackbar_errorUpdating": "Hiba a merülési típus frissítésekor: {error}", + "diveTypes_snackbar_updated": "\"{name}\" frissítve", "diveTypes_snackbar_added": "Merülés típus hozzáadva: {name}", "diveTypes_snackbar_cannotDelete": "Nem lehet törölni \"{name}\" - meglévő merülések használják", "diveTypes_snackbar_deleted": "Törölve: \"{name}\"", diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index 012e8f5dff..f7700ab65f 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -2932,6 +2932,15 @@ "diveTypes_deleteDialog_content": "Sei sicuro di voler eliminare \"{name}\"?", "diveTypes_deleteDialog_title": "Eliminare Tipo Immersione?", "diveTypes_deleteTooltip": "Elimina tipo immersione", + "diveTypes_editDialog_builtInNameHelper": "I nomi predefiniti non possono essere modificati", + "diveTypes_editDialog_saveButton": "Salva", + "diveTypes_editDialog_title": "Modifica tipo di immersione", + "diveTypes_showInHeaderLabel": "Intestazione", + "diveTypes_showInHeaderTooltip": "Mostra il badge di questo tipo nell'intestazione dei dettagli dell'immersione", + "diveTypes_showInListLabel": "Elenco", + "diveTypes_showInListTooltip": "Mostra il badge di questo tipo nell'elenco delle immersioni", + "diveTypes_snackbar_errorUpdating": "Errore durante l'aggiornamento del tipo di immersione: {error}", + "diveTypes_snackbar_updated": "\"{name}\" aggiornato", "diveTypes_snackbar_added": "Tipo immersione aggiunto: {name}", "diveTypes_snackbar_cannotDelete": "Impossibile eliminare \"{name}\" - è usato da immersioni esistenti", "diveTypes_snackbar_deleted": "Eliminato \"{name}\"", diff --git a/lib/l10n/arb/app_localizations_ar.dart b/lib/l10n/arb/app_localizations_ar.dart index a9410e5c74..4a70e2a617 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -9159,27 +9159,27 @@ class AppLocalizationsAr extends AppLocalizations { @override String get diveTypes_editDialog_builtInNameHelper => - 'Built-in names can\'t be changed'; + 'لا يمكن تغيير الأسماء المدمجة'; @override - String get diveTypes_editDialog_saveButton => 'Save'; + String get diveTypes_editDialog_saveButton => 'حفظ'; @override - String get diveTypes_editDialog_title => 'Edit Dive Type'; + String get diveTypes_editDialog_title => 'تعديل نوع الغوص'; @override - String get diveTypes_showInHeaderLabel => 'Header'; + String get diveTypes_showInHeaderLabel => 'الترويسة'; @override String get diveTypes_showInHeaderTooltip => - 'Show this type\'s badge in the dive detail header'; + 'إظهار شارة هذا النوع في ترويسة تفاصيل الغطسة'; @override - String get diveTypes_showInListLabel => 'List'; + String get diveTypes_showInListLabel => 'القائمة'; @override String get diveTypes_showInListTooltip => - 'Show this type\'s badge in the dive list'; + 'إظهار شارة هذا النوع في قائمة الغطسات'; @override String diveTypes_snackbar_added(Object name) { @@ -9208,12 +9208,12 @@ class AppLocalizationsAr extends AppLocalizations { @override String diveTypes_snackbar_errorUpdating(Object error) { - return 'Error updating dive type: $error'; + return 'خطأ في تحديث نوع الغوص: $error'; } @override String diveTypes_snackbar_updated(Object name) { - return 'Updated \"$name\"'; + return 'تم تحديث \"$name\"'; } @override diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index 7123690058..d78efbbf9e 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -9332,27 +9332,27 @@ class AppLocalizationsEs extends AppLocalizations { @override String get diveTypes_editDialog_builtInNameHelper => - 'Built-in names can\'t be changed'; + 'Los nombres integrados no se pueden cambiar'; @override - String get diveTypes_editDialog_saveButton => 'Save'; + String get diveTypes_editDialog_saveButton => 'Guardar'; @override - String get diveTypes_editDialog_title => 'Edit Dive Type'; + String get diveTypes_editDialog_title => 'Editar tipo de inmersión'; @override - String get diveTypes_showInHeaderLabel => 'Header'; + String get diveTypes_showInHeaderLabel => 'Encabezado'; @override String get diveTypes_showInHeaderTooltip => - 'Show this type\'s badge in the dive detail header'; + 'Mostrar la insignia de este tipo en el encabezado de detalles de la inmersión'; @override - String get diveTypes_showInListLabel => 'List'; + String get diveTypes_showInListLabel => 'Lista'; @override String get diveTypes_showInListTooltip => - 'Show this type\'s badge in the dive list'; + 'Mostrar la insignia de este tipo en la lista de inmersiones'; @override String diveTypes_snackbar_added(Object name) { @@ -9381,12 +9381,12 @@ class AppLocalizationsEs extends AppLocalizations { @override String diveTypes_snackbar_errorUpdating(Object error) { - return 'Error updating dive type: $error'; + return 'Error al actualizar el tipo de inmersión: $error'; } @override String diveTypes_snackbar_updated(Object name) { - return 'Updated \"$name\"'; + return '\"$name\" actualizado'; } @override diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index 3a59b1f6a8..b79eb77102 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -9365,27 +9365,27 @@ class AppLocalizationsFr extends AppLocalizations { @override String get diveTypes_editDialog_builtInNameHelper => - 'Built-in names can\'t be changed'; + 'Les noms intégrés ne peuvent pas être modifiés'; @override - String get diveTypes_editDialog_saveButton => 'Save'; + String get diveTypes_editDialog_saveButton => 'Enregistrer'; @override - String get diveTypes_editDialog_title => 'Edit Dive Type'; + String get diveTypes_editDialog_title => 'Modifier le type de plongée'; @override - String get diveTypes_showInHeaderLabel => 'Header'; + String get diveTypes_showInHeaderLabel => 'En-tête'; @override String get diveTypes_showInHeaderTooltip => - 'Show this type\'s badge in the dive detail header'; + 'Afficher le badge de ce type dans l\'en-tête des détails de la plongée'; @override - String get diveTypes_showInListLabel => 'List'; + String get diveTypes_showInListLabel => 'Liste'; @override String get diveTypes_showInListTooltip => - 'Show this type\'s badge in the dive list'; + 'Afficher le badge de ce type dans la liste des plongées'; @override String diveTypes_snackbar_added(Object name) { @@ -9414,12 +9414,12 @@ class AppLocalizationsFr extends AppLocalizations { @override String diveTypes_snackbar_errorUpdating(Object error) { - return 'Error updating dive type: $error'; + return 'Erreur lors de la mise à jour du type de plongée : $error'; } @override String diveTypes_snackbar_updated(Object name) { - return 'Updated \"$name\"'; + return '\"$name\" mis à jour'; } @override diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index 395d01cad6..58ffa5b8b4 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -9101,27 +9101,27 @@ class AppLocalizationsHe extends AppLocalizations { @override String get diveTypes_editDialog_builtInNameHelper => - 'Built-in names can\'t be changed'; + 'לא ניתן לשנות שמות מובנים'; @override - String get diveTypes_editDialog_saveButton => 'Save'; + String get diveTypes_editDialog_saveButton => 'שמירה'; @override - String get diveTypes_editDialog_title => 'Edit Dive Type'; + String get diveTypes_editDialog_title => 'עריכת סוג צלילה'; @override - String get diveTypes_showInHeaderLabel => 'Header'; + String get diveTypes_showInHeaderLabel => 'כותרת'; @override String get diveTypes_showInHeaderTooltip => - 'Show this type\'s badge in the dive detail header'; + 'הצג את התג של סוג זה בכותרת פרטי הצלילה'; @override - String get diveTypes_showInListLabel => 'List'; + String get diveTypes_showInListLabel => 'רשימה'; @override String get diveTypes_showInListTooltip => - 'Show this type\'s badge in the dive list'; + 'הצג את התג של סוג זה ברשימת הצלילות'; @override String diveTypes_snackbar_added(Object name) { @@ -9150,12 +9150,12 @@ class AppLocalizationsHe extends AppLocalizations { @override String diveTypes_snackbar_errorUpdating(Object error) { - return 'Error updating dive type: $error'; + return 'שגיאה בעדכון סוג הצלילה: $error'; } @override String diveTypes_snackbar_updated(Object name) { - return 'Updated \"$name\"'; + return '\"$name\" עודכן'; } @override diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index 7f20aee45f..67db729f8e 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -9305,27 +9305,27 @@ class AppLocalizationsHu extends AppLocalizations { @override String get diveTypes_editDialog_builtInNameHelper => - 'Built-in names can\'t be changed'; + 'A beépített nevek nem módosíthatók'; @override - String get diveTypes_editDialog_saveButton => 'Save'; + String get diveTypes_editDialog_saveButton => 'Mentés'; @override - String get diveTypes_editDialog_title => 'Edit Dive Type'; + String get diveTypes_editDialog_title => 'Merülési típus szerkesztése'; @override - String get diveTypes_showInHeaderLabel => 'Header'; + String get diveTypes_showInHeaderLabel => 'Fejléc'; @override String get diveTypes_showInHeaderTooltip => - 'Show this type\'s badge in the dive detail header'; + 'Ezen típus jelvényének megjelenítése a merülés részleteinek fejlécében'; @override - String get diveTypes_showInListLabel => 'List'; + String get diveTypes_showInListLabel => 'Lista'; @override String get diveTypes_showInListTooltip => - 'Show this type\'s badge in the dive list'; + 'Ezen típus jelvényének megjelenítése a merülési listában'; @override String diveTypes_snackbar_added(Object name) { @@ -9354,12 +9354,12 @@ class AppLocalizationsHu extends AppLocalizations { @override String diveTypes_snackbar_errorUpdating(Object error) { - return 'Error updating dive type: $error'; + return 'Hiba a merülési típus frissítésekor: $error'; } @override String diveTypes_snackbar_updated(Object name) { - return 'Updated \"$name\"'; + return '\"$name\" frissítve'; } @override diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index bd87543af9..ed595b90d2 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -9331,27 +9331,27 @@ class AppLocalizationsIt extends AppLocalizations { @override String get diveTypes_editDialog_builtInNameHelper => - 'Built-in names can\'t be changed'; + 'I nomi predefiniti non possono essere modificati'; @override - String get diveTypes_editDialog_saveButton => 'Save'; + String get diveTypes_editDialog_saveButton => 'Salva'; @override - String get diveTypes_editDialog_title => 'Edit Dive Type'; + String get diveTypes_editDialog_title => 'Modifica tipo di immersione'; @override - String get diveTypes_showInHeaderLabel => 'Header'; + String get diveTypes_showInHeaderLabel => 'Intestazione'; @override String get diveTypes_showInHeaderTooltip => - 'Show this type\'s badge in the dive detail header'; + 'Mostra il badge di questo tipo nell\'intestazione dei dettagli dell\'immersione'; @override - String get diveTypes_showInListLabel => 'List'; + String get diveTypes_showInListLabel => 'Elenco'; @override String get diveTypes_showInListTooltip => - 'Show this type\'s badge in the dive list'; + 'Mostra il badge di questo tipo nell\'elenco delle immersioni'; @override String diveTypes_snackbar_added(Object name) { @@ -9380,12 +9380,12 @@ class AppLocalizationsIt extends AppLocalizations { @override String diveTypes_snackbar_errorUpdating(Object error) { - return 'Error updating dive type: $error'; + return 'Errore durante l\'aggiornamento del tipo di immersione: $error'; } @override String diveTypes_snackbar_updated(Object name) { - return 'Updated \"$name\"'; + return '\"$name\" aggiornato'; } @override diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index 6f5a778145..93398351f9 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -9259,27 +9259,27 @@ class AppLocalizationsNl extends AppLocalizations { @override String get diveTypes_editDialog_builtInNameHelper => - 'Built-in names can\'t be changed'; + 'Ingebouwde namen kunnen niet worden gewijzigd'; @override - String get diveTypes_editDialog_saveButton => 'Save'; + String get diveTypes_editDialog_saveButton => 'Opslaan'; @override - String get diveTypes_editDialog_title => 'Edit Dive Type'; + String get diveTypes_editDialog_title => 'Duiktype bewerken'; @override - String get diveTypes_showInHeaderLabel => 'Header'; + String get diveTypes_showInHeaderLabel => 'Koptekst'; @override String get diveTypes_showInHeaderTooltip => - 'Show this type\'s badge in the dive detail header'; + 'Toon de badge van dit type in de duikdetailkop'; @override - String get diveTypes_showInListLabel => 'List'; + String get diveTypes_showInListLabel => 'Lijst'; @override String get diveTypes_showInListTooltip => - 'Show this type\'s badge in the dive list'; + 'Toon de badge van dit type in de duiklijst'; @override String diveTypes_snackbar_added(Object name) { @@ -9308,12 +9308,12 @@ class AppLocalizationsNl extends AppLocalizations { @override String diveTypes_snackbar_errorUpdating(Object error) { - return 'Error updating dive type: $error'; + return 'Fout bij het bijwerken van het duiktype: $error'; } @override String diveTypes_snackbar_updated(Object name) { - return 'Updated \"$name\"'; + return '\"$name\" bijgewerkt'; } @override diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index 380bc7f13b..d2ebcafc90 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -9333,27 +9333,27 @@ class AppLocalizationsPt extends AppLocalizations { @override String get diveTypes_editDialog_builtInNameHelper => - 'Built-in names can\'t be changed'; + 'Os nomes internos não podem ser alterados'; @override - String get diveTypes_editDialog_saveButton => 'Save'; + String get diveTypes_editDialog_saveButton => 'Salvar'; @override - String get diveTypes_editDialog_title => 'Edit Dive Type'; + String get diveTypes_editDialog_title => 'Editar tipo de mergulho'; @override - String get diveTypes_showInHeaderLabel => 'Header'; + String get diveTypes_showInHeaderLabel => 'Cabeçalho'; @override String get diveTypes_showInHeaderTooltip => - 'Show this type\'s badge in the dive detail header'; + 'Mostrar o selo deste tipo no cabeçalho de detalhes do mergulho'; @override - String get diveTypes_showInListLabel => 'List'; + String get diveTypes_showInListLabel => 'Lista'; @override String get diveTypes_showInListTooltip => - 'Show this type\'s badge in the dive list'; + 'Mostrar o selo deste tipo na lista de mergulhos'; @override String diveTypes_snackbar_added(Object name) { @@ -9382,12 +9382,12 @@ class AppLocalizationsPt extends AppLocalizations { @override String diveTypes_snackbar_errorUpdating(Object error) { - return 'Error updating dive type: $error'; + return 'Erro ao atualizar o tipo de mergulho: $error'; } @override String diveTypes_snackbar_updated(Object name) { - return 'Updated \"$name\"'; + return '\"$name\" atualizado'; } @override diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index 0a011f4301..b14cd93279 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -8878,28 +8878,25 @@ class AppLocalizationsZh extends AppLocalizations { String get diveTypes_deleteTooltip => '删除潜水类型'; @override - String get diveTypes_editDialog_builtInNameHelper => - 'Built-in names can\'t be changed'; + String get diveTypes_editDialog_builtInNameHelper => '内置名称无法更改'; @override - String get diveTypes_editDialog_saveButton => 'Save'; + String get diveTypes_editDialog_saveButton => '保存'; @override - String get diveTypes_editDialog_title => 'Edit Dive Type'; + String get diveTypes_editDialog_title => '编辑潜水类型'; @override - String get diveTypes_showInHeaderLabel => 'Header'; + String get diveTypes_showInHeaderLabel => '标题栏'; @override - String get diveTypes_showInHeaderTooltip => - 'Show this type\'s badge in the dive detail header'; + String get diveTypes_showInHeaderTooltip => '在潜水详情标题栏中显示此类型的徽章'; @override - String get diveTypes_showInListLabel => 'List'; + String get diveTypes_showInListLabel => '列表'; @override - String get diveTypes_showInListTooltip => - 'Show this type\'s badge in the dive list'; + String get diveTypes_showInListTooltip => '在潜水列表中显示此类型的徽章'; @override String diveTypes_snackbar_added(Object name) { @@ -8928,12 +8925,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String diveTypes_snackbar_errorUpdating(Object error) { - return 'Error updating dive type: $error'; + return '更新潜水类型时出错:$error'; } @override String diveTypes_snackbar_updated(Object name) { - return 'Updated \"$name\"'; + return '已更新“$name”'; } @override diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index 4d0a439587..036dcceb65 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -3005,6 +3005,15 @@ "diveTypes_deleteDialog_content": "Weet je zeker dat je \"{name}\" wilt verwijderen?", "diveTypes_deleteDialog_title": "Duiktype verwijderen?", "diveTypes_deleteTooltip": "Duiktype verwijderen", + "diveTypes_editDialog_builtInNameHelper": "Ingebouwde namen kunnen niet worden gewijzigd", + "diveTypes_editDialog_saveButton": "Opslaan", + "diveTypes_editDialog_title": "Duiktype bewerken", + "diveTypes_showInHeaderLabel": "Koptekst", + "diveTypes_showInHeaderTooltip": "Toon de badge van dit type in de duikdetailkop", + "diveTypes_showInListLabel": "Lijst", + "diveTypes_showInListTooltip": "Toon de badge van dit type in de duiklijst", + "diveTypes_snackbar_errorUpdating": "Fout bij het bijwerken van het duiktype: {error}", + "diveTypes_snackbar_updated": "\"{name}\" bijgewerkt", "diveTypes_snackbar_added": "Duiktype toegevoegd: {name}", "diveTypes_snackbar_cannotDelete": "Kan \"{name}\" niet verwijderen - wordt gebruikt door bestaande duiken", "diveTypes_snackbar_deleted": "\"{name}\" verwijderd", diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index 2cf9f16e81..f0a687a6d0 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -3005,6 +3005,15 @@ "diveTypes_deleteDialog_content": "Tem certeza de que deseja excluir \"{name}\"?", "diveTypes_deleteDialog_title": "Excluir Tipo de Mergulho?", "diveTypes_deleteTooltip": "Excluir tipo de mergulho", + "diveTypes_editDialog_builtInNameHelper": "Os nomes internos não podem ser alterados", + "diveTypes_editDialog_saveButton": "Salvar", + "diveTypes_editDialog_title": "Editar tipo de mergulho", + "diveTypes_showInHeaderLabel": "Cabeçalho", + "diveTypes_showInHeaderTooltip": "Mostrar o selo deste tipo no cabeçalho de detalhes do mergulho", + "diveTypes_showInListLabel": "Lista", + "diveTypes_showInListTooltip": "Mostrar o selo deste tipo na lista de mergulhos", + "diveTypes_snackbar_errorUpdating": "Erro ao atualizar o tipo de mergulho: {error}", + "diveTypes_snackbar_updated": "\"{name}\" atualizado", "diveTypes_snackbar_added": "Tipo de mergulho adicionado: {name}", "diveTypes_snackbar_cannotDelete": "Não é possível excluir \"{name}\" - está sendo usado por mergulhos existentes", "diveTypes_snackbar_deleted": "Excluído \"{name}\"", diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index a7ad960bc0..87dc9d5c7e 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -3138,6 +3138,15 @@ "diveTypes_deleteDialog_content": "确定要删除 \"{name}\"?", "diveTypes_deleteDialog_title": "删除潜水类型?", "diveTypes_deleteTooltip": "删除潜水类型", + "diveTypes_editDialog_builtInNameHelper": "内置名称无法更改", + "diveTypes_editDialog_saveButton": "保存", + "diveTypes_editDialog_title": "编辑潜水类型", + "diveTypes_showInHeaderLabel": "标题栏", + "diveTypes_showInHeaderTooltip": "在潜水详情标题栏中显示此类型的徽章", + "diveTypes_showInListLabel": "列表", + "diveTypes_showInListTooltip": "在潜水列表中显示此类型的徽章", + "diveTypes_snackbar_errorUpdating": "更新潜水类型时出错:{error}", + "diveTypes_snackbar_updated": "已更新“{name}”", "diveTypes_snackbar_added": "已添加潜水类型:{name}", "diveTypes_snackbar_cannotDelete": "无法删除 \"{name}\" - 已被现有潜水记录使用", "diveTypes_snackbar_deleted": "已删除\"{name}\"", From b962b42b99c52f2324554f3f1051c15dc5f86a47 Mon Sep 17 00:00:00 2001 From: Cornelius Schmale Date: Fri, 28 Aug 2026 01:47:40 +0200 Subject: [PATCH 5/5] fix: address Copilot review comments on dive-type visibility PR Adopt the Diver.copyWith _unset/_resolve sentinel pattern so DiveTypeEntity.copyWith(shortName: null) can actually clear an existing short name instead of being swallowed by ?? this.value. Show the localized name in the built-in edit dialog's disabled name field instead of the seeded English DB value. --- .../domain/entities/dive_type_entity.dart | 14 +++++- .../presentation/pages/dive_types_page.dart | 8 +++- .../entities/dive_type_entity_test.dart | 39 +++++++++++++++++ .../pages/dive_types_page_test.dart | 43 +++++++++++++++++++ 4 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 test/features/dive_types/domain/entities/dive_type_entity_test.dart diff --git a/lib/features/dive_types/domain/entities/dive_type_entity.dart b/lib/features/dive_types/domain/entities/dive_type_entity.dart index c3c6f436da..c6f4a23891 100644 --- a/lib/features/dive_types/domain/entities/dive_type_entity.dart +++ b/lib/features/dive_types/domain/entities/dive_type_entity.dart @@ -72,6 +72,16 @@ class DiveTypeEntity extends Equatable { .replaceAll(RegExp(r'\s+'), '_'); } + /// Sentinel marking a `copyWith` parameter as "not provided". Lets callers + /// distinguish omitting [shortName] (keep the current value) from passing + /// `null` (clear it) -- plain `value ?? this.value` cannot express a clear, + /// which broke the edit dialog's "remove an existing short name" flow. + /// Mirrors [Diver.copyWith]'s `_unset`/`_resolve` pattern. + static const Object _unset = Object(); + + static T _resolve(Object? value, T current) => + identical(value, _unset) ? current : value as T; + DiveTypeEntity copyWith({ String? id, String? diverId, @@ -80,7 +90,7 @@ class DiveTypeEntity extends Equatable { int? sortOrder, DateTime? createdAt, DateTime? updatedAt, - String? shortName, + Object? shortName = _unset, bool? showInDetailHeader, bool? showInListView, }) { @@ -92,7 +102,7 @@ class DiveTypeEntity extends Equatable { sortOrder: sortOrder ?? this.sortOrder, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, - shortName: shortName ?? this.shortName, + shortName: _resolve(shortName, this.shortName), showInDetailHeader: showInDetailHeader ?? this.showInDetailHeader, showInListView: showInListView ?? this.showInListView, ); diff --git a/lib/features/dive_types/presentation/pages/dive_types_page.dart b/lib/features/dive_types/presentation/pages/dive_types_page.dart index f1d227b32c..1c3649dabb 100644 --- a/lib/features/dive_types/presentation/pages/dive_types_page.dart +++ b/lib/features/dive_types/presentation/pages/dive_types_page.dart @@ -245,7 +245,13 @@ class DiveTypesPage extends ConsumerWidget { DiveTypeEntity diveType, ) async { final canEditName = !diveType.isBuiltIn; - final nameController = TextEditingController(text: diveType.name); + // Built-in names are disabled for editing, so show the localized name + // (matching the list tile) rather than the seeded English DB value -- + // otherwise a German-locale diver would see "Wreck" instead of + // "Wracktauchen" in a field they can't even change. + final nameController = TextEditingController( + text: canEditName ? diveType.name : diveType.localizedName(context.l10n), + ); final shortNameController = TextEditingController( text: diveType.shortName ?? '', ); diff --git a/test/features/dive_types/domain/entities/dive_type_entity_test.dart b/test/features/dive_types/domain/entities/dive_type_entity_test.dart new file mode 100644 index 0000000000..2e350ba923 --- /dev/null +++ b/test/features/dive_types/domain/entities/dive_type_entity_test.dart @@ -0,0 +1,39 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/dive_types/domain/entities/dive_type_entity.dart'; + +void main() { + DiveTypeEntity entityWith({String? shortName}) => DiveTypeEntity( + id: 'cenote', + name: 'Cenote', + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + shortName: shortName, + ); + + group('DiveTypeEntity.copyWith shortName', () { + test('omitting the parameter keeps the current shortName', () { + final original = entityWith(shortName: 'Cen'); + + final copy = original.copyWith(name: 'Cenote System'); + + expect(copy.shortName, 'Cen'); + }); + + test('passing a new value replaces the current shortName', () { + final original = entityWith(shortName: 'Cen'); + + final copy = original.copyWith(shortName: 'CEN'); + + expect(copy.shortName, 'CEN'); + }); + + test('passing null explicitly clears an existing shortName ' + '(regression: the edit dialog could not remove a short name)', () { + final original = entityWith(shortName: 'Cen'); + + final copy = original.copyWith(shortName: null); + + expect(copy.shortName, isNull); + }); + }); +} diff --git a/test/features/dive_types/presentation/pages/dive_types_page_test.dart b/test/features/dive_types/presentation/pages/dive_types_page_test.dart index a7f65ffd3c..87d0649276 100644 --- a/test/features/dive_types/presentation/pages/dive_types_page_test.dart +++ b/test/features/dive_types/presentation/pages/dive_types_page_test.dart @@ -153,6 +153,25 @@ void main() { expect(find.byType(TextFormField), findsOneWidget); }); + testWidgets( + 'a built-in type\'s disabled name field shows the localized name, ' + 'not the seeded English value', + (tester) async { + await tester.pumpWidget( + _buildPage(diverIdNotifier, const Locale('de')), + ); + await tester.pumpAndSettle(); + + await openEditDialogFor(tester, 'Wracktauchen'); + + final nameField = tester.widget( + find.byType(TextFormField).first, + ); + expect(nameField.controller?.text, 'Wracktauchen'); + expect(nameField.controller?.text, isNot('Wreck')); + }, + ); + testWidgets( 'unchecking "Header" and saving persists and survives a rebuild', (tester) async { @@ -248,5 +267,29 @@ void main() { final saved = dive.firstWhere((t) => t.name == 'Cave System'); expect(saved.shortName, 'Cave'); }); + + testWidgets('clearing a custom type\'s short name field removes it ' + '(regression: copyWith could not clear shortName)', (tester) async { + await tester.pumpWidget(_buildPage(diverIdNotifier, const Locale('en'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextFormField).at(0), 'Cenote'); + await tester.enterText(find.byType(TextFormField).at(1), 'Cen'); + await tester.tap(find.widgetWithText(FilledButton, 'Add')); + await tester.pumpAndSettle(); + + var dive = await DiveTypeRepository().getAllDiveTypes(diverId: 'diver-1'); + expect(dive.firstWhere((t) => t.name == 'Cenote').shortName, 'Cen'); + + await openEditDialogFor(tester, 'Cenote'); + await tester.enterText(find.byType(TextFormField).at(1), ''); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + dive = await DiveTypeRepository().getAllDiveTypes(diverId: 'diver-1'); + expect(dive.firstWhere((t) => t.name == 'Cenote').shortName, isNull); + }); }); }