diff --git a/lib/core/constants/dive_detail_sections.dart b/lib/core/constants/dive_detail_sections.dart new file mode 100644 index 000000000..dca56ed4b --- /dev/null +++ b/lib/core/constants/dive_detail_sections.dart @@ -0,0 +1,204 @@ +import 'dart:convert'; + +import 'package:submersion/l10n/arb/app_localizations.dart'; + +/// Identifies each configurable section on the Dive Details page. +/// +/// Declaration order defines the default display order. The two fixed sections +/// (Header and Dive Profile Chart) are not included — they always render first. +enum DiveDetailSectionId { + decoO2, + sacSegments, + details, + environment, + altitude, + tide, + weights, + tanks, + buddies, + signatures, + equipment, + sightings, + media, + tags, + notes, + customFields, + dataSources; + + /// Human-readable name shown in the settings UI (English fallback). + String get displayName { + return switch (this) { + decoO2 => 'Deco Status / Tissue Loading', + sacSegments => 'SAC Rate by Segment', + details => 'Details', + environment => 'Environment', + altitude => 'Altitude', + tide => 'Tide', + weights => 'Weights', + tanks => 'Tanks', + buddies => 'Buddies', + signatures => 'Signatures', + equipment => 'Equipment', + sightings => 'Marine Life Sightings', + media => 'Media', + tags => 'Tags', + notes => 'Notes', + customFields => 'Custom Fields', + dataSources => 'Data Sources', + }; + } + + /// Short description shown below the name in the settings UI + /// (English fallback). + String get description { + return switch (this) { + decoO2 => 'NDL, ceiling, tissue heat map, O2 toxicity', + sacSegments => 'Phase/time segmentation, cylinder breakdown', + details => 'Type, location, trip, dive center, interval', + environment => 'Air/water temp, visibility, current', + altitude => 'Altitude value, category, deco requirement', + tide => 'Tide cycle graph and timing', + weights => 'Weight breakdown, total weight', + tanks => 'Tank list, gas mixes, pressures, per-tank SAC', + buddies => 'Buddy list with roles', + signatures => 'Buddy/instructor signature display and capture', + equipment => 'Equipment used in dive', + sightings => 'Species spotted, sighting details', + media => 'Photos/videos gallery', + tags => 'Dive tags', + notes => 'Dive notes/description', + customFields => 'User-defined custom fields', + dataSources => 'Connected dive computers, source management', + }; + } + + /// Localized display name resolved via [AppLocalizations]. + String localizedDisplayName(AppLocalizations l10n) { + return switch (this) { + decoO2 => l10n.diveDetailSection_decoO2_name, + sacSegments => l10n.diveDetailSection_sacSegments_name, + details => l10n.diveDetailSection_details_name, + environment => l10n.diveDetailSection_environment_name, + altitude => l10n.diveDetailSection_altitude_name, + tide => l10n.diveDetailSection_tide_name, + weights => l10n.diveDetailSection_weights_name, + tanks => l10n.diveDetailSection_tanks_name, + buddies => l10n.diveDetailSection_buddies_name, + signatures => l10n.diveDetailSection_signatures_name, + equipment => l10n.diveDetailSection_equipment_name, + sightings => l10n.diveDetailSection_sightings_name, + media => l10n.diveDetailSection_media_name, + tags => l10n.diveDetailSection_tags_name, + notes => l10n.diveDetailSection_notes_name, + customFields => l10n.diveDetailSection_customFields_name, + dataSources => l10n.diveDetailSection_dataSources_name, + }; + } + + /// Localized description resolved via [AppLocalizations]. + String localizedDescription(AppLocalizations l10n) { + return switch (this) { + decoO2 => l10n.diveDetailSection_decoO2_description, + sacSegments => l10n.diveDetailSection_sacSegments_description, + details => l10n.diveDetailSection_details_description, + environment => l10n.diveDetailSection_environment_description, + altitude => l10n.diveDetailSection_altitude_description, + tide => l10n.diveDetailSection_tide_description, + weights => l10n.diveDetailSection_weights_description, + tanks => l10n.diveDetailSection_tanks_description, + buddies => l10n.diveDetailSection_buddies_description, + signatures => l10n.diveDetailSection_signatures_description, + equipment => l10n.diveDetailSection_equipment_description, + sightings => l10n.diveDetailSection_sightings_description, + media => l10n.diveDetailSection_media_description, + tags => l10n.diveDetailSection_tags_description, + notes => l10n.diveDetailSection_notes_description, + customFields => l10n.diveDetailSection_customFields_description, + dataSources => l10n.diveDetailSection_dataSources_description, + }; + } +} + +/// Visibility and ordering configuration for a single dive detail section. +class DiveDetailSectionConfig { + final DiveDetailSectionId id; + final bool visible; + + const DiveDetailSectionConfig({required this.id, required this.visible}); + + DiveDetailSectionConfig copyWith({bool? visible}) { + return DiveDetailSectionConfig(id: id, visible: visible ?? this.visible); + } + + Map toJson() => {'id': id.name, 'visible': visible}; + + factory DiveDetailSectionConfig.fromJson(Map json) { + final idStr = json['id'] as String; + final id = DiveDetailSectionId.values.firstWhere((e) => e.name == idStr); + return DiveDetailSectionConfig( + id: id, + visible: json['visible'] as bool? ?? true, + ); + } + + static DiveDetailSectionConfig? tryFromJson(Map json) { + try { + return DiveDetailSectionConfig.fromJson(json); + } catch (_) { + return null; + } + } + + static const List defaultSections = [ + DiveDetailSectionConfig(id: DiveDetailSectionId.decoO2, visible: true), + DiveDetailSectionConfig(id: DiveDetailSectionId.sacSegments, visible: true), + DiveDetailSectionConfig(id: DiveDetailSectionId.details, visible: true), + DiveDetailSectionConfig(id: DiveDetailSectionId.environment, visible: true), + DiveDetailSectionConfig(id: DiveDetailSectionId.altitude, visible: true), + DiveDetailSectionConfig(id: DiveDetailSectionId.tide, visible: true), + DiveDetailSectionConfig(id: DiveDetailSectionId.weights, visible: true), + DiveDetailSectionConfig(id: DiveDetailSectionId.tanks, visible: true), + DiveDetailSectionConfig(id: DiveDetailSectionId.buddies, visible: true), + DiveDetailSectionConfig(id: DiveDetailSectionId.signatures, visible: true), + DiveDetailSectionConfig(id: DiveDetailSectionId.equipment, visible: true), + DiveDetailSectionConfig(id: DiveDetailSectionId.sightings, visible: true), + DiveDetailSectionConfig(id: DiveDetailSectionId.media, visible: true), + DiveDetailSectionConfig(id: DiveDetailSectionId.tags, visible: true), + DiveDetailSectionConfig(id: DiveDetailSectionId.notes, visible: true), + DiveDetailSectionConfig( + id: DiveDetailSectionId.customFields, + visible: true, + ), + DiveDetailSectionConfig(id: DiveDetailSectionId.dataSources, visible: true), + ]; + + static String sectionsToJson(List sections) { + return jsonEncode(sections.map((s) => s.toJson()).toList()); + } + + static List sectionsFromJson(String? json) { + if (json == null || json.isEmpty) return List.of(defaultSections); + try { + final decoded = jsonDecode(json) as List; + final sections = decoded + .map((e) => e is Map ? tryFromJson(e) : null) + .whereType() + .toList(); + if (sections.isEmpty) return List.of(defaultSections); + return ensureAllSections(sections); + } catch (_) { + return List.of(defaultSections); + } + } + + static List ensureAllSections( + List sections, + ) { + final presentIds = sections.map((s) => s.id).toSet(); + final missing = DiveDetailSectionId.values + .where((id) => !presentIds.contains(id)) + .map((id) => DiveDetailSectionConfig(id: id, visible: true)); + if (missing.isEmpty) return sections; + return [...sections, ...missing]; + } +} diff --git a/lib/core/database/database.dart b/lib/core/database/database.dart index 798798e86..d9f4bb62b 100644 --- a/lib/core/database/database.dart +++ b/lib/core/database/database.dart @@ -717,6 +717,8 @@ class DiverSettings extends Table { // Data source badge visibility (v55) BoolColumn get showDataSourceBadges => boolean().withDefault(const Constant(true))(); + // Dive detail section order and visibility (v56) — JSON array + TextColumn get diveDetailSections => text().nullable()(); IntColumn get createdAt => integer()(); IntColumn get updatedAt => integer()(); @@ -1238,7 +1240,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 = 56; + static const int currentSchemaVersion = 57; @override int get schemaVersion => currentSchemaVersion; @@ -2475,6 +2477,12 @@ class AppDatabase extends _$AppDatabase { 'ALTER TABLE dives RENAME COLUMN duration TO bottom_time', ); } + if (from < 57) { + // Add dive detail section configuration column to diver_settings + await customStatement( + 'ALTER TABLE diver_settings ADD COLUMN dive_detail_sections TEXT', + ); + } }, beforeOpen: (details) async { // Enable foreign keys diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 7669d8fec..84f30e9bf 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -73,6 +73,7 @@ import 'package:submersion/features/settings/presentation/pages/fix_dive_times_p import 'package:submersion/features/settings/presentation/pages/settings_page.dart'; import 'package:submersion/features/settings/presentation/pages/appearance_page.dart'; import 'package:submersion/features/settings/presentation/pages/default_visible_metrics_page.dart'; +import 'package:submersion/features/settings/presentation/pages/dive_detail_sections_page.dart'; import 'package:submersion/features/settings/presentation/pages/language_settings_page.dart'; import 'package:submersion/features/settings/presentation/pages/theme_gallery_page.dart'; import 'package:submersion/features/settings/presentation/pages/storage_settings_page.dart'; @@ -733,6 +734,11 @@ final appRouterProvider = Provider((ref) { name: 'appearance', builder: (context, state) => const AppearancePage(), ), + GoRoute( + path: 'dive-detail-sections', + name: 'diveDetailSections', + builder: (context, state) => const DiveDetailSectionsPage(), + ), GoRoute( path: 'default-metrics', name: 'defaultMetrics', 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 42f3920c7..d5c64649a 100644 --- a/lib/features/dive_log/presentation/pages/dive_detail_page.dart +++ b/lib/features/dive_log/presentation/pages/dive_detail_page.dart @@ -9,6 +9,7 @@ import 'package:submersion/core/providers/provider.dart'; import 'package:go_router/go_router.dart'; import 'package:latlong2/latlong.dart'; +import 'package:submersion/core/constants/dive_detail_sections.dart'; import 'package:submersion/core/constants/enums.dart'; import 'package:submersion/core/constants/tank_presets.dart'; import 'package:submersion/core/constants/units.dart'; @@ -26,6 +27,7 @@ import 'package:submersion/features/settings/presentation/providers/settings_pro import 'package:submersion/features/dive_log/data/services/profile_analysis_service.dart'; import 'package:submersion/features/dive_log/data/services/profile_markers_service.dart'; import 'package:submersion/features/dive_log/domain/entities/cylinder_sac.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive_data_source.dart'; import 'package:submersion/features/dive_log/domain/entities/dive.dart'; import 'package:submersion/features/dive_log/domain/entities/dive_computer.dart'; import 'package:submersion/features/dive_log/domain/entities/gas_switch.dart'; @@ -223,11 +225,202 @@ class _DiveDetailPageState extends ConsumerState { ); } + /// Maps each configurable section ID to a builder returning its widgets. + /// + /// Each builder preserves the exact data-driven visibility conditions from + /// the original hardcoded layout. Spacing SizedBoxes are included before + /// each section's content (or handled internally by the section widget). + Map Function()> _sectionBuilders({ + required BuildContext context, + required WidgetRef ref, + required Dive dive, + required UnitFormatter units, + required AsyncValue> computerReadingsAsync, + required AppSettings settings, + }) { + return { + DiveDetailSectionId.decoO2: () { + if (dive.profile.isEmpty) return []; + return [ + const SizedBox(height: 24), + ValueListenableBuilder( + valueListenable: _selectedPointNotifier, + builder: (context, selectedPointIndex, _) { + return _buildDecoO2Panel(context, ref, dive, selectedPointIndex); + }, + ), + ]; + }, + DiveDetailSectionId.sacSegments: () { + if (dive.profile.isEmpty) return []; + return [ + const SizedBox(height: 24), + ValueListenableBuilder( + valueListenable: _selectedPointNotifier, + builder: (context, selectedPointIndex, _) { + return _buildSacSegmentsSection( + context, + ref, + dive, + selectedPointIndex, + ); + }, + ), + ]; + }, + DiveDetailSectionId.details: () { + return [ + ValueListenableBuilder( + valueListenable: _viewedSourceIdNotifier, + builder: (context, viewedSourceId, _) { + final dataSources = computerReadingsAsync.valueOrNull ?? []; + final attribution = FieldAttributionService.computeAttribution( + dataSources, + viewedSourceId: viewedSourceId, + ); + final showBadges = + settings.showDataSourceBadges && attribution.isNotEmpty; + return _buildDetailsSection( + context, + ref, + dive, + units, + attribution: showBadges ? attribution : null, + ); + }, + ), + ]; + }, + DiveDetailSectionId.environment: () { + if (!_hasEnvironmentData(dive)) return []; + return [ + const SizedBox(height: 24), + _buildEnvironmentSection(context, dive, units), + ]; + }, + DiveDetailSectionId.altitude: () { + if (dive.altitude == null || dive.altitude! <= 0) return []; + return [ + const SizedBox(height: 24), + _buildAltitudeSection(context, ref, dive), + ]; + }, + DiveDetailSectionId.tide: () { + // _buildTideSection includes its own internal spacing + return [_buildTideSection(context, ref, dive)]; + }, + DiveDetailSectionId.weights: () { + if (!_hasWeights(dive)) return []; + return [ + const SizedBox(height: 24), + _buildWeightSection(context, dive, units), + ]; + }, + DiveDetailSectionId.tanks: () { + if (dive.tanks.isEmpty) return []; + return [ + const SizedBox(height: 24), + _buildTanksSection(context, ref, dive, units), + ]; + }, + DiveDetailSectionId.buddies: () { + return [const SizedBox(height: 24), _buildBuddiesSection(context, ref)]; + }, + DiveDetailSectionId.signatures: () { + return [ + const SizedBox(height: 24), + BuddySignaturesSection(diveId: diveId), + if (dive.courseId != null) ...[ + const SizedBox(height: 24), + _buildSignatureSection(context, ref, dive), + ], + ]; + }, + DiveDetailSectionId.equipment: () { + if (dive.equipment.isEmpty) return []; + return [ + const SizedBox(height: 24), + _buildEquipmentSection(context, ref, dive), + ]; + }, + DiveDetailSectionId.sightings: () { + // _buildSightingsSection includes its own internal spacing + return [_buildSightingsSection(context, ref)]; + }, + DiveDetailSectionId.media: () { + return [ + const SizedBox(height: 24), + _buildMediaSection(context, ref, dive), + ]; + }, + DiveDetailSectionId.tags: () { + if (dive.tags.isEmpty) return []; + return [const SizedBox(height: 24), _buildTagsSection(context, dive)]; + }, + DiveDetailSectionId.notes: () { + return [const SizedBox(height: 24), _buildNotesSection(context, dive)]; + }, + DiveDetailSectionId.customFields: () { + if (dive.customFields.isEmpty) return []; + return [ + const SizedBox(height: 24), + _buildCustomFieldsSection(context, dive), + ]; + }, + DiveDetailSectionId.dataSources: () { + return [ + const SizedBox(height: 24), + ValueListenableBuilder( + valueListenable: _viewedSourceIdNotifier, + builder: (context, viewedSourceId, _) { + final dataSources = computerReadingsAsync.valueOrNull ?? []; + return DataSourcesSection( + dataSources: dataSources, + diveCreatedAt: dive.dateTime, + diveId: dive.id, + units: units, + viewedSourceId: viewedSourceId, + onTapSource: (sourceId) { + if (_viewedSourceIdNotifier.value == sourceId) { + _viewedSourceIdNotifier.value = null; + } else { + _viewedSourceIdNotifier.value = sourceId; + } + }, + onSetPrimary: (readingId) => _onSetPrimaryDataSource( + context, + ref, + diveId: dive.id, + readingId: readingId, + ), + onUnlink: (readingId) => _onUnlinkDataSource( + context, + ref, + diveId: dive.id, + readingId: readingId, + ), + ); + }, + ), + ]; + }, + }; + } + Widget _buildContent(BuildContext context, WidgetRef ref, Dive dive) { final settings = ref.watch(settingsProvider); final units = UnitFormatter(settings); final computerReadingsAsync = ref.watch(diveDataSourcesProvider(dive.id)); + final builders = _sectionBuilders( + context: context, + ref: ref, + dive: dive, + units: units, + computerReadingsAsync: computerReadingsAsync, + settings: settings, + ); + final body = SingleChildScrollView( padding: const EdgeInsets.all(16), child: RepaintBoundary( @@ -235,6 +428,7 @@ class _DiveDetailPageState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Fixed: Header ValueListenableBuilder( valueListenable: _viewedSourceIdNotifier, builder: (context, viewedSourceId, _) { @@ -255,127 +449,13 @@ class _DiveDetailPageState extends ConsumerState { }, ), const SizedBox(height: 24), + // Fixed: Dive Profile Chart if (dive.profile.isNotEmpty) ...[ _buildProfileSection(context, ref, dive), - const SizedBox(height: 24), - ValueListenableBuilder( - valueListenable: _selectedPointNotifier, - builder: (context, selectedPointIndex, _) { - return _buildDecoO2Panel( - context, - ref, - dive, - selectedPointIndex, - ); - }, - ), - const SizedBox(height: 24), - ValueListenableBuilder( - valueListenable: _selectedPointNotifier, - builder: (context, selectedPointIndex, _) { - return _buildSacSegmentsSection( - context, - ref, - dive, - selectedPointIndex, - ); - }, - ), - ], - ValueListenableBuilder( - valueListenable: _viewedSourceIdNotifier, - builder: (context, viewedSourceId, _) { - final dataSources = computerReadingsAsync.valueOrNull ?? []; - final attribution = FieldAttributionService.computeAttribution( - dataSources, - viewedSourceId: viewedSourceId, - ); - final showBadges = - settings.showDataSourceBadges && attribution.isNotEmpty; - return _buildDetailsSection( - context, - ref, - dive, - units, - attribution: showBadges ? attribution : null, - ); - }, - ), - if (_hasEnvironmentData(dive)) ...[ - const SizedBox(height: 24), - _buildEnvironmentSection(context, dive, units), - ], - if (dive.altitude != null && dive.altitude! > 0) ...[ - const SizedBox(height: 24), - _buildAltitudeSection(context, ref, dive), ], - if (_hasWeights(dive)) ...[ - const SizedBox(height: 24), - _buildWeightSection(context, dive, units), - ], - const SizedBox(height: 24), - _buildBuddiesSection(context, ref), - const SizedBox(height: 24), - BuddySignaturesSection(diveId: diveId), - const SizedBox(height: 24), - if (dive.tanks.isNotEmpty) ...[ - _buildTanksSection(context, ref, dive, units), - ], - if (dive.equipment.isNotEmpty) ...[ - const SizedBox(height: 24), - _buildEquipmentSection(context, ref, dive), - ], - _buildSightingsSection(context, ref), - const SizedBox(height: 24), - _buildMediaSection(context, ref, dive), - if (dive.tags.isNotEmpty) ...[ - const SizedBox(height: 24), - _buildTagsSection(context, dive), - ], - const SizedBox(height: 24), - _buildNotesSection(context, dive), - if (dive.customFields.isNotEmpty) ...[ - const SizedBox(height: 24), - _buildCustomFieldsSection(context, dive), - ], - if (dive.courseId != null) ...[ - const SizedBox(height: 24), - _buildSignatureSection(context, ref, dive), - ], - _buildTideSection(context, ref, dive), - const SizedBox(height: 24), - ValueListenableBuilder( - valueListenable: _viewedSourceIdNotifier, - builder: (context, viewedSourceId, _) { - final dataSources = computerReadingsAsync.valueOrNull ?? []; - return DataSourcesSection( - dataSources: dataSources, - diveCreatedAt: dive.dateTime, - diveId: dive.id, - units: units, - viewedSourceId: viewedSourceId, - onTapSource: (sourceId) { - if (_viewedSourceIdNotifier.value == sourceId) { - _viewedSourceIdNotifier.value = null; - } else { - _viewedSourceIdNotifier.value = sourceId; - } - }, - onSetPrimary: (readingId) => _onSetPrimaryDataSource( - context, - ref, - diveId: dive.id, - readingId: readingId, - ), - onUnlink: (readingId) => _onUnlinkDataSource( - context, - ref, - diveId: dive.id, - readingId: readingId, - ), - ); - }, - ), + // Configurable sections in user-defined order + for (final section in settings.diveDetailSections) + if (section.visible) ...builders[section.id]?.call() ?? [], const SizedBox(height: 32), ], ), diff --git a/lib/features/settings/data/repositories/diver_settings_repository.dart b/lib/features/settings/data/repositories/diver_settings_repository.dart index 8379a0629..1cdd5ed9d 100644 --- a/lib/features/settings/data/repositories/diver_settings_repository.dart +++ b/lib/features/settings/data/repositories/diver_settings_repository.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:uuid/uuid.dart'; import 'package:submersion/core/constants/card_color.dart'; +import 'package:submersion/core/constants/dive_detail_sections.dart'; import 'package:submersion/core/constants/list_view_mode.dart'; import 'package:submersion/core/constants/profile_metrics.dart'; import 'package:submersion/core/constants/units.dart'; @@ -131,6 +132,9 @@ class DiverSettingsRepository { ), reminderTime: Value(_formatReminderTime(s.reminderTime)), showDataSourceBadges: Value(s.showDataSourceBadges), + diveDetailSections: Value( + DiveDetailSectionConfig.sectionsToJson(s.diveDetailSections), + ), createdAt: Value(now), updatedAt: Value(now), ), @@ -250,6 +254,9 @@ class DiverSettingsRepository { ), reminderTime: Value(_formatReminderTime(settings.reminderTime)), showDataSourceBadges: Value(settings.showDataSourceBadges), + diveDetailSections: Value( + DiveDetailSectionConfig.sectionsToJson(settings.diveDetailSections), + ), updatedAt: Value(now), ), ); @@ -392,6 +399,9 @@ class DiverSettingsRepository { serviceReminderDays: _parseReminderDays(row.serviceReminderDays), reminderTime: _parseReminderTime(row.reminderTime), showDataSourceBadges: row.showDataSourceBadges, + diveDetailSections: DiveDetailSectionConfig.sectionsFromJson( + row.diveDetailSections, + ), ); } diff --git a/lib/features/settings/presentation/pages/appearance_page.dart b/lib/features/settings/presentation/pages/appearance_page.dart index 280fdeb66..f2a52a177 100644 --- a/lib/features/settings/presentation/pages/appearance_page.dart +++ b/lib/features/settings/presentation/pages/appearance_page.dart @@ -356,6 +356,26 @@ class AppearancePage extends ConsumerWidget { trailing: const Icon(Icons.chevron_right), onTap: () => context.push('/settings/default-metrics'), ), + const Divider(), + _buildSectionHeader( + context, + context.l10n.settings_appearance_header_diveDetails, + ), + ListTile( + leading: const Icon(Icons.reorder), + title: Text( + context + .l10n + .settings_appearance_diveDetails_sectionOrderVisibility, + ), + subtitle: Text( + context + .l10n + .settings_appearance_diveDetails_sectionOrderVisibility_subtitle, + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.push('/settings/dive-detail-sections'), + ), const SizedBox(height: 8), _buildSectionHeader( context, diff --git a/lib/features/settings/presentation/pages/dive_detail_sections_page.dart b/lib/features/settings/presentation/pages/dive_detail_sections_page.dart new file mode 100644 index 000000000..716eed682 --- /dev/null +++ b/lib/features/settings/presentation/pages/dive_detail_sections_page.dart @@ -0,0 +1,150 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:submersion/core/constants/dive_detail_sections.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +class DiveDetailSectionsPage extends ConsumerWidget { + const DiveDetailSectionsPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final sections = ref.watch( + settingsProvider.select((s) => s.diveDetailSections), + ); + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar( + title: Text(context.l10n.settings_diveDetailSections_title), + actions: [ + PopupMenuButton( + onSelected: (value) { + if (value == 'reset') { + ref.read(settingsProvider.notifier).resetDiveDetailSections(); + } + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: 'reset', + child: Text( + context.l10n.settings_diveDetailSections_resetToDefault, + ), + ), + ], + ), + ], + ), + body: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), + child: Text( + context.l10n.settings_diveDetailSections_fixedSections, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Text( + context.l10n.settings_diveDetailSections_configurableSections, + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ), + const Divider(height: 1), + Expanded( + child: ReorderableListView.builder( + buildDefaultDragHandles: false, + itemCount: sections.length, + onReorder: (oldIndex, newIndex) { + _onReorder(ref, sections, oldIndex, newIndex); + }, + itemBuilder: (context, index) { + final section = sections[index]; + return _SectionTile( + key: ValueKey(section.id), + section: section, + index: index, + onToggle: (visible) { + _onToggle(ref, sections, index, visible); + }, + ); + }, + ), + ), + ], + ), + ); + } + + void _onReorder( + WidgetRef ref, + List sections, + int oldIndex, + int newIndex, + ) { + if (newIndex > oldIndex) newIndex--; + final updated = List.of(sections); + final item = updated.removeAt(oldIndex); + updated.insert(newIndex, item); + ref.read(settingsProvider.notifier).setDiveDetailSections(updated); + } + + void _onToggle( + WidgetRef ref, + List sections, + int index, + bool visible, + ) { + final updated = List.of(sections); + updated[index] = updated[index].copyWith(visible: visible); + ref.read(settingsProvider.notifier).setDiveDetailSections(updated); + } +} + +class _SectionTile extends StatelessWidget { + const _SectionTile({ + super.key, + required this.section, + required this.index, + required this.onToggle, + }); + + final DiveDetailSectionConfig section; + final int index; + final ValueChanged onToggle; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: section.visible ? 1.0 : 0.5, + child: ListTile( + leading: ReorderableDragStartListener( + index: index, + child: const Icon(Icons.drag_handle), + ), + title: Text( + section.id.localizedDisplayName(context.l10n), + style: theme.textTheme.bodyLarge, + ), + subtitle: Text( + section.id.localizedDescription(context.l10n), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + trailing: Switch(value: section.visible, onChanged: onToggle), + ), + ); + } +} diff --git a/lib/features/settings/presentation/pages/settings_page.dart b/lib/features/settings/presentation/pages/settings_page.dart index 527bbda40..eab7fcd14 100644 --- a/lib/features/settings/presentation/pages/settings_page.dart +++ b/lib/features/settings/presentation/pages/settings_page.dart @@ -1471,6 +1471,29 @@ class _AppearanceSectionContentState ), ), const SizedBox(height: 24), + _buildSectionHeader( + context, + context.l10n.settings_appearance_header_diveDetails, + ), + const SizedBox(height: 8), + Card( + child: ListTile( + leading: const Icon(Icons.reorder), + title: Text( + context + .l10n + .settings_appearance_diveDetails_sectionOrderVisibility, + ), + subtitle: Text( + context + .l10n + .settings_appearance_diveDetails_sectionOrderVisibility_subtitle, + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.push('/settings/dive-detail-sections'), + ), + ), + const SizedBox(height: 24), _buildSectionHeader( context, context.l10n.settings_appearance_header_diveSites, diff --git a/lib/features/settings/presentation/providers/settings_providers.dart b/lib/features/settings/presentation/providers/settings_providers.dart index d3fc114e6..f8eebcedb 100644 --- a/lib/features/settings/presentation/providers/settings_providers.dart +++ b/lib/features/settings/presentation/providers/settings_providers.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:submersion/core/constants/card_color.dart'; +import 'package:submersion/core/constants/dive_detail_sections.dart'; import 'package:submersion/core/constants/list_view_mode.dart'; import 'package:submersion/core/providers/provider.dart'; import 'package:submersion/core/theme/app_theme_preset.dart'; @@ -246,6 +247,9 @@ class AppSettings { /// Show field-level data source attribution badges on dive details final bool showDataSourceBadges; + /// Ordered list of dive detail section visibility preferences + final List diveDetailSections; + const AppSettings({ this.depthUnit = DepthUnit.meters, this.temperatureUnit = TemperatureUnit.celsius, @@ -324,6 +328,7 @@ class AppSettings { this.serviceReminderDays = const [7, 14, 30], this.reminderTime = const TimeOfDay(hour: 9, minute: 0), this.showDataSourceBadges = true, + this.diveDetailSections = DiveDetailSectionConfig.defaultSections, }); /// Compute the current unit preset based on actual unit values @@ -435,6 +440,8 @@ class AppSettings { List? serviceReminderDays, TimeOfDay? reminderTime, bool? showDataSourceBadges, + List? diveDetailSections, + bool clearDiveDetailSections = false, }) { return AppSettings( depthUnit: depthUnit ?? this.depthUnit, @@ -526,6 +533,9 @@ class AppSettings { serviceReminderDays: serviceReminderDays ?? this.serviceReminderDays, reminderTime: reminderTime ?? this.reminderTime, showDataSourceBadges: showDataSourceBadges ?? this.showDataSourceBadges, + diveDetailSections: clearDiveDetailSections + ? DiveDetailSectionConfig.defaultSections + : (diveDetailSections ?? this.diveDetailSections), ); } } @@ -1039,6 +1049,18 @@ class SettingsNotifier extends StateNotifier { await _saveSettings(); } + Future setDiveDetailSections( + List sections, + ) async { + state = state.copyWith(diveDetailSections: sections); + await _saveSettings(); + } + + Future resetDiveDetailSections() async { + state = state.copyWith(clearDiveDetailSections: true); + await _saveSettings(); + } + Future toggleReminderDay(int days) async { final current = List.from(state.serviceReminderDays); if (current.contains(days)) { diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 42f545c72..0dd5ce83c 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -5224,9 +5224,50 @@ "settings_appearance_colorGradient_custom": "Custom", "settings_appearance_gasSwitchMarkers": "Gas switch markers", "settings_appearance_gasSwitchMarkers_subtitle": "Show markers for gas switches", + "settings_appearance_header_diveDetails": "Dive Details", "settings_appearance_header_diveLog": "Dive Log", "settings_appearance_header_diveProfile": "Dive Profile", "settings_appearance_header_diveSites": "Dive Sites", + "settings_appearance_diveDetails_sectionOrderVisibility": "Section Order & Visibility", + "settings_appearance_diveDetails_sectionOrderVisibility_subtitle": "Choose which sections appear and their order", + "settings_diveDetailSections_title": "Section Order & Visibility", + "settings_diveDetailSections_resetToDefault": "Reset to Default", + "settings_diveDetailSections_fixedSections": "Fixed sections: Header, Dive Profile Chart", + "settings_diveDetailSections_configurableSections": "Configurable sections (drag to reorder)", + "diveDetailSection_decoO2_name": "Deco Status / Tissue Loading", + "diveDetailSection_decoO2_description": "NDL, ceiling, tissue heat map, O2 toxicity", + "diveDetailSection_sacSegments_name": "SAC Rate by Segment", + "diveDetailSection_sacSegments_description": "Phase/time segmentation, cylinder breakdown", + "diveDetailSection_details_name": "Details", + "diveDetailSection_details_description": "Type, location, trip, dive center, interval", + "diveDetailSection_environment_name": "Environment", + "diveDetailSection_environment_description": "Air/water temp, visibility, current", + "diveDetailSection_altitude_name": "Altitude", + "diveDetailSection_altitude_description": "Altitude value, category, deco requirement", + "diveDetailSection_tide_name": "Tide", + "diveDetailSection_tide_description": "Tide cycle graph and timing", + "diveDetailSection_weights_name": "Weights", + "diveDetailSection_weights_description": "Weight breakdown, total weight", + "diveDetailSection_tanks_name": "Tanks", + "diveDetailSection_tanks_description": "Tank list, gas mixes, pressures, per-tank SAC", + "diveDetailSection_buddies_name": "Buddies", + "diveDetailSection_buddies_description": "Buddy list with roles", + "diveDetailSection_signatures_name": "Signatures", + "diveDetailSection_signatures_description": "Buddy/instructor signature display and capture", + "diveDetailSection_equipment_name": "Equipment", + "diveDetailSection_equipment_description": "Equipment used in dive", + "diveDetailSection_sightings_name": "Marine Life Sightings", + "diveDetailSection_sightings_description": "Species spotted, sighting details", + "diveDetailSection_media_name": "Media", + "diveDetailSection_media_description": "Photos/videos gallery", + "diveDetailSection_tags_name": "Tags", + "diveDetailSection_tags_description": "Dive tags", + "diveDetailSection_notes_name": "Notes", + "diveDetailSection_notes_description": "Dive notes/description", + "diveDetailSection_customFields_name": "Custom Fields", + "diveDetailSection_customFields_description": "User-defined custom fields", + "diveDetailSection_dataSources_name": "Data Sources", + "diveDetailSection_dataSources_description": "Connected dive computers, source management", "settings_appearance_header_language": "Language", "settings_appearance_header_theme": "Color Theme", "settings_appearance_header_mode": "Mode", diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index 6080a36ba..698db8c6a 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -16436,6 +16436,12 @@ abstract class AppLocalizations { /// **'Show markers for gas switches'** String get settings_appearance_gasSwitchMarkers_subtitle; + /// No description provided for @settings_appearance_header_diveDetails. + /// + /// In en, this message translates to: + /// **'Dive Details'** + String get settings_appearance_header_diveDetails; + /// No description provided for @settings_appearance_header_diveLog. /// /// In en, this message translates to: @@ -16454,6 +16460,246 @@ abstract class AppLocalizations { /// **'Dive Sites'** String get settings_appearance_header_diveSites; + /// No description provided for @settings_appearance_diveDetails_sectionOrderVisibility. + /// + /// In en, this message translates to: + /// **'Section Order & Visibility'** + String get settings_appearance_diveDetails_sectionOrderVisibility; + + /// No description provided for @settings_appearance_diveDetails_sectionOrderVisibility_subtitle. + /// + /// In en, this message translates to: + /// **'Choose which sections appear and their order'** + String get settings_appearance_diveDetails_sectionOrderVisibility_subtitle; + + /// No description provided for @settings_diveDetailSections_title. + /// + /// In en, this message translates to: + /// **'Section Order & Visibility'** + String get settings_diveDetailSections_title; + + /// No description provided for @settings_diveDetailSections_resetToDefault. + /// + /// In en, this message translates to: + /// **'Reset to Default'** + String get settings_diveDetailSections_resetToDefault; + + /// No description provided for @settings_diveDetailSections_fixedSections. + /// + /// In en, this message translates to: + /// **'Fixed sections: Header, Dive Profile Chart'** + String get settings_diveDetailSections_fixedSections; + + /// No description provided for @settings_diveDetailSections_configurableSections. + /// + /// In en, this message translates to: + /// **'Configurable sections (drag to reorder)'** + String get settings_diveDetailSections_configurableSections; + + /// No description provided for @diveDetailSection_decoO2_name. + /// + /// In en, this message translates to: + /// **'Deco Status / Tissue Loading'** + String get diveDetailSection_decoO2_name; + + /// No description provided for @diveDetailSection_decoO2_description. + /// + /// In en, this message translates to: + /// **'NDL, ceiling, tissue heat map, O2 toxicity'** + String get diveDetailSection_decoO2_description; + + /// No description provided for @diveDetailSection_sacSegments_name. + /// + /// In en, this message translates to: + /// **'SAC Rate by Segment'** + String get diveDetailSection_sacSegments_name; + + /// No description provided for @diveDetailSection_sacSegments_description. + /// + /// In en, this message translates to: + /// **'Phase/time segmentation, cylinder breakdown'** + String get diveDetailSection_sacSegments_description; + + /// No description provided for @diveDetailSection_details_name. + /// + /// In en, this message translates to: + /// **'Details'** + String get diveDetailSection_details_name; + + /// No description provided for @diveDetailSection_details_description. + /// + /// In en, this message translates to: + /// **'Type, location, trip, dive center, interval'** + String get diveDetailSection_details_description; + + /// No description provided for @diveDetailSection_environment_name. + /// + /// In en, this message translates to: + /// **'Environment'** + String get diveDetailSection_environment_name; + + /// No description provided for @diveDetailSection_environment_description. + /// + /// In en, this message translates to: + /// **'Air/water temp, visibility, current'** + String get diveDetailSection_environment_description; + + /// No description provided for @diveDetailSection_altitude_name. + /// + /// In en, this message translates to: + /// **'Altitude'** + String get diveDetailSection_altitude_name; + + /// No description provided for @diveDetailSection_altitude_description. + /// + /// In en, this message translates to: + /// **'Altitude value, category, deco requirement'** + String get diveDetailSection_altitude_description; + + /// No description provided for @diveDetailSection_tide_name. + /// + /// In en, this message translates to: + /// **'Tide'** + String get diveDetailSection_tide_name; + + /// No description provided for @diveDetailSection_tide_description. + /// + /// In en, this message translates to: + /// **'Tide cycle graph and timing'** + String get diveDetailSection_tide_description; + + /// No description provided for @diveDetailSection_weights_name. + /// + /// In en, this message translates to: + /// **'Weights'** + String get diveDetailSection_weights_name; + + /// No description provided for @diveDetailSection_weights_description. + /// + /// In en, this message translates to: + /// **'Weight breakdown, total weight'** + String get diveDetailSection_weights_description; + + /// No description provided for @diveDetailSection_tanks_name. + /// + /// In en, this message translates to: + /// **'Tanks'** + String get diveDetailSection_tanks_name; + + /// No description provided for @diveDetailSection_tanks_description. + /// + /// In en, this message translates to: + /// **'Tank list, gas mixes, pressures, per-tank SAC'** + String get diveDetailSection_tanks_description; + + /// No description provided for @diveDetailSection_buddies_name. + /// + /// In en, this message translates to: + /// **'Buddies'** + String get diveDetailSection_buddies_name; + + /// No description provided for @diveDetailSection_buddies_description. + /// + /// In en, this message translates to: + /// **'Buddy list with roles'** + String get diveDetailSection_buddies_description; + + /// No description provided for @diveDetailSection_signatures_name. + /// + /// In en, this message translates to: + /// **'Signatures'** + String get diveDetailSection_signatures_name; + + /// No description provided for @diveDetailSection_signatures_description. + /// + /// In en, this message translates to: + /// **'Buddy/instructor signature display and capture'** + String get diveDetailSection_signatures_description; + + /// No description provided for @diveDetailSection_equipment_name. + /// + /// In en, this message translates to: + /// **'Equipment'** + String get diveDetailSection_equipment_name; + + /// No description provided for @diveDetailSection_equipment_description. + /// + /// In en, this message translates to: + /// **'Equipment used in dive'** + String get diveDetailSection_equipment_description; + + /// No description provided for @diveDetailSection_sightings_name. + /// + /// In en, this message translates to: + /// **'Marine Life Sightings'** + String get diveDetailSection_sightings_name; + + /// No description provided for @diveDetailSection_sightings_description. + /// + /// In en, this message translates to: + /// **'Species spotted, sighting details'** + String get diveDetailSection_sightings_description; + + /// No description provided for @diveDetailSection_media_name. + /// + /// In en, this message translates to: + /// **'Media'** + String get diveDetailSection_media_name; + + /// No description provided for @diveDetailSection_media_description. + /// + /// In en, this message translates to: + /// **'Photos/videos gallery'** + String get diveDetailSection_media_description; + + /// No description provided for @diveDetailSection_tags_name. + /// + /// In en, this message translates to: + /// **'Tags'** + String get diveDetailSection_tags_name; + + /// No description provided for @diveDetailSection_tags_description. + /// + /// In en, this message translates to: + /// **'Dive tags'** + String get diveDetailSection_tags_description; + + /// No description provided for @diveDetailSection_notes_name. + /// + /// In en, this message translates to: + /// **'Notes'** + String get diveDetailSection_notes_name; + + /// No description provided for @diveDetailSection_notes_description. + /// + /// In en, this message translates to: + /// **'Dive notes/description'** + String get diveDetailSection_notes_description; + + /// No description provided for @diveDetailSection_customFields_name. + /// + /// In en, this message translates to: + /// **'Custom Fields'** + String get diveDetailSection_customFields_name; + + /// No description provided for @diveDetailSection_customFields_description. + /// + /// In en, this message translates to: + /// **'User-defined custom fields'** + String get diveDetailSection_customFields_description; + + /// No description provided for @diveDetailSection_dataSources_name. + /// + /// In en, this message translates to: + /// **'Data Sources'** + String get diveDetailSection_dataSources_name; + + /// No description provided for @diveDetailSection_dataSources_description. + /// + /// In en, this message translates to: + /// **'Connected dive computers, source management'** + String get diveDetailSection_dataSources_description; + /// No description provided for @settings_appearance_header_language. /// /// 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 52c05ed4b..2f362cc82 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -9399,6 +9399,9 @@ class AppLocalizationsAr extends AppLocalizations { String get settings_appearance_gasSwitchMarkers_subtitle => 'عرض علامات لتبديل الغازات'; + @override + String get settings_appearance_header_diveDetails => 'Dive Details'; + @override String get settings_appearance_header_diveLog => 'سجل الغوص'; @@ -9408,6 +9411,143 @@ class AppLocalizationsAr extends AppLocalizations { @override String get settings_appearance_header_diveSites => 'مواقع الغوص'; + @override + String get settings_appearance_diveDetails_sectionOrderVisibility => + 'Section Order & Visibility'; + + @override + String get settings_appearance_diveDetails_sectionOrderVisibility_subtitle => + 'Choose which sections appear and their order'; + + @override + String get settings_diveDetailSections_title => 'Section Order & Visibility'; + + @override + String get settings_diveDetailSections_resetToDefault => 'Reset to Default'; + + @override + String get settings_diveDetailSections_fixedSections => + 'Fixed sections: Header, Dive Profile Chart'; + + @override + String get settings_diveDetailSections_configurableSections => + 'Configurable sections (drag to reorder)'; + + @override + String get diveDetailSection_decoO2_name => 'Deco Status / Tissue Loading'; + + @override + String get diveDetailSection_decoO2_description => + 'NDL, ceiling, tissue heat map, O2 toxicity'; + + @override + String get diveDetailSection_sacSegments_name => 'SAC Rate by Segment'; + + @override + String get diveDetailSection_sacSegments_description => + 'Phase/time segmentation, cylinder breakdown'; + + @override + String get diveDetailSection_details_name => 'Details'; + + @override + String get diveDetailSection_details_description => + 'Type, location, trip, dive center, interval'; + + @override + String get diveDetailSection_environment_name => 'Environment'; + + @override + String get diveDetailSection_environment_description => + 'Air/water temp, visibility, current'; + + @override + String get diveDetailSection_altitude_name => 'Altitude'; + + @override + String get diveDetailSection_altitude_description => + 'Altitude value, category, deco requirement'; + + @override + String get diveDetailSection_tide_name => 'Tide'; + + @override + String get diveDetailSection_tide_description => + 'Tide cycle graph and timing'; + + @override + String get diveDetailSection_weights_name => 'Weights'; + + @override + String get diveDetailSection_weights_description => + 'Weight breakdown, total weight'; + + @override + String get diveDetailSection_tanks_name => 'Tanks'; + + @override + String get diveDetailSection_tanks_description => + 'Tank list, gas mixes, pressures, per-tank SAC'; + + @override + String get diveDetailSection_buddies_name => 'Buddies'; + + @override + String get diveDetailSection_buddies_description => 'Buddy list with roles'; + + @override + String get diveDetailSection_signatures_name => 'Signatures'; + + @override + String get diveDetailSection_signatures_description => + 'Buddy/instructor signature display and capture'; + + @override + String get diveDetailSection_equipment_name => 'Equipment'; + + @override + String get diveDetailSection_equipment_description => + 'Equipment used in dive'; + + @override + String get diveDetailSection_sightings_name => 'Marine Life Sightings'; + + @override + String get diveDetailSection_sightings_description => + 'Species spotted, sighting details'; + + @override + String get diveDetailSection_media_name => 'Media'; + + @override + String get diveDetailSection_media_description => 'Photos/videos gallery'; + + @override + String get diveDetailSection_tags_name => 'Tags'; + + @override + String get diveDetailSection_tags_description => 'Dive tags'; + + @override + String get diveDetailSection_notes_name => 'Notes'; + + @override + String get diveDetailSection_notes_description => 'Dive notes/description'; + + @override + String get diveDetailSection_customFields_name => 'Custom Fields'; + + @override + String get diveDetailSection_customFields_description => + 'User-defined custom fields'; + + @override + String get diveDetailSection_dataSources_name => 'Data Sources'; + + @override + String get diveDetailSection_dataSources_description => + 'Connected dive computers, source management'; + @override String get settings_appearance_header_language => 'اللغة'; diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index 8658ef1ff..a5bdeb1b3 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -9589,6 +9589,9 @@ class AppLocalizationsDe extends AppLocalizations { String get settings_appearance_gasSwitchMarkers_subtitle => 'Markierungen fuer Gaswechsel anzeigen'; + @override + String get settings_appearance_header_diveDetails => 'Dive Details'; + @override String get settings_appearance_header_diveLog => 'Tauchlogbuch'; @@ -9598,6 +9601,143 @@ class AppLocalizationsDe extends AppLocalizations { @override String get settings_appearance_header_diveSites => 'Tauchplaetze'; + @override + String get settings_appearance_diveDetails_sectionOrderVisibility => + 'Section Order & Visibility'; + + @override + String get settings_appearance_diveDetails_sectionOrderVisibility_subtitle => + 'Choose which sections appear and their order'; + + @override + String get settings_diveDetailSections_title => 'Section Order & Visibility'; + + @override + String get settings_diveDetailSections_resetToDefault => 'Reset to Default'; + + @override + String get settings_diveDetailSections_fixedSections => + 'Fixed sections: Header, Dive Profile Chart'; + + @override + String get settings_diveDetailSections_configurableSections => + 'Configurable sections (drag to reorder)'; + + @override + String get diveDetailSection_decoO2_name => 'Deco Status / Tissue Loading'; + + @override + String get diveDetailSection_decoO2_description => + 'NDL, ceiling, tissue heat map, O2 toxicity'; + + @override + String get diveDetailSection_sacSegments_name => 'SAC Rate by Segment'; + + @override + String get diveDetailSection_sacSegments_description => + 'Phase/time segmentation, cylinder breakdown'; + + @override + String get diveDetailSection_details_name => 'Details'; + + @override + String get diveDetailSection_details_description => + 'Type, location, trip, dive center, interval'; + + @override + String get diveDetailSection_environment_name => 'Environment'; + + @override + String get diveDetailSection_environment_description => + 'Air/water temp, visibility, current'; + + @override + String get diveDetailSection_altitude_name => 'Altitude'; + + @override + String get diveDetailSection_altitude_description => + 'Altitude value, category, deco requirement'; + + @override + String get diveDetailSection_tide_name => 'Tide'; + + @override + String get diveDetailSection_tide_description => + 'Tide cycle graph and timing'; + + @override + String get diveDetailSection_weights_name => 'Weights'; + + @override + String get diveDetailSection_weights_description => + 'Weight breakdown, total weight'; + + @override + String get diveDetailSection_tanks_name => 'Tanks'; + + @override + String get diveDetailSection_tanks_description => + 'Tank list, gas mixes, pressures, per-tank SAC'; + + @override + String get diveDetailSection_buddies_name => 'Buddies'; + + @override + String get diveDetailSection_buddies_description => 'Buddy list with roles'; + + @override + String get diveDetailSection_signatures_name => 'Signatures'; + + @override + String get diveDetailSection_signatures_description => + 'Buddy/instructor signature display and capture'; + + @override + String get diveDetailSection_equipment_name => 'Equipment'; + + @override + String get diveDetailSection_equipment_description => + 'Equipment used in dive'; + + @override + String get diveDetailSection_sightings_name => 'Marine Life Sightings'; + + @override + String get diveDetailSection_sightings_description => + 'Species spotted, sighting details'; + + @override + String get diveDetailSection_media_name => 'Media'; + + @override + String get diveDetailSection_media_description => 'Photos/videos gallery'; + + @override + String get diveDetailSection_tags_name => 'Tags'; + + @override + String get diveDetailSection_tags_description => 'Dive tags'; + + @override + String get diveDetailSection_notes_name => 'Notes'; + + @override + String get diveDetailSection_notes_description => 'Dive notes/description'; + + @override + String get diveDetailSection_customFields_name => 'Custom Fields'; + + @override + String get diveDetailSection_customFields_description => + 'User-defined custom fields'; + + @override + String get diveDetailSection_dataSources_name => 'Data Sources'; + + @override + String get diveDetailSection_dataSources_description => + 'Connected dive computers, source management'; + @override String get settings_appearance_header_language => 'Sprache'; diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index dda6daba3..c6390c7d5 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -9432,6 +9432,9 @@ class AppLocalizationsEn extends AppLocalizations { String get settings_appearance_gasSwitchMarkers_subtitle => 'Show markers for gas switches'; + @override + String get settings_appearance_header_diveDetails => 'Dive Details'; + @override String get settings_appearance_header_diveLog => 'Dive Log'; @@ -9441,6 +9444,143 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settings_appearance_header_diveSites => 'Dive Sites'; + @override + String get settings_appearance_diveDetails_sectionOrderVisibility => + 'Section Order & Visibility'; + + @override + String get settings_appearance_diveDetails_sectionOrderVisibility_subtitle => + 'Choose which sections appear and their order'; + + @override + String get settings_diveDetailSections_title => 'Section Order & Visibility'; + + @override + String get settings_diveDetailSections_resetToDefault => 'Reset to Default'; + + @override + String get settings_diveDetailSections_fixedSections => + 'Fixed sections: Header, Dive Profile Chart'; + + @override + String get settings_diveDetailSections_configurableSections => + 'Configurable sections (drag to reorder)'; + + @override + String get diveDetailSection_decoO2_name => 'Deco Status / Tissue Loading'; + + @override + String get diveDetailSection_decoO2_description => + 'NDL, ceiling, tissue heat map, O2 toxicity'; + + @override + String get diveDetailSection_sacSegments_name => 'SAC Rate by Segment'; + + @override + String get diveDetailSection_sacSegments_description => + 'Phase/time segmentation, cylinder breakdown'; + + @override + String get diveDetailSection_details_name => 'Details'; + + @override + String get diveDetailSection_details_description => + 'Type, location, trip, dive center, interval'; + + @override + String get diveDetailSection_environment_name => 'Environment'; + + @override + String get diveDetailSection_environment_description => + 'Air/water temp, visibility, current'; + + @override + String get diveDetailSection_altitude_name => 'Altitude'; + + @override + String get diveDetailSection_altitude_description => + 'Altitude value, category, deco requirement'; + + @override + String get diveDetailSection_tide_name => 'Tide'; + + @override + String get diveDetailSection_tide_description => + 'Tide cycle graph and timing'; + + @override + String get diveDetailSection_weights_name => 'Weights'; + + @override + String get diveDetailSection_weights_description => + 'Weight breakdown, total weight'; + + @override + String get diveDetailSection_tanks_name => 'Tanks'; + + @override + String get diveDetailSection_tanks_description => + 'Tank list, gas mixes, pressures, per-tank SAC'; + + @override + String get diveDetailSection_buddies_name => 'Buddies'; + + @override + String get diveDetailSection_buddies_description => 'Buddy list with roles'; + + @override + String get diveDetailSection_signatures_name => 'Signatures'; + + @override + String get diveDetailSection_signatures_description => + 'Buddy/instructor signature display and capture'; + + @override + String get diveDetailSection_equipment_name => 'Equipment'; + + @override + String get diveDetailSection_equipment_description => + 'Equipment used in dive'; + + @override + String get diveDetailSection_sightings_name => 'Marine Life Sightings'; + + @override + String get diveDetailSection_sightings_description => + 'Species spotted, sighting details'; + + @override + String get diveDetailSection_media_name => 'Media'; + + @override + String get diveDetailSection_media_description => 'Photos/videos gallery'; + + @override + String get diveDetailSection_tags_name => 'Tags'; + + @override + String get diveDetailSection_tags_description => 'Dive tags'; + + @override + String get diveDetailSection_notes_name => 'Notes'; + + @override + String get diveDetailSection_notes_description => 'Dive notes/description'; + + @override + String get diveDetailSection_customFields_name => 'Custom Fields'; + + @override + String get diveDetailSection_customFields_description => + 'User-defined custom fields'; + + @override + String get diveDetailSection_dataSources_name => 'Data Sources'; + + @override + String get diveDetailSection_dataSources_description => + 'Connected dive computers, source management'; + @override String get settings_appearance_header_language => 'Language'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index c740e48c1..52c0d57db 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -9575,6 +9575,9 @@ class AppLocalizationsEs extends AppLocalizations { String get settings_appearance_gasSwitchMarkers_subtitle => 'Mostrar marcadores para cambios de gas'; + @override + String get settings_appearance_header_diveDetails => 'Dive Details'; + @override String get settings_appearance_header_diveLog => 'Registro de buceo'; @@ -9584,6 +9587,143 @@ class AppLocalizationsEs extends AppLocalizations { @override String get settings_appearance_header_diveSites => 'Puntos de buceo'; + @override + String get settings_appearance_diveDetails_sectionOrderVisibility => + 'Section Order & Visibility'; + + @override + String get settings_appearance_diveDetails_sectionOrderVisibility_subtitle => + 'Choose which sections appear and their order'; + + @override + String get settings_diveDetailSections_title => 'Section Order & Visibility'; + + @override + String get settings_diveDetailSections_resetToDefault => 'Reset to Default'; + + @override + String get settings_diveDetailSections_fixedSections => + 'Fixed sections: Header, Dive Profile Chart'; + + @override + String get settings_diveDetailSections_configurableSections => + 'Configurable sections (drag to reorder)'; + + @override + String get diveDetailSection_decoO2_name => 'Deco Status / Tissue Loading'; + + @override + String get diveDetailSection_decoO2_description => + 'NDL, ceiling, tissue heat map, O2 toxicity'; + + @override + String get diveDetailSection_sacSegments_name => 'SAC Rate by Segment'; + + @override + String get diveDetailSection_sacSegments_description => + 'Phase/time segmentation, cylinder breakdown'; + + @override + String get diveDetailSection_details_name => 'Details'; + + @override + String get diveDetailSection_details_description => + 'Type, location, trip, dive center, interval'; + + @override + String get diveDetailSection_environment_name => 'Environment'; + + @override + String get diveDetailSection_environment_description => + 'Air/water temp, visibility, current'; + + @override + String get diveDetailSection_altitude_name => 'Altitude'; + + @override + String get diveDetailSection_altitude_description => + 'Altitude value, category, deco requirement'; + + @override + String get diveDetailSection_tide_name => 'Tide'; + + @override + String get diveDetailSection_tide_description => + 'Tide cycle graph and timing'; + + @override + String get diveDetailSection_weights_name => 'Weights'; + + @override + String get diveDetailSection_weights_description => + 'Weight breakdown, total weight'; + + @override + String get diveDetailSection_tanks_name => 'Tanks'; + + @override + String get diveDetailSection_tanks_description => + 'Tank list, gas mixes, pressures, per-tank SAC'; + + @override + String get diveDetailSection_buddies_name => 'Buddies'; + + @override + String get diveDetailSection_buddies_description => 'Buddy list with roles'; + + @override + String get diveDetailSection_signatures_name => 'Signatures'; + + @override + String get diveDetailSection_signatures_description => + 'Buddy/instructor signature display and capture'; + + @override + String get diveDetailSection_equipment_name => 'Equipment'; + + @override + String get diveDetailSection_equipment_description => + 'Equipment used in dive'; + + @override + String get diveDetailSection_sightings_name => 'Marine Life Sightings'; + + @override + String get diveDetailSection_sightings_description => + 'Species spotted, sighting details'; + + @override + String get diveDetailSection_media_name => 'Media'; + + @override + String get diveDetailSection_media_description => 'Photos/videos gallery'; + + @override + String get diveDetailSection_tags_name => 'Tags'; + + @override + String get diveDetailSection_tags_description => 'Dive tags'; + + @override + String get diveDetailSection_notes_name => 'Notes'; + + @override + String get diveDetailSection_notes_description => 'Dive notes/description'; + + @override + String get diveDetailSection_customFields_name => 'Custom Fields'; + + @override + String get diveDetailSection_customFields_description => + 'User-defined custom fields'; + + @override + String get diveDetailSection_dataSources_name => 'Data Sources'; + + @override + String get diveDetailSection_dataSources_description => + 'Connected dive computers, source management'; + @override String get settings_appearance_header_language => 'Idioma'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index 6c68c06fb..b6b7c17b1 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -9620,6 +9620,9 @@ class AppLocalizationsFr extends AppLocalizations { String get settings_appearance_gasSwitchMarkers_subtitle => 'Afficher les marqueurs de changement de gaz'; + @override + String get settings_appearance_header_diveDetails => 'Dive Details'; + @override String get settings_appearance_header_diveLog => 'Carnet de plongee'; @@ -9629,6 +9632,143 @@ class AppLocalizationsFr extends AppLocalizations { @override String get settings_appearance_header_diveSites => 'Sites de plongee'; + @override + String get settings_appearance_diveDetails_sectionOrderVisibility => + 'Section Order & Visibility'; + + @override + String get settings_appearance_diveDetails_sectionOrderVisibility_subtitle => + 'Choose which sections appear and their order'; + + @override + String get settings_diveDetailSections_title => 'Section Order & Visibility'; + + @override + String get settings_diveDetailSections_resetToDefault => 'Reset to Default'; + + @override + String get settings_diveDetailSections_fixedSections => + 'Fixed sections: Header, Dive Profile Chart'; + + @override + String get settings_diveDetailSections_configurableSections => + 'Configurable sections (drag to reorder)'; + + @override + String get diveDetailSection_decoO2_name => 'Deco Status / Tissue Loading'; + + @override + String get diveDetailSection_decoO2_description => + 'NDL, ceiling, tissue heat map, O2 toxicity'; + + @override + String get diveDetailSection_sacSegments_name => 'SAC Rate by Segment'; + + @override + String get diveDetailSection_sacSegments_description => + 'Phase/time segmentation, cylinder breakdown'; + + @override + String get diveDetailSection_details_name => 'Details'; + + @override + String get diveDetailSection_details_description => + 'Type, location, trip, dive center, interval'; + + @override + String get diveDetailSection_environment_name => 'Environment'; + + @override + String get diveDetailSection_environment_description => + 'Air/water temp, visibility, current'; + + @override + String get diveDetailSection_altitude_name => 'Altitude'; + + @override + String get diveDetailSection_altitude_description => + 'Altitude value, category, deco requirement'; + + @override + String get diveDetailSection_tide_name => 'Tide'; + + @override + String get diveDetailSection_tide_description => + 'Tide cycle graph and timing'; + + @override + String get diveDetailSection_weights_name => 'Weights'; + + @override + String get diveDetailSection_weights_description => + 'Weight breakdown, total weight'; + + @override + String get diveDetailSection_tanks_name => 'Tanks'; + + @override + String get diveDetailSection_tanks_description => + 'Tank list, gas mixes, pressures, per-tank SAC'; + + @override + String get diveDetailSection_buddies_name => 'Buddies'; + + @override + String get diveDetailSection_buddies_description => 'Buddy list with roles'; + + @override + String get diveDetailSection_signatures_name => 'Signatures'; + + @override + String get diveDetailSection_signatures_description => + 'Buddy/instructor signature display and capture'; + + @override + String get diveDetailSection_equipment_name => 'Equipment'; + + @override + String get diveDetailSection_equipment_description => + 'Equipment used in dive'; + + @override + String get diveDetailSection_sightings_name => 'Marine Life Sightings'; + + @override + String get diveDetailSection_sightings_description => + 'Species spotted, sighting details'; + + @override + String get diveDetailSection_media_name => 'Media'; + + @override + String get diveDetailSection_media_description => 'Photos/videos gallery'; + + @override + String get diveDetailSection_tags_name => 'Tags'; + + @override + String get diveDetailSection_tags_description => 'Dive tags'; + + @override + String get diveDetailSection_notes_name => 'Notes'; + + @override + String get diveDetailSection_notes_description => 'Dive notes/description'; + + @override + String get diveDetailSection_customFields_name => 'Custom Fields'; + + @override + String get diveDetailSection_customFields_description => + 'User-defined custom fields'; + + @override + String get diveDetailSection_dataSources_name => 'Data Sources'; + + @override + String get diveDetailSection_dataSources_description => + 'Connected dive computers, source management'; + @override String get settings_appearance_header_language => 'Langue'; diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index 4e64fff66..6ded09d8e 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -9333,6 +9333,9 @@ class AppLocalizationsHe extends AppLocalizations { String get settings_appearance_gasSwitchMarkers_subtitle => 'הצג סמנים להחלפות גז'; + @override + String get settings_appearance_header_diveDetails => 'Dive Details'; + @override String get settings_appearance_header_diveLog => 'יומן צלילות'; @@ -9342,6 +9345,143 @@ class AppLocalizationsHe extends AppLocalizations { @override String get settings_appearance_header_diveSites => 'אתרי צלילה'; + @override + String get settings_appearance_diveDetails_sectionOrderVisibility => + 'Section Order & Visibility'; + + @override + String get settings_appearance_diveDetails_sectionOrderVisibility_subtitle => + 'Choose which sections appear and their order'; + + @override + String get settings_diveDetailSections_title => 'Section Order & Visibility'; + + @override + String get settings_diveDetailSections_resetToDefault => 'Reset to Default'; + + @override + String get settings_diveDetailSections_fixedSections => + 'Fixed sections: Header, Dive Profile Chart'; + + @override + String get settings_diveDetailSections_configurableSections => + 'Configurable sections (drag to reorder)'; + + @override + String get diveDetailSection_decoO2_name => 'Deco Status / Tissue Loading'; + + @override + String get diveDetailSection_decoO2_description => + 'NDL, ceiling, tissue heat map, O2 toxicity'; + + @override + String get diveDetailSection_sacSegments_name => 'SAC Rate by Segment'; + + @override + String get diveDetailSection_sacSegments_description => + 'Phase/time segmentation, cylinder breakdown'; + + @override + String get diveDetailSection_details_name => 'Details'; + + @override + String get diveDetailSection_details_description => + 'Type, location, trip, dive center, interval'; + + @override + String get diveDetailSection_environment_name => 'Environment'; + + @override + String get diveDetailSection_environment_description => + 'Air/water temp, visibility, current'; + + @override + String get diveDetailSection_altitude_name => 'Altitude'; + + @override + String get diveDetailSection_altitude_description => + 'Altitude value, category, deco requirement'; + + @override + String get diveDetailSection_tide_name => 'Tide'; + + @override + String get diveDetailSection_tide_description => + 'Tide cycle graph and timing'; + + @override + String get diveDetailSection_weights_name => 'Weights'; + + @override + String get diveDetailSection_weights_description => + 'Weight breakdown, total weight'; + + @override + String get diveDetailSection_tanks_name => 'Tanks'; + + @override + String get diveDetailSection_tanks_description => + 'Tank list, gas mixes, pressures, per-tank SAC'; + + @override + String get diveDetailSection_buddies_name => 'Buddies'; + + @override + String get diveDetailSection_buddies_description => 'Buddy list with roles'; + + @override + String get diveDetailSection_signatures_name => 'Signatures'; + + @override + String get diveDetailSection_signatures_description => + 'Buddy/instructor signature display and capture'; + + @override + String get diveDetailSection_equipment_name => 'Equipment'; + + @override + String get diveDetailSection_equipment_description => + 'Equipment used in dive'; + + @override + String get diveDetailSection_sightings_name => 'Marine Life Sightings'; + + @override + String get diveDetailSection_sightings_description => + 'Species spotted, sighting details'; + + @override + String get diveDetailSection_media_name => 'Media'; + + @override + String get diveDetailSection_media_description => 'Photos/videos gallery'; + + @override + String get diveDetailSection_tags_name => 'Tags'; + + @override + String get diveDetailSection_tags_description => 'Dive tags'; + + @override + String get diveDetailSection_notes_name => 'Notes'; + + @override + String get diveDetailSection_notes_description => 'Dive notes/description'; + + @override + String get diveDetailSection_customFields_name => 'Custom Fields'; + + @override + String get diveDetailSection_customFields_description => + 'User-defined custom fields'; + + @override + String get diveDetailSection_dataSources_name => 'Data Sources'; + + @override + String get diveDetailSection_dataSources_description => + 'Connected dive computers, source management'; + @override String get settings_appearance_header_language => 'שפה'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index acbd31926..307023535 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -9555,6 +9555,9 @@ class AppLocalizationsHu extends AppLocalizations { String get settings_appearance_gasSwitchMarkers_subtitle => 'Gazvaltas jelolok megjelenites'; + @override + String get settings_appearance_header_diveDetails => 'Dive Details'; + @override String get settings_appearance_header_diveLog => 'Merülesi naplo'; @@ -9564,6 +9567,143 @@ class AppLocalizationsHu extends AppLocalizations { @override String get settings_appearance_header_diveSites => 'Merülohelyek'; + @override + String get settings_appearance_diveDetails_sectionOrderVisibility => + 'Section Order & Visibility'; + + @override + String get settings_appearance_diveDetails_sectionOrderVisibility_subtitle => + 'Choose which sections appear and their order'; + + @override + String get settings_diveDetailSections_title => 'Section Order & Visibility'; + + @override + String get settings_diveDetailSections_resetToDefault => 'Reset to Default'; + + @override + String get settings_diveDetailSections_fixedSections => + 'Fixed sections: Header, Dive Profile Chart'; + + @override + String get settings_diveDetailSections_configurableSections => + 'Configurable sections (drag to reorder)'; + + @override + String get diveDetailSection_decoO2_name => 'Deco Status / Tissue Loading'; + + @override + String get diveDetailSection_decoO2_description => + 'NDL, ceiling, tissue heat map, O2 toxicity'; + + @override + String get diveDetailSection_sacSegments_name => 'SAC Rate by Segment'; + + @override + String get diveDetailSection_sacSegments_description => + 'Phase/time segmentation, cylinder breakdown'; + + @override + String get diveDetailSection_details_name => 'Details'; + + @override + String get diveDetailSection_details_description => + 'Type, location, trip, dive center, interval'; + + @override + String get diveDetailSection_environment_name => 'Environment'; + + @override + String get diveDetailSection_environment_description => + 'Air/water temp, visibility, current'; + + @override + String get diveDetailSection_altitude_name => 'Altitude'; + + @override + String get diveDetailSection_altitude_description => + 'Altitude value, category, deco requirement'; + + @override + String get diveDetailSection_tide_name => 'Tide'; + + @override + String get diveDetailSection_tide_description => + 'Tide cycle graph and timing'; + + @override + String get diveDetailSection_weights_name => 'Weights'; + + @override + String get diveDetailSection_weights_description => + 'Weight breakdown, total weight'; + + @override + String get diveDetailSection_tanks_name => 'Tanks'; + + @override + String get diveDetailSection_tanks_description => + 'Tank list, gas mixes, pressures, per-tank SAC'; + + @override + String get diveDetailSection_buddies_name => 'Buddies'; + + @override + String get diveDetailSection_buddies_description => 'Buddy list with roles'; + + @override + String get diveDetailSection_signatures_name => 'Signatures'; + + @override + String get diveDetailSection_signatures_description => + 'Buddy/instructor signature display and capture'; + + @override + String get diveDetailSection_equipment_name => 'Equipment'; + + @override + String get diveDetailSection_equipment_description => + 'Equipment used in dive'; + + @override + String get diveDetailSection_sightings_name => 'Marine Life Sightings'; + + @override + String get diveDetailSection_sightings_description => + 'Species spotted, sighting details'; + + @override + String get diveDetailSection_media_name => 'Media'; + + @override + String get diveDetailSection_media_description => 'Photos/videos gallery'; + + @override + String get diveDetailSection_tags_name => 'Tags'; + + @override + String get diveDetailSection_tags_description => 'Dive tags'; + + @override + String get diveDetailSection_notes_name => 'Notes'; + + @override + String get diveDetailSection_notes_description => 'Dive notes/description'; + + @override + String get diveDetailSection_customFields_name => 'Custom Fields'; + + @override + String get diveDetailSection_customFields_description => + 'User-defined custom fields'; + + @override + String get diveDetailSection_dataSources_name => 'Data Sources'; + + @override + String get diveDetailSection_dataSources_description => + 'Connected dive computers, source management'; + @override String get settings_appearance_header_language => 'Nyelv'; diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index 43fda0d39..b20473dcc 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -9591,6 +9591,9 @@ class AppLocalizationsIt extends AppLocalizations { String get settings_appearance_gasSwitchMarkers_subtitle => 'Mostra i marcatori per i cambi gas'; + @override + String get settings_appearance_header_diveDetails => 'Dive Details'; + @override String get settings_appearance_header_diveLog => 'Registro immersioni'; @@ -9600,6 +9603,143 @@ class AppLocalizationsIt extends AppLocalizations { @override String get settings_appearance_header_diveSites => 'Siti di immersione'; + @override + String get settings_appearance_diveDetails_sectionOrderVisibility => + 'Section Order & Visibility'; + + @override + String get settings_appearance_diveDetails_sectionOrderVisibility_subtitle => + 'Choose which sections appear and their order'; + + @override + String get settings_diveDetailSections_title => 'Section Order & Visibility'; + + @override + String get settings_diveDetailSections_resetToDefault => 'Reset to Default'; + + @override + String get settings_diveDetailSections_fixedSections => + 'Fixed sections: Header, Dive Profile Chart'; + + @override + String get settings_diveDetailSections_configurableSections => + 'Configurable sections (drag to reorder)'; + + @override + String get diveDetailSection_decoO2_name => 'Deco Status / Tissue Loading'; + + @override + String get diveDetailSection_decoO2_description => + 'NDL, ceiling, tissue heat map, O2 toxicity'; + + @override + String get diveDetailSection_sacSegments_name => 'SAC Rate by Segment'; + + @override + String get diveDetailSection_sacSegments_description => + 'Phase/time segmentation, cylinder breakdown'; + + @override + String get diveDetailSection_details_name => 'Details'; + + @override + String get diveDetailSection_details_description => + 'Type, location, trip, dive center, interval'; + + @override + String get diveDetailSection_environment_name => 'Environment'; + + @override + String get diveDetailSection_environment_description => + 'Air/water temp, visibility, current'; + + @override + String get diveDetailSection_altitude_name => 'Altitude'; + + @override + String get diveDetailSection_altitude_description => + 'Altitude value, category, deco requirement'; + + @override + String get diveDetailSection_tide_name => 'Tide'; + + @override + String get diveDetailSection_tide_description => + 'Tide cycle graph and timing'; + + @override + String get diveDetailSection_weights_name => 'Weights'; + + @override + String get diveDetailSection_weights_description => + 'Weight breakdown, total weight'; + + @override + String get diveDetailSection_tanks_name => 'Tanks'; + + @override + String get diveDetailSection_tanks_description => + 'Tank list, gas mixes, pressures, per-tank SAC'; + + @override + String get diveDetailSection_buddies_name => 'Buddies'; + + @override + String get diveDetailSection_buddies_description => 'Buddy list with roles'; + + @override + String get diveDetailSection_signatures_name => 'Signatures'; + + @override + String get diveDetailSection_signatures_description => + 'Buddy/instructor signature display and capture'; + + @override + String get diveDetailSection_equipment_name => 'Equipment'; + + @override + String get diveDetailSection_equipment_description => + 'Equipment used in dive'; + + @override + String get diveDetailSection_sightings_name => 'Marine Life Sightings'; + + @override + String get diveDetailSection_sightings_description => + 'Species spotted, sighting details'; + + @override + String get diveDetailSection_media_name => 'Media'; + + @override + String get diveDetailSection_media_description => 'Photos/videos gallery'; + + @override + String get diveDetailSection_tags_name => 'Tags'; + + @override + String get diveDetailSection_tags_description => 'Dive tags'; + + @override + String get diveDetailSection_notes_name => 'Notes'; + + @override + String get diveDetailSection_notes_description => 'Dive notes/description'; + + @override + String get diveDetailSection_customFields_name => 'Custom Fields'; + + @override + String get diveDetailSection_customFields_description => + 'User-defined custom fields'; + + @override + String get diveDetailSection_dataSources_name => 'Data Sources'; + + @override + String get diveDetailSection_dataSources_description => + 'Connected dive computers, source management'; + @override String get settings_appearance_header_language => 'Lingua'; diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index 624013eaf..5b368e33a 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -9515,6 +9515,9 @@ class AppLocalizationsNl extends AppLocalizations { String get settings_appearance_gasSwitchMarkers_subtitle => 'Toon markeringen voor gaswisselingen'; + @override + String get settings_appearance_header_diveDetails => 'Dive Details'; + @override String get settings_appearance_header_diveLog => 'Duiklogboek'; @@ -9524,6 +9527,143 @@ class AppLocalizationsNl extends AppLocalizations { @override String get settings_appearance_header_diveSites => 'Duikstekken'; + @override + String get settings_appearance_diveDetails_sectionOrderVisibility => + 'Section Order & Visibility'; + + @override + String get settings_appearance_diveDetails_sectionOrderVisibility_subtitle => + 'Choose which sections appear and their order'; + + @override + String get settings_diveDetailSections_title => 'Section Order & Visibility'; + + @override + String get settings_diveDetailSections_resetToDefault => 'Reset to Default'; + + @override + String get settings_diveDetailSections_fixedSections => + 'Fixed sections: Header, Dive Profile Chart'; + + @override + String get settings_diveDetailSections_configurableSections => + 'Configurable sections (drag to reorder)'; + + @override + String get diveDetailSection_decoO2_name => 'Deco Status / Tissue Loading'; + + @override + String get diveDetailSection_decoO2_description => + 'NDL, ceiling, tissue heat map, O2 toxicity'; + + @override + String get diveDetailSection_sacSegments_name => 'SAC Rate by Segment'; + + @override + String get diveDetailSection_sacSegments_description => + 'Phase/time segmentation, cylinder breakdown'; + + @override + String get diveDetailSection_details_name => 'Details'; + + @override + String get diveDetailSection_details_description => + 'Type, location, trip, dive center, interval'; + + @override + String get diveDetailSection_environment_name => 'Environment'; + + @override + String get diveDetailSection_environment_description => + 'Air/water temp, visibility, current'; + + @override + String get diveDetailSection_altitude_name => 'Altitude'; + + @override + String get diveDetailSection_altitude_description => + 'Altitude value, category, deco requirement'; + + @override + String get diveDetailSection_tide_name => 'Tide'; + + @override + String get diveDetailSection_tide_description => + 'Tide cycle graph and timing'; + + @override + String get diveDetailSection_weights_name => 'Weights'; + + @override + String get diveDetailSection_weights_description => + 'Weight breakdown, total weight'; + + @override + String get diveDetailSection_tanks_name => 'Tanks'; + + @override + String get diveDetailSection_tanks_description => + 'Tank list, gas mixes, pressures, per-tank SAC'; + + @override + String get diveDetailSection_buddies_name => 'Buddies'; + + @override + String get diveDetailSection_buddies_description => 'Buddy list with roles'; + + @override + String get diveDetailSection_signatures_name => 'Signatures'; + + @override + String get diveDetailSection_signatures_description => + 'Buddy/instructor signature display and capture'; + + @override + String get diveDetailSection_equipment_name => 'Equipment'; + + @override + String get diveDetailSection_equipment_description => + 'Equipment used in dive'; + + @override + String get diveDetailSection_sightings_name => 'Marine Life Sightings'; + + @override + String get diveDetailSection_sightings_description => + 'Species spotted, sighting details'; + + @override + String get diveDetailSection_media_name => 'Media'; + + @override + String get diveDetailSection_media_description => 'Photos/videos gallery'; + + @override + String get diveDetailSection_tags_name => 'Tags'; + + @override + String get diveDetailSection_tags_description => 'Dive tags'; + + @override + String get diveDetailSection_notes_name => 'Notes'; + + @override + String get diveDetailSection_notes_description => 'Dive notes/description'; + + @override + String get diveDetailSection_customFields_name => 'Custom Fields'; + + @override + String get diveDetailSection_customFields_description => + 'User-defined custom fields'; + + @override + String get diveDetailSection_dataSources_name => 'Data Sources'; + + @override + String get diveDetailSection_dataSources_description => + 'Connected dive computers, source management'; + @override String get settings_appearance_header_language => 'Taal'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index ffaee1c93..644c100b8 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -9590,6 +9590,9 @@ class AppLocalizationsPt extends AppLocalizations { String get settings_appearance_gasSwitchMarkers_subtitle => 'Mostrar marcadores para trocas de gas'; + @override + String get settings_appearance_header_diveDetails => 'Dive Details'; + @override String get settings_appearance_header_diveLog => 'Registro de Mergulho'; @@ -9599,6 +9602,143 @@ class AppLocalizationsPt extends AppLocalizations { @override String get settings_appearance_header_diveSites => 'Pontos de Mergulho'; + @override + String get settings_appearance_diveDetails_sectionOrderVisibility => + 'Section Order & Visibility'; + + @override + String get settings_appearance_diveDetails_sectionOrderVisibility_subtitle => + 'Choose which sections appear and their order'; + + @override + String get settings_diveDetailSections_title => 'Section Order & Visibility'; + + @override + String get settings_diveDetailSections_resetToDefault => 'Reset to Default'; + + @override + String get settings_diveDetailSections_fixedSections => + 'Fixed sections: Header, Dive Profile Chart'; + + @override + String get settings_diveDetailSections_configurableSections => + 'Configurable sections (drag to reorder)'; + + @override + String get diveDetailSection_decoO2_name => 'Deco Status / Tissue Loading'; + + @override + String get diveDetailSection_decoO2_description => + 'NDL, ceiling, tissue heat map, O2 toxicity'; + + @override + String get diveDetailSection_sacSegments_name => 'SAC Rate by Segment'; + + @override + String get diveDetailSection_sacSegments_description => + 'Phase/time segmentation, cylinder breakdown'; + + @override + String get diveDetailSection_details_name => 'Details'; + + @override + String get diveDetailSection_details_description => + 'Type, location, trip, dive center, interval'; + + @override + String get diveDetailSection_environment_name => 'Environment'; + + @override + String get diveDetailSection_environment_description => + 'Air/water temp, visibility, current'; + + @override + String get diveDetailSection_altitude_name => 'Altitude'; + + @override + String get diveDetailSection_altitude_description => + 'Altitude value, category, deco requirement'; + + @override + String get diveDetailSection_tide_name => 'Tide'; + + @override + String get diveDetailSection_tide_description => + 'Tide cycle graph and timing'; + + @override + String get diveDetailSection_weights_name => 'Weights'; + + @override + String get diveDetailSection_weights_description => + 'Weight breakdown, total weight'; + + @override + String get diveDetailSection_tanks_name => 'Tanks'; + + @override + String get diveDetailSection_tanks_description => + 'Tank list, gas mixes, pressures, per-tank SAC'; + + @override + String get diveDetailSection_buddies_name => 'Buddies'; + + @override + String get diveDetailSection_buddies_description => 'Buddy list with roles'; + + @override + String get diveDetailSection_signatures_name => 'Signatures'; + + @override + String get diveDetailSection_signatures_description => + 'Buddy/instructor signature display and capture'; + + @override + String get diveDetailSection_equipment_name => 'Equipment'; + + @override + String get diveDetailSection_equipment_description => + 'Equipment used in dive'; + + @override + String get diveDetailSection_sightings_name => 'Marine Life Sightings'; + + @override + String get diveDetailSection_sightings_description => + 'Species spotted, sighting details'; + + @override + String get diveDetailSection_media_name => 'Media'; + + @override + String get diveDetailSection_media_description => 'Photos/videos gallery'; + + @override + String get diveDetailSection_tags_name => 'Tags'; + + @override + String get diveDetailSection_tags_description => 'Dive tags'; + + @override + String get diveDetailSection_notes_name => 'Notes'; + + @override + String get diveDetailSection_notes_description => 'Dive notes/description'; + + @override + String get diveDetailSection_customFields_name => 'Custom Fields'; + + @override + String get diveDetailSection_customFields_description => + 'User-defined custom fields'; + + @override + String get diveDetailSection_dataSources_name => 'Data Sources'; + + @override + String get diveDetailSection_dataSources_description => + 'Connected dive computers, source management'; + @override String get settings_appearance_header_language => 'Idioma'; diff --git a/test/core/constants/dive_detail_sections_test.dart b/test/core/constants/dive_detail_sections_test.dart new file mode 100644 index 000000000..3eb137555 --- /dev/null +++ b/test/core/constants/dive_detail_sections_test.dart @@ -0,0 +1,637 @@ +import 'dart:convert'; +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/dive_detail_sections.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +void main() { + group('DiveDetailSectionId', () { + test('has 17 values', () { + expect(DiveDetailSectionId.values.length, 17); + }); + + test('values match expected IDs', () { + expect(DiveDetailSectionId.values.first, DiveDetailSectionId.decoO2); + expect(DiveDetailSectionId.values.last, DiveDetailSectionId.dataSources); + }); + }); + + group('DiveDetailSectionConfig', () { + test('constructs with required fields', () { + const config = DiveDetailSectionConfig( + id: DiveDetailSectionId.decoO2, + visible: true, + ); + expect(config.id, DiveDetailSectionId.decoO2); + expect(config.visible, true); + }); + + test('copyWith updates visible', () { + const config = DiveDetailSectionConfig( + id: DiveDetailSectionId.tanks, + visible: true, + ); + final updated = config.copyWith(visible: false); + expect(updated.id, DiveDetailSectionId.tanks); + expect(updated.visible, false); + }); + + test('toJson serializes correctly', () { + const config = DiveDetailSectionConfig( + id: DiveDetailSectionId.environment, + visible: false, + ); + final json = config.toJson(); + expect(json['id'], 'environment'); + expect(json['visible'], false); + }); + + test('fromJson deserializes correctly', () { + final config = DiveDetailSectionConfig.fromJson({ + 'id': 'environment', + 'visible': false, + }); + expect(config.id, DiveDetailSectionId.environment); + expect(config.visible, false); + }); + + test('fromJson ignores unknown section IDs', () { + final config = DiveDetailSectionConfig.tryFromJson({ + 'id': 'nonexistent', + 'visible': true, + }); + expect(config, isNull); + }); + }); + + group('DiveDetailSectionConfig list serialization', () { + test('sectionsToJson produces valid JSON string', () { + const sections = [ + DiveDetailSectionConfig(id: DiveDetailSectionId.decoO2, visible: true), + DiveDetailSectionConfig( + id: DiveDetailSectionId.details, + visible: false, + ), + ]; + final jsonStr = DiveDetailSectionConfig.sectionsToJson(sections); + final decoded = jsonDecode(jsonStr) as List; + expect(decoded.length, 2); + expect(decoded[0]['id'], 'decoO2'); + expect(decoded[1]['visible'], false); + }); + + test('sectionsFromJson parses and ensures all sections', () { + const jsonStr = + '[{"id":"decoO2","visible":true},{"id":"details","visible":false}]'; + final sections = DiveDetailSectionConfig.sectionsFromJson(jsonStr); + // 2 saved + 15 missing = 17 total + expect(sections.length, 17); + expect(sections[0].id, DiveDetailSectionId.decoO2); + expect(sections[0].visible, true); + expect(sections[1].id, DiveDetailSectionId.details); + expect(sections[1].visible, false); + // Missing sections appended as visible + expect(sections.sublist(2).every((s) => s.visible), true); + }); + + test('sectionsFromJson skips unknown IDs and ensures all sections', () { + const jsonStr = + '[{"id":"decoO2","visible":true},{"id":"unknown","visible":true},{"id":"details","visible":false}]'; + final sections = DiveDetailSectionConfig.sectionsFromJson(jsonStr); + // 2 known + 15 missing = 17 (unknown skipped) + expect(sections.length, 17); + expect(sections[0].id, DiveDetailSectionId.decoO2); + expect(sections[1].id, DiveDetailSectionId.details); + }); + + test('sectionsFromJson returns defaults for null input', () { + final sections = DiveDetailSectionConfig.sectionsFromJson(null); + expect(sections.length, 17); + expect(sections.every((s) => s.visible), true); + }); + + test('sectionsFromJson returns defaults for empty string', () { + final sections = DiveDetailSectionConfig.sectionsFromJson(''); + expect(sections.length, 17); + }); + + test('sectionsFromJson returns defaults for invalid JSON', () { + final sections = DiveDetailSectionConfig.sectionsFromJson('not json'); + expect(sections.length, 17); + }); + }); + + group('defaultSections', () { + test('contains all 17 section IDs', () { + expect(DiveDetailSectionConfig.defaultSections.length, 17); + final ids = DiveDetailSectionConfig.defaultSections + .map((s) => s.id) + .toSet(); + expect(ids, DiveDetailSectionId.values.toSet()); + }); + + test('all sections are visible by default', () { + expect( + DiveDetailSectionConfig.defaultSections.every((s) => s.visible), + true, + ); + }); + + test('order matches enum declaration order', () { + for (var i = 0; i < DiveDetailSectionId.values.length; i++) { + expect( + DiveDetailSectionConfig.defaultSections[i].id, + DiveDetailSectionId.values[i], + ); + } + }); + }); + + group('ensureAllSections', () { + test('appends missing sections from a saved config', () { + const saved = [ + DiveDetailSectionConfig(id: DiveDetailSectionId.decoO2, visible: true), + DiveDetailSectionConfig( + id: DiveDetailSectionId.details, + visible: false, + ), + ]; + final result = DiveDetailSectionConfig.ensureAllSections(saved); + expect(result.length, 17); + expect(result[0].id, DiveDetailSectionId.decoO2); + expect(result[0].visible, true); + expect(result[1].id, DiveDetailSectionId.details); + expect(result[1].visible, false); + expect(result.sublist(2).every((s) => s.visible), true); + }); + + test('returns saved config unchanged when all sections present', () { + const saved = DiveDetailSectionConfig.defaultSections; + final result = DiveDetailSectionConfig.ensureAllSections(saved); + expect(result.length, 17); + for (var i = 0; i < 17; i++) { + expect(result[i].id, saved[i].id); + expect(result[i].visible, saved[i].visible); + } + }); + }); + + group('DiveDetailSectionConfig fromJson edge cases', () { + test('fromJson defaults visible to true when missing', () { + final config = DiveDetailSectionConfig.fromJson({'id': 'tanks'}); + expect(config.id, DiveDetailSectionId.tanks); + expect(config.visible, true); + }); + + test('fromJson throws for unknown id', () { + expect( + () => DiveDetailSectionConfig.fromJson({ + 'id': 'nonexistent', + 'visible': true, + }), + throwsStateError, + ); + }); + + test('fromJson with visible explicitly set to false', () { + final config = DiveDetailSectionConfig.fromJson({ + 'id': 'decoO2', + 'visible': false, + }); + expect(config.visible, false); + }); + + test('toJson then fromJson round-trip preserves single config', () { + const original = DiveDetailSectionConfig( + id: DiveDetailSectionId.sightings, + visible: false, + ); + final json = original.toJson(); + final restored = DiveDetailSectionConfig.fromJson(json); + expect(restored.id, original.id); + expect(restored.visible, original.visible); + }); + }); + + group('tryFromJson', () { + test('returns config for valid JSON map', () { + final config = DiveDetailSectionConfig.tryFromJson({ + 'id': 'media', + 'visible': true, + }); + expect(config, isNotNull); + expect(config!.id, DiveDetailSectionId.media); + expect(config.visible, true); + }); + + test('returns null for missing id key', () { + final config = DiveDetailSectionConfig.tryFromJson({'visible': true}); + expect(config, isNull); + }); + + test('returns null for non-string id', () { + final config = DiveDetailSectionConfig.tryFromJson({ + 'id': 42, + 'visible': true, + }); + expect(config, isNull); + }); + }); + + group('DiveDetailSectionConfig copyWith edge cases', () { + test('copyWith without arguments preserves all values', () { + const config = DiveDetailSectionConfig( + id: DiveDetailSectionId.environment, + visible: false, + ); + final copy = config.copyWith(); + expect(copy.id, DiveDetailSectionId.environment); + expect(copy.visible, false); + }); + }); + + group('round-trip serialization', () { + test( + 'sectionsToJson then sectionsFromJson preserves order and visibility', + () { + const original = [ + DiveDetailSectionConfig( + id: DiveDetailSectionId.tanks, + visible: false, + ), + DiveDetailSectionConfig(id: DiveDetailSectionId.notes, visible: true), + DiveDetailSectionConfig( + id: DiveDetailSectionId.decoO2, + visible: true, + ), + ]; + final json = DiveDetailSectionConfig.sectionsToJson(original); + final restored = DiveDetailSectionConfig.sectionsFromJson(json); + // 3 saved + 14 missing = 17 + expect(restored.length, 17); + // First 3 preserve original order and visibility + expect(restored[0].id, DiveDetailSectionId.tanks); + expect(restored[0].visible, false); + expect(restored[1].id, DiveDetailSectionId.notes); + expect(restored[1].visible, true); + expect(restored[2].id, DiveDetailSectionId.decoO2); + expect(restored[2].visible, true); + }, + ); + + test('full 17-section round-trip preserves exact order', () { + final custom = List.of(DiveDetailSectionConfig.defaultSections); + // Reverse order and toggle some off + final reversed = custom.reversed.toList(); + reversed[0] = reversed[0].copyWith(visible: false); + reversed[5] = reversed[5].copyWith(visible: false); + final json = DiveDetailSectionConfig.sectionsToJson(reversed); + final restored = DiveDetailSectionConfig.sectionsFromJson(json); + expect(restored.length, 17); + for (var i = 0; i < 17; i++) { + expect(restored[i].id, reversed[i].id); + expect(restored[i].visible, reversed[i].visible); + } + }); + }); + + group('ensureAllSections edge cases', () { + test('handles empty input list', () { + final result = DiveDetailSectionConfig.ensureAllSections([]); + expect(result.length, 17); + expect(result.every((s) => s.visible), true); + }); + + test('preserves custom visibility for existing sections', () { + const saved = [ + DiveDetailSectionConfig(id: DiveDetailSectionId.tanks, visible: false), + DiveDetailSectionConfig( + id: DiveDetailSectionId.buddies, + visible: false, + ), + ]; + final result = DiveDetailSectionConfig.ensureAllSections(saved); + final tanksConfig = result.firstWhere( + (s) => s.id == DiveDetailSectionId.tanks, + ); + final buddiesConfig = result.firstWhere( + (s) => s.id == DiveDetailSectionId.buddies, + ); + expect(tanksConfig.visible, false); + expect(buddiesConfig.visible, false); + }); + }); + + group('sectionsFromJson error recovery', () { + test('returns defaults when JSON is a map instead of a list', () { + final sections = DiveDetailSectionConfig.sectionsFromJson( + '{"foo":"bar"}', + ); + expect(sections.length, 17); + expect(sections.every((s) => s.visible), true); + }); + + test('returns defaults when JSON list contains non-map items', () { + final sections = DiveDetailSectionConfig.sectionsFromJson('[1, 2, 3]'); + expect(sections.length, 17); + expect(sections.every((s) => s.visible), true); + }); + + test('returns defaults when all entries have unknown IDs', () { + const jsonStr = + '[{"id":"foo","visible":true},{"id":"bar","visible":false}]'; + final sections = DiveDetailSectionConfig.sectionsFromJson(jsonStr); + // All unknown → parsed list empty → returns defaults + expect(sections.length, 17); + expect(sections.every((s) => s.visible), true); + }); + + test( + 'preserves valid entries in JSON list with mixed valid/invalid types', + () { + const jsonStr = '[{"id":"decoO2","visible":true}, "not a map", 42]'; + final sections = DiveDetailSectionConfig.sectionsFromJson(jsonStr); + // Non-Map items are skipped; valid decoO2 is preserved; missing sections + // are appended by ensureAllSections. + expect(sections.length, 17); + expect(sections.first.id, DiveDetailSectionId.decoO2); + expect(sections.first.visible, true); + }, + ); + }); + + group('sectionsFromJson with all sections present', () { + test('returns exact list when all 17 sections are in JSON', () { + final allSections = DiveDetailSectionConfig.defaultSections + .map((s) => s.toJson()) + .toList(); + final json = jsonEncode(allSections); + final sections = DiveDetailSectionConfig.sectionsFromJson(json); + expect(sections.length, 17); + }); + }); + + group('DiveDetailSectionId metadata', () { + test('displayName returns non-empty string for all values', () { + for (final id in DiveDetailSectionId.values) { + expect(id.displayName.isNotEmpty, true); + } + }); + + test('description returns non-empty string for all values', () { + for (final id in DiveDetailSectionId.values) { + expect(id.description.isNotEmpty, true); + } + }); + + test('displayName values are correct for each section', () { + expect( + DiveDetailSectionId.decoO2.displayName, + 'Deco Status / Tissue Loading', + ); + expect( + DiveDetailSectionId.sacSegments.displayName, + 'SAC Rate by Segment', + ); + expect(DiveDetailSectionId.details.displayName, 'Details'); + expect(DiveDetailSectionId.environment.displayName, 'Environment'); + expect(DiveDetailSectionId.altitude.displayName, 'Altitude'); + expect(DiveDetailSectionId.tide.displayName, 'Tide'); + expect(DiveDetailSectionId.weights.displayName, 'Weights'); + expect(DiveDetailSectionId.tanks.displayName, 'Tanks'); + expect(DiveDetailSectionId.buddies.displayName, 'Buddies'); + expect(DiveDetailSectionId.signatures.displayName, 'Signatures'); + expect(DiveDetailSectionId.equipment.displayName, 'Equipment'); + expect( + DiveDetailSectionId.sightings.displayName, + 'Marine Life Sightings', + ); + expect(DiveDetailSectionId.media.displayName, 'Media'); + expect(DiveDetailSectionId.tags.displayName, 'Tags'); + expect(DiveDetailSectionId.notes.displayName, 'Notes'); + expect(DiveDetailSectionId.customFields.displayName, 'Custom Fields'); + expect(DiveDetailSectionId.dataSources.displayName, 'Data Sources'); + }); + + test('description values are correct for each section', () { + expect( + DiveDetailSectionId.decoO2.description, + 'NDL, ceiling, tissue heat map, O2 toxicity', + ); + expect( + DiveDetailSectionId.sacSegments.description, + 'Phase/time segmentation, cylinder breakdown', + ); + expect( + DiveDetailSectionId.details.description, + 'Type, location, trip, dive center, interval', + ); + expect( + DiveDetailSectionId.environment.description, + 'Air/water temp, visibility, current', + ); + expect( + DiveDetailSectionId.altitude.description, + 'Altitude value, category, deco requirement', + ); + expect( + DiveDetailSectionId.tide.description, + 'Tide cycle graph and timing', + ); + expect( + DiveDetailSectionId.weights.description, + 'Weight breakdown, total weight', + ); + expect( + DiveDetailSectionId.tanks.description, + 'Tank list, gas mixes, pressures, per-tank SAC', + ); + expect(DiveDetailSectionId.buddies.description, 'Buddy list with roles'); + expect( + DiveDetailSectionId.signatures.description, + 'Buddy/instructor signature display and capture', + ); + expect( + DiveDetailSectionId.equipment.description, + 'Equipment used in dive', + ); + expect( + DiveDetailSectionId.sightings.description, + 'Species spotted, sighting details', + ); + expect(DiveDetailSectionId.media.description, 'Photos/videos gallery'); + expect(DiveDetailSectionId.tags.description, 'Dive tags'); + expect(DiveDetailSectionId.notes.description, 'Dive notes/description'); + expect( + DiveDetailSectionId.customFields.description, + 'User-defined custom fields', + ); + expect( + DiveDetailSectionId.dataSources.description, + 'Connected dive computers, source management', + ); + }); + + test('each section has a unique displayName', () { + final names = DiveDetailSectionId.values + .map((id) => id.displayName) + .toList(); + expect(names.toSet().length, names.length); + }); + }); + + group('DiveDetailSectionId localized metadata', () { + late AppLocalizations l10n; + + setUpAll(() { + l10n = lookupAppLocalizations(const Locale('en')); + }); + + test('localizedDisplayName returns non-empty string for all values', () { + for (final id in DiveDetailSectionId.values) { + expect(id.localizedDisplayName(l10n).isNotEmpty, true); + } + }); + + test('localizedDescription returns non-empty string for all values', () { + for (final id in DiveDetailSectionId.values) { + expect(id.localizedDescription(l10n).isNotEmpty, true); + } + }); + + test('localizedDisplayName matches displayName for English locale', () { + for (final id in DiveDetailSectionId.values) { + expect(id.localizedDisplayName(l10n), id.displayName); + } + }); + + test('localizedDescription matches description for English locale', () { + for (final id in DiveDetailSectionId.values) { + expect(id.localizedDescription(l10n), id.description); + } + }); + + test('localizedDisplayName values are correct for each section', () { + expect( + DiveDetailSectionId.decoO2.localizedDisplayName(l10n), + 'Deco Status / Tissue Loading', + ); + expect( + DiveDetailSectionId.sacSegments.localizedDisplayName(l10n), + 'SAC Rate by Segment', + ); + expect(DiveDetailSectionId.details.localizedDisplayName(l10n), 'Details'); + expect( + DiveDetailSectionId.environment.localizedDisplayName(l10n), + 'Environment', + ); + expect( + DiveDetailSectionId.altitude.localizedDisplayName(l10n), + 'Altitude', + ); + expect(DiveDetailSectionId.tide.localizedDisplayName(l10n), 'Tide'); + expect(DiveDetailSectionId.weights.localizedDisplayName(l10n), 'Weights'); + expect(DiveDetailSectionId.tanks.localizedDisplayName(l10n), 'Tanks'); + expect(DiveDetailSectionId.buddies.localizedDisplayName(l10n), 'Buddies'); + expect( + DiveDetailSectionId.signatures.localizedDisplayName(l10n), + 'Signatures', + ); + expect( + DiveDetailSectionId.equipment.localizedDisplayName(l10n), + 'Equipment', + ); + expect( + DiveDetailSectionId.sightings.localizedDisplayName(l10n), + 'Marine Life Sightings', + ); + expect(DiveDetailSectionId.media.localizedDisplayName(l10n), 'Media'); + expect(DiveDetailSectionId.tags.localizedDisplayName(l10n), 'Tags'); + expect(DiveDetailSectionId.notes.localizedDisplayName(l10n), 'Notes'); + expect( + DiveDetailSectionId.customFields.localizedDisplayName(l10n), + 'Custom Fields', + ); + expect( + DiveDetailSectionId.dataSources.localizedDisplayName(l10n), + 'Data Sources', + ); + }); + + test('localizedDescription values are correct for each section', () { + expect( + DiveDetailSectionId.decoO2.localizedDescription(l10n), + 'NDL, ceiling, tissue heat map, O2 toxicity', + ); + expect( + DiveDetailSectionId.sacSegments.localizedDescription(l10n), + 'Phase/time segmentation, cylinder breakdown', + ); + expect( + DiveDetailSectionId.details.localizedDescription(l10n), + 'Type, location, trip, dive center, interval', + ); + expect( + DiveDetailSectionId.environment.localizedDescription(l10n), + 'Air/water temp, visibility, current', + ); + expect( + DiveDetailSectionId.altitude.localizedDescription(l10n), + 'Altitude value, category, deco requirement', + ); + expect( + DiveDetailSectionId.tide.localizedDescription(l10n), + 'Tide cycle graph and timing', + ); + expect( + DiveDetailSectionId.weights.localizedDescription(l10n), + 'Weight breakdown, total weight', + ); + expect( + DiveDetailSectionId.tanks.localizedDescription(l10n), + 'Tank list, gas mixes, pressures, per-tank SAC', + ); + expect( + DiveDetailSectionId.buddies.localizedDescription(l10n), + 'Buddy list with roles', + ); + expect( + DiveDetailSectionId.signatures.localizedDescription(l10n), + 'Buddy/instructor signature display and capture', + ); + expect( + DiveDetailSectionId.equipment.localizedDescription(l10n), + 'Equipment used in dive', + ); + expect( + DiveDetailSectionId.sightings.localizedDescription(l10n), + 'Species spotted, sighting details', + ); + expect( + DiveDetailSectionId.media.localizedDescription(l10n), + 'Photos/videos gallery', + ); + expect(DiveDetailSectionId.tags.localizedDescription(l10n), 'Dive tags'); + expect( + DiveDetailSectionId.notes.localizedDescription(l10n), + 'Dive notes/description', + ); + expect( + DiveDetailSectionId.customFields.localizedDescription(l10n), + 'User-defined custom fields', + ); + expect( + DiveDetailSectionId.dataSources.localizedDescription(l10n), + 'Connected dive computers, source management', + ); + }); + + test('each section has a unique localizedDisplayName', () { + final names = DiveDetailSectionId.values + .map((id) => id.localizedDisplayName(l10n)) + .toList(); + expect(names.toSet().length, names.length); + }); + }); +} diff --git a/test/features/dive_log/presentation/pages/dive_detail_page_section_config_test.dart b/test/features/dive_log/presentation/pages/dive_detail_page_section_config_test.dart new file mode 100644 index 000000000..823f76582 --- /dev/null +++ b/test/features/dive_log/presentation/pages/dive_detail_page_section_config_test.dart @@ -0,0 +1,561 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +// ignore: implementation_imports +import 'package:riverpod/src/framework.dart' as riverpod show Override; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:submersion/core/constants/dive_detail_sections.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/buddies/domain/entities/buddy.dart'; +import 'package:submersion/features/buddies/presentation/providers/buddy_providers.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive_custom_field.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive_data_source.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive_weight.dart'; +import 'package:submersion/features/dive_log/presentation/pages/dive_detail_page.dart'; +import 'package:submersion/features/dive_log/presentation/providers/dive_providers.dart'; +import 'package:submersion/features/equipment/domain/entities/equipment_item.dart'; +import 'package:submersion/features/marine_life/domain/entities/species.dart'; +import 'package:submersion/features/marine_life/presentation/providers/species_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/features/signatures/domain/entities/signature.dart'; +import 'package:submersion/features/signatures/presentation/providers/signature_providers.dart'; +import 'package:submersion/features/tags/domain/entities/tag.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +typedef Override = riverpod.Override; + +/// Mock SettingsNotifier that doesn't access the database +class _MockSettingsNotifier extends StateNotifier + implements SettingsNotifier { + _MockSettingsNotifier(super.initial); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +/// Sections that return early with empty dive data (no extra providers needed). +const _earlyReturnSections = [ + DiveDetailSectionId.decoO2, + DiveDetailSectionId.sacSegments, + DiveDetailSectionId.environment, + DiveDetailSectionId.altitude, + DiveDetailSectionId.weights, + DiveDetailSectionId.tanks, + DiveDetailSectionId.equipment, + DiveDetailSectionId.tags, + DiveDetailSectionId.customFields, +]; + +/// Sections whose builders only need context + dive (no Riverpod providers). +const _simpleRenderSections = [DiveDetailSectionId.notes]; + +/// Build settings with only specified sections visible. +AppSettings _settingsWithVisibleSections(List visible) { + final sections = DiveDetailSectionId.values + .map( + (id) => DiveDetailSectionConfig(id: id, visible: visible.contains(id)), + ) + .toList(); + return AppSettings(diveDetailSections: sections); +} + +/// Build a minimal ProviderScope + MaterialApp for DiveDetailPage. +Widget _buildTestWidget({ + required Dive dive, + required AppSettings settings, + List extraOverrides = const [], +}) { + return ProviderScope( + overrides: [ + diveProvider(dive.id).overrideWith((ref) async => dive), + diveDataSourcesProvider( + dive.id, + ).overrideWith((ref) async => []), + settingsProvider.overrideWith((ref) => _MockSettingsNotifier(settings)), + ...extraOverrides, + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: DiveDetailPage(diveId: dive.id), + ), + ); +} + +/// Provider overrides needed for sections that always render their widgets. +List _alwaysRenderOverrides( + String diveId, + SharedPreferences prefs, +) => [ + sharedPreferencesProvider.overrideWithValue(prefs), + buddiesForDiveProvider(diveId).overrideWith((ref) async => []), + diveSightingsProvider(diveId).overrideWith((ref) async => []), + buddySignaturesForDiveProvider( + diveId, + ).overrideWith((ref) async => []), + surfaceIntervalProvider(diveId).overrideWith((ref) async => null), + tankPressuresProvider( + diveId, + ).overrideWith((ref) async => >{}), +]; + +/// A minimal dive with all collections empty (triggers early-return branches). +final _emptyDive = Dive( + id: 'test-dive-1', + dateTime: DateTime(2026, 3, 15, 10, 0), +); + +/// A dive with tags and notes (triggers widget-building branches). +final _diveWithContent = Dive( + id: 'test-dive-2', + dateTime: DateTime(2026, 3, 15, 10, 0), + notes: 'Great dive, saw a lot of fish.', + tags: [ + Tag( + id: 'tag-1', + name: 'Night Dive', + createdAt: DateTime(2026, 1, 1), + updatedAt: DateTime(2026, 1, 1), + ), + ], + customFields: [ + const DiveCustomField(id: 'cf-1', key: 'Instructor', value: 'Jane'), + ], +); + +/// A dive with rich data to exercise non-early-return builder paths. +final _richDive = Dive( + id: 'test-dive-3', + dateTime: DateTime(2026, 3, 15, 10, 0), + altitude: 1500.0, + airTemp: 22.0, + waterTemp: 18.0, + weightAmount: 5.0, + weightType: WeightType.integrated, + weights: [ + const DiveWeight( + id: 'w-1', + diveId: 'test-dive-3', + weightType: WeightType.belt, + amountKg: 4.0, + ), + ], + tanks: [const DiveTank(id: 'tank-1', volume: 11.1, startPressure: 200)], + equipment: [ + const EquipmentItem( + id: 'eq-1', + name: 'BCD', + type: EquipmentType.bcd, + status: EquipmentStatus.active, + ), + ], +); + +void main() { + late SharedPreferences prefs; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + }); + + group('DiveDetailPage section config rendering', () { + testWidgets('renders with all sections invisible (only fixed header)', ( + tester, + ) async { + final settings = _settingsWithVisibleSections([]); + + await tester.pumpWidget( + _buildTestWidget(dive: _emptyDive, settings: settings), + ); + await tester.pumpAndSettle(); + + // Page renders — header is always shown (dive number, date) + expect(find.text('#-'), findsOneWidget); + }); + + testWidgets('renders early-return sections with empty dive data', ( + tester, + ) async { + final settings = _settingsWithVisibleSections(_earlyReturnSections); + + await tester.pumpWidget( + _buildTestWidget(dive: _emptyDive, settings: settings), + ); + await tester.pumpAndSettle(); + + // Page renders without errors — all sections return [] due to empty data + expect(find.text('#-'), findsOneWidget); + }); + + testWidgets('renders notes section with dive content', (tester) async { + final settings = _settingsWithVisibleSections(_simpleRenderSections); + + await tester.pumpWidget( + _buildTestWidget(dive: _diveWithContent, settings: settings), + ); + await tester.pumpAndSettle(); + + // Notes section rendered with dive notes text + expect(find.text('Great dive, saw a lot of fish.'), findsOneWidget); + }); + + testWidgets('renders tags section when dive has tags', (tester) async { + final settings = _settingsWithVisibleSections([DiveDetailSectionId.tags]); + + await tester.pumpWidget( + _buildTestWidget(dive: _diveWithContent, settings: settings), + ); + await tester.pumpAndSettle(); + + // Tags section rendered with tag name + expect(find.text('Night Dive'), findsOneWidget); + }); + + testWidgets('renders custom fields section when dive has custom fields', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(800, 2000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final settings = _settingsWithVisibleSections([ + DiveDetailSectionId.customFields, + ]); + + await tester.pumpWidget( + _buildTestWidget(dive: _diveWithContent, settings: settings), + ); + await tester.pumpAndSettle(); + + // Custom fields section rendered (key has trailing colon) + expect(find.text('Instructor:'), findsOneWidget); + expect(find.text('Jane'), findsOneWidget); + }); + + testWidgets('hidden sections do not render their content', (tester) async { + // Only notes is visible; tags and customFields are invisible + final settings = _settingsWithVisibleSections([ + DiveDetailSectionId.notes, + ]); + + await tester.pumpWidget( + _buildTestWidget(dive: _diveWithContent, settings: settings), + ); + await tester.pumpAndSettle(); + + // Notes renders + expect(find.text('Great dive, saw a lot of fish.'), findsOneWidget); + // Tags and custom fields do NOT render + expect(find.text('Night Dive'), findsNothing); + expect(find.text('Instructor:'), findsNothing); + }); + + testWidgets('sections render in config order', (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 2000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + // Put notes before tags (reversed from default) + final settings = AppSettings( + diveDetailSections: [ + const DiveDetailSectionConfig( + id: DiveDetailSectionId.notes, + visible: true, + ), + const DiveDetailSectionConfig( + id: DiveDetailSectionId.tags, + visible: true, + ), + // All others invisible + ...DiveDetailSectionId.values + .where( + (id) => + id != DiveDetailSectionId.notes && + id != DiveDetailSectionId.tags, + ) + .map((id) => DiveDetailSectionConfig(id: id, visible: false)), + ], + ); + + await tester.pumpWidget( + _buildTestWidget(dive: _diveWithContent, settings: settings), + ); + await tester.pumpAndSettle(); + + // Both sections render + expect(find.text('Great dive, saw a lot of fish.'), findsOneWidget); + expect(find.text('Night Dive'), findsOneWidget); + }); + + testWidgets('early-return sections plus content sections together', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(800, 2000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final settings = _settingsWithVisibleSections([ + ..._earlyReturnSections, + ..._simpleRenderSections, + DiveDetailSectionId.tags, + DiveDetailSectionId.customFields, + ]); + + await tester.pumpWidget( + _buildTestWidget(dive: _diveWithContent, settings: settings), + ); + await tester.pumpAndSettle(); + + // Content sections render + expect(find.text('Great dive, saw a lot of fish.'), findsOneWidget); + expect(find.text('Night Dive'), findsOneWidget); + expect(find.text('Instructor:'), findsOneWidget); + }); + + testWidgets('renders dataSources section with empty data sources', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(800, 4000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final settings = _settingsWithVisibleSections([ + DiveDetailSectionId.dataSources, + ]); + + await tester.pumpWidget( + _buildTestWidget(dive: _emptyDive, settings: settings), + ); + await tester.pumpAndSettle(); + + // Page renders with data sources section (empty state) + expect(find.text('#-'), findsOneWidget); + }); + + testWidgets('renders buddies and sightings sections with empty data', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(800, 4000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final settings = _settingsWithVisibleSections([ + DiveDetailSectionId.buddies, + DiveDetailSectionId.sightings, + ]); + + await tester.pumpWidget( + _buildTestWidget( + dive: _emptyDive, + settings: settings, + extraOverrides: _alwaysRenderOverrides(_emptyDive.id, prefs), + ), + ); + await tester.pumpAndSettle(); + + // Page renders without errors + expect(find.text('#-'), findsOneWidget); + }); + + testWidgets('renders signatures section without course', (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 4000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final settings = _settingsWithVisibleSections([ + DiveDetailSectionId.signatures, + ]); + + await tester.pumpWidget( + _buildTestWidget( + dive: _emptyDive, + settings: settings, + extraOverrides: _alwaysRenderOverrides(_emptyDive.id, prefs), + ), + ); + await tester.pumpAndSettle(); + + // Page renders — no instructor signature since courseId is null + expect(find.text('#-'), findsOneWidget); + }); + + testWidgets('renders details section with surface interval provider', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(800, 4000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final settings = _settingsWithVisibleSections([ + DiveDetailSectionId.details, + ]); + + await tester.pumpWidget( + _buildTestWidget( + dive: _emptyDive, + settings: settings, + extraOverrides: _alwaysRenderOverrides(_emptyDive.id, prefs), + ), + ); + await tester.pumpAndSettle(); + + // Details section renders + expect(find.text('#-'), findsOneWidget); + }); + + testWidgets('renders all sections together with empty dive data', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(800, 8000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + // All sections visible — exercises every builder closure + final settings = _settingsWithVisibleSections( + DiveDetailSectionId.values.toList(), + ); + + await tester.pumpWidget( + _buildTestWidget( + dive: _emptyDive, + settings: settings, + extraOverrides: _alwaysRenderOverrides(_emptyDive.id, prefs), + ), + ); + await tester.pumpAndSettle(); + + // Page fully renders with all sections + expect(find.text('#-'), findsOneWidget); + }); + + testWidgets('renders altitude section when dive has altitude data', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(800, 8000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final settings = _settingsWithVisibleSections([ + DiveDetailSectionId.altitude, + DiveDetailSectionId.details, + ]); + + await tester.pumpWidget( + _buildTestWidget( + dive: _richDive, + settings: settings, + extraOverrides: _alwaysRenderOverrides(_richDive.id, prefs), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('#-'), findsOneWidget); + }); + + testWidgets('renders environment section when dive has env data', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(800, 8000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final settings = _settingsWithVisibleSections([ + DiveDetailSectionId.environment, + DiveDetailSectionId.details, + ]); + + await tester.pumpWidget( + _buildTestWidget( + dive: _richDive, + settings: settings, + extraOverrides: _alwaysRenderOverrides(_richDive.id, prefs), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('#-'), findsOneWidget); + }); + + testWidgets('renders weights section when dive has weights', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(800, 8000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final settings = _settingsWithVisibleSections([ + DiveDetailSectionId.weights, + DiveDetailSectionId.details, + ]); + + await tester.pumpWidget( + _buildTestWidget( + dive: _richDive, + settings: settings, + extraOverrides: _alwaysRenderOverrides(_richDive.id, prefs), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('#-'), findsOneWidget); + }); + + testWidgets('renders tanks section when dive has tanks', (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 8000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final settings = _settingsWithVisibleSections([ + DiveDetailSectionId.tanks, + DiveDetailSectionId.details, + ]); + + await tester.pumpWidget( + _buildTestWidget( + dive: _richDive, + settings: settings, + extraOverrides: _alwaysRenderOverrides(_richDive.id, prefs), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('#-'), findsOneWidget); + }); + + testWidgets('renders equipment section when dive has equipment', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(800, 8000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final settings = _settingsWithVisibleSections([ + DiveDetailSectionId.equipment, + DiveDetailSectionId.details, + ]); + + await tester.pumpWidget( + _buildTestWidget( + dive: _richDive, + settings: settings, + extraOverrides: _alwaysRenderOverrides(_richDive.id, prefs), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('#-'), findsOneWidget); + }); + + testWidgets('renders all data-bearing sections with rich dive', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(800, 10000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final settings = _settingsWithVisibleSections( + DiveDetailSectionId.values.toList(), + ); + + await tester.pumpWidget( + _buildTestWidget( + dive: _richDive, + settings: settings, + extraOverrides: _alwaysRenderOverrides(_richDive.id, prefs), + ), + ); + await tester.pumpAndSettle(); + + // All builder closures are exercised with data present + expect(find.text('#-'), findsOneWidget); + }); + }); +} diff --git a/test/features/settings/presentation/pages/appearance_page_test.dart b/test/features/settings/presentation/pages/appearance_page_test.dart new file mode 100644 index 000000000..808a06293 --- /dev/null +++ b/test/features/settings/presentation/pages/appearance_page_test.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/dive_detail_sections.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/settings/presentation/pages/appearance_page.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +/// Mock SettingsNotifier that doesn't access the database +class _MockSettingsNotifier extends StateNotifier + implements SettingsNotifier { + _MockSettingsNotifier() : super(const AppSettings()); + + @override + Future setDiveDetailSections( + List sections, + ) async => state = state.copyWith(diveDetailSections: sections); + + @override + Future resetDiveDetailSections() async => + state = state.copyWith(clearDiveDetailSections: true); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +Widget _buildTestWidget() { + return ProviderScope( + overrides: [ + settingsProvider.overrideWith((ref) => _MockSettingsNotifier()), + ], + child: const MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: AppearancePage(), + ), + ); +} + +void main() { + group('AppearancePage dive detail sections', () { + testWidgets('shows Dive Details section header', (tester) async { + await tester.binding.setSurfaceSize(const Size(400, 4000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget(_buildTestWidget()); + await tester.pumpAndSettle(); + + expect(find.text('Dive Details'), findsOneWidget); + }); + + testWidgets('shows Section Order & Visibility tile', (tester) async { + await tester.binding.setSurfaceSize(const Size(400, 4000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget(_buildTestWidget()); + await tester.pumpAndSettle(); + + expect(find.text('Section Order & Visibility'), findsOneWidget); + expect( + find.text('Choose which sections appear and their order'), + findsOneWidget, + ); + }); + + testWidgets('shows reorder icon on dive details tile', (tester) async { + await tester.binding.setSurfaceSize(const Size(400, 4000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget(_buildTestWidget()); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.reorder), findsOneWidget); + }); + }); +} diff --git a/test/features/settings/presentation/pages/dive_detail_sections_page_test.dart b/test/features/settings/presentation/pages/dive_detail_sections_page_test.dart new file mode 100644 index 000000000..9989eb9d9 --- /dev/null +++ b/test/features/settings/presentation/pages/dive_detail_sections_page_test.dart @@ -0,0 +1,339 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/dive_detail_sections.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/settings/presentation/pages/dive_detail_sections_page.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +/// Mock SettingsNotifier that doesn't access the database +class _MockSettingsNotifier extends StateNotifier + implements SettingsNotifier { + _MockSettingsNotifier() : super(const AppSettings()); + + @override + Future setDiveDetailSections( + List sections, + ) async => state = state.copyWith(diveDetailSections: sections); + + @override + Future resetDiveDetailSections() async => + state = state.copyWith(clearDiveDetailSections: true); + + // Stub remaining SettingsNotifier methods + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +/// Mock with custom initial sections for testing custom order +class _MockSettingsNotifierWithSections extends StateNotifier + implements SettingsNotifier { + _MockSettingsNotifierWithSections(List sections) + : super(AppSettings(diveDetailSections: sections)); + + @override + Future setDiveDetailSections( + List sections, + ) async => state = state.copyWith(diveDetailSections: sections); + + @override + Future resetDiveDetailSections() async => + state = state.copyWith(clearDiveDetailSections: true); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +Widget _buildTestWidget() { + return ProviderScope( + overrides: [ + settingsProvider.overrideWith((ref) => _MockSettingsNotifier()), + ], + child: const MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: DiveDetailSectionsPage(), + ), + ); +} + +void main() { + group('DiveDetailSectionsPage', () { + testWidgets('renders all 17 section names', (tester) async { + await tester.binding.setSurfaceSize(const Size(400, 4000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget(_buildTestWidget()); + await tester.pumpAndSettle(); + + for (final id in DiveDetailSectionId.values) { + expect(find.text(id.displayName), findsOneWidget); + } + }); + + testWidgets('renders 17 switches', (tester) async { + await tester.binding.setSurfaceSize(const Size(400, 4000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget(_buildTestWidget()); + await tester.pumpAndSettle(); + + expect(find.byType(Switch), findsNWidgets(17)); + }); + + testWidgets('renders drag handles', (tester) async { + await tester.binding.setSurfaceSize(const Size(400, 4000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget(_buildTestWidget()); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.drag_handle), findsNWidgets(17)); + }); + + testWidgets('shows fixed sections note', (tester) async { + await tester.pumpWidget(_buildTestWidget()); + await tester.pumpAndSettle(); + + expect(find.textContaining('Header'), findsOneWidget); + expect(find.textContaining('Dive Profile'), findsOneWidget); + }); + + testWidgets('all switches are initially on', (tester) async { + await tester.binding.setSurfaceSize(const Size(400, 4000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget(_buildTestWidget()); + await tester.pumpAndSettle(); + + final switches = tester.widgetList(find.byType(Switch)); + for (final s in switches) { + expect(s.value, true); + } + }); + + testWidgets('toggling a switch updates section visibility', (tester) async { + await tester.binding.setSurfaceSize(const Size(400, 4000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget(_buildTestWidget()); + await tester.pumpAndSettle(); + + // Find the first switch and tap it off + final firstSwitch = find.byType(Switch).first; + await tester.tap(firstSwitch); + await tester.pumpAndSettle(); + + // After toggling, the first switch should now be off + final switches = tester.widgetList(find.byType(Switch)).toList(); + expect(switches[0].value, false); + // Others remain on + expect(switches[1].value, true); + }); + + testWidgets('hidden section has reduced opacity', (tester) async { + await tester.binding.setSurfaceSize(const Size(400, 4000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget(_buildTestWidget()); + await tester.pumpAndSettle(); + + // Toggle first section off + await tester.tap(find.byType(Switch).first); + await tester.pumpAndSettle(); + + // Find AnimatedOpacity widgets + final opacities = tester.widgetList( + find.byType(AnimatedOpacity), + ); + final opacityList = opacities.toList(); + // First section should be dimmed + expect(opacityList[0].opacity, 0.5); + // Second section should be full opacity + expect(opacityList[1].opacity, 1.0); + }); + + testWidgets('reset to default restores all sections visible', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(400, 4000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget(_buildTestWidget()); + await tester.pumpAndSettle(); + + // Toggle first section off + await tester.tap(find.byType(Switch).first); + await tester.pumpAndSettle(); + + // Verify it's off + var switches = tester.widgetList(find.byType(Switch)).toList(); + expect(switches[0].value, false); + + // Tap the overflow menu + await tester.tap(find.byType(PopupMenuButton)); + await tester.pumpAndSettle(); + + // Tap "Reset to Default" + await tester.tap(find.text('Reset to Default')); + await tester.pumpAndSettle(); + + // All switches should be back on + switches = tester.widgetList(find.byType(Switch)).toList(); + for (final s in switches) { + expect(s.value, true); + } + }); + + testWidgets('toggling switch back on restores full opacity', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(400, 4000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget(_buildTestWidget()); + await tester.pumpAndSettle(); + + // Toggle off + await tester.tap(find.byType(Switch).first); + await tester.pumpAndSettle(); + expect( + tester + .widgetList(find.byType(AnimatedOpacity)) + .first + .opacity, + 0.5, + ); + + // Toggle back on + await tester.tap(find.byType(Switch).first); + await tester.pumpAndSettle(); + expect( + tester + .widgetList(find.byType(AnimatedOpacity)) + .first + .opacity, + 1.0, + ); + + // Switch should be on again + expect(tester.widgetList(find.byType(Switch)).first.value, true); + }); + + testWidgets('multiple sections can be toggled off independently', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(400, 4000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget(_buildTestWidget()); + await tester.pumpAndSettle(); + + // Toggle first and third switches off + final switches = find.byType(Switch); + await tester.tap(switches.at(0)); + await tester.pumpAndSettle(); + await tester.tap(switches.at(2)); + await tester.pumpAndSettle(); + + final switchList = tester + .widgetList(find.byType(Switch)) + .toList(); + expect(switchList[0].value, false); + expect(switchList[1].value, true); + expect(switchList[2].value, false); + expect(switchList[3].value, true); + }); + + testWidgets('overflow menu button is present', (tester) async { + await tester.pumpWidget(_buildTestWidget()); + await tester.pumpAndSettle(); + + expect(find.byType(PopupMenuButton), findsOneWidget); + }); + + testWidgets('shows configurable sections subheading', (tester) async { + await tester.pumpWidget(_buildTestWidget()); + await tester.pumpAndSettle(); + + expect( + find.text('Configurable sections (drag to reorder)'), + findsOneWidget, + ); + }); + + testWidgets('sections render with custom initial order', (tester) async { + await tester.binding.setSurfaceSize(const Size(400, 4000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + // Create a custom order: tanks first, then decoO2 + final customSections = [ + const DiveDetailSectionConfig( + id: DiveDetailSectionId.tanks, + visible: true, + ), + const DiveDetailSectionConfig( + id: DiveDetailSectionId.decoO2, + visible: false, + ), + ...DiveDetailSectionId.values + .where( + (id) => + id != DiveDetailSectionId.tanks && + id != DiveDetailSectionId.decoO2, + ) + .map((id) => DiveDetailSectionConfig(id: id, visible: true)), + ]; + + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith( + (ref) => _MockSettingsNotifierWithSections(customSections), + ), + ], + child: const MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: DiveDetailSectionsPage(), + ), + ), + ); + await tester.pumpAndSettle(); + + // First section should be Tanks, second should be Deco Status + final titles = tester.widgetList( + find.descendant(of: find.byType(ListTile), matching: find.byType(Text)), + ); + // Find display names in order + final displayNames = titles + .map((t) => t.data) + .where( + (d) => + d != null && + DiveDetailSectionId.values.any((id) => id.displayName == d), + ) + .toList(); + expect(displayNames.first, 'Tanks'); + }); + + testWidgets('shows app bar title', (tester) async { + await tester.pumpWidget(_buildTestWidget()); + await tester.pumpAndSettle(); + + expect(find.text('Section Order & Visibility'), findsOneWidget); + }); + + testWidgets('shows section descriptions', (tester) async { + await tester.binding.setSurfaceSize(const Size(400, 4000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget(_buildTestWidget()); + await tester.pumpAndSettle(); + + for (final id in DiveDetailSectionId.values) { + expect(find.text(id.description), findsOneWidget); + } + }); + }); +} diff --git a/test/features/settings/presentation/pages/settings_page_test.dart b/test/features/settings/presentation/pages/settings_page_test.dart index f95f6c5f3..3e8b2afc1 100644 --- a/test/features/settings/presentation/pages/settings_page_test.dart +++ b/test/features/settings/presentation/pages/settings_page_test.dart @@ -9,6 +9,7 @@ import 'package:submersion/features/divers/domain/entities/diver.dart'; import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; import 'package:submersion/features/settings/presentation/pages/settings_page.dart'; import 'package:submersion/core/constants/card_color.dart'; +import 'package:submersion/core/constants/dive_detail_sections.dart'; import 'package:submersion/core/constants/list_view_mode.dart'; import 'package:submersion/core/constants/profile_metrics.dart'; import 'package:submersion/features/dive_log/presentation/widgets/tissue_color_schemes.dart'; @@ -275,6 +276,13 @@ class _MockSettingsNotifier extends StateNotifier @override Future setShowDataSourceBadges(bool value) async => state = state.copyWith(showDataSourceBadges: value); + @override + Future setDiveDetailSections( + List sections, + ) async => state = state.copyWith(diveDetailSections: sections); + @override + Future resetDiveDetailSections() async => + state = state.copyWith(clearDiveDetailSections: true); } /// Mock CurrentDiverIdNotifier that doesn't access the database diff --git a/test/features/settings/presentation/providers/settings_providers_test.dart b/test/features/settings/presentation/providers/settings_providers_test.dart index 3593b060e..7f3fc1901 100644 --- a/test/features/settings/presentation/providers/settings_providers_test.dart +++ b/test/features/settings/presentation/providers/settings_providers_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/dive_detail_sections.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; void main() { @@ -31,4 +32,134 @@ void main() { expect(updated.defaultTankPreset, null); }); }); + + group('AppSettings diveDetailSections', () { + test('defaults to all 17 sections visible', () { + const settings = AppSettings(); + expect(settings.diveDetailSections.length, 17); + expect(settings.diveDetailSections.every((s) => s.visible), true); + }); + + test('copyWith updates diveDetailSections', () { + const settings = AppSettings(); + final custom = [ + const DiveDetailSectionConfig( + id: DiveDetailSectionId.tanks, + visible: true, + ), + const DiveDetailSectionConfig( + id: DiveDetailSectionId.decoO2, + visible: false, + ), + ]; + final updated = settings.copyWith(diveDetailSections: custom); + expect(updated.diveDetailSections.length, 2); + expect(updated.diveDetailSections[0].id, DiveDetailSectionId.tanks); + }); + + test('copyWith can clear diveDetailSections to defaults', () { + const settings = AppSettings( + diveDetailSections: [ + DiveDetailSectionConfig( + id: DiveDetailSectionId.tanks, + visible: false, + ), + ], + ); + final updated = settings.copyWith(clearDiveDetailSections: true); + expect(updated.diveDetailSections.length, 17); + expect(updated.diveDetailSections.every((s) => s.visible), true); + }); + + test('copyWith preserves diveDetailSections when not specified', () { + final custom = [ + const DiveDetailSectionConfig( + id: DiveDetailSectionId.tanks, + visible: false, + ), + const DiveDetailSectionConfig( + id: DiveDetailSectionId.notes, + visible: true, + ), + ]; + final settings = AppSettings(diveDetailSections: custom); + final updated = settings.copyWith(themePresetId: 'dark'); + expect(updated.diveDetailSections.length, 2); + expect(updated.diveDetailSections[0].id, DiveDetailSectionId.tanks); + expect(updated.diveDetailSections[0].visible, false); + }); + + test('default sections match DiveDetailSectionConfig.defaultSections', () { + const settings = AppSettings(); + expect( + settings.diveDetailSections.length, + DiveDetailSectionConfig.defaultSections.length, + ); + for (var i = 0; i < settings.diveDetailSections.length; i++) { + expect( + settings.diveDetailSections[i].id, + DiveDetailSectionConfig.defaultSections[i].id, + ); + } + }); + + test( + 'clearDiveDetailSections takes precedence over diveDetailSections', + () { + final custom = [ + const DiveDetailSectionConfig( + id: DiveDetailSectionId.tanks, + visible: false, + ), + ]; + final settings = AppSettings(diveDetailSections: custom); + final updated = settings.copyWith( + diveDetailSections: custom, + clearDiveDetailSections: true, + ); + // Clear flag wins — should be defaults, not the custom list + expect(updated.diveDetailSections.length, 17); + expect(updated.diveDetailSections.every((s) => s.visible), true); + }, + ); + + test('sequential copyWith operations are independent', () { + const settings = AppSettings(); + final custom = [ + const DiveDetailSectionConfig( + id: DiveDetailSectionId.tanks, + visible: false, + ), + ]; + // First copyWith changes sections + final step1 = settings.copyWith(diveDetailSections: custom); + // Second copyWith changes theme but not sections + final step2 = step1.copyWith(themePresetId: 'deep'); + // Sections should be preserved from step1 + expect(step2.diveDetailSections.length, 1); + expect(step2.diveDetailSections[0].id, DiveDetailSectionId.tanks); + expect(step2.themePresetId, 'deep'); + }); + + test('copyWith with empty list results in empty list', () { + const settings = AppSettings(); + final updated = settings.copyWith( + diveDetailSections: [], + ); + expect(updated.diveDetailSections.isEmpty, true); + }); + + test('constructor with custom sections preserves them', () { + const custom = [ + DiveDetailSectionConfig(id: DiveDetailSectionId.notes, visible: false), + DiveDetailSectionConfig(id: DiveDetailSectionId.media, visible: true), + ]; + const settings = AppSettings(diveDetailSections: custom); + expect(settings.diveDetailSections.length, 2); + expect(settings.diveDetailSections[0].id, DiveDetailSectionId.notes); + expect(settings.diveDetailSections[0].visible, false); + expect(settings.diveDetailSections[1].id, DiveDetailSectionId.media); + expect(settings.diveDetailSections[1].visible, true); + }); + }); } diff --git a/test/features/statistics/presentation/pages/records_page_test.dart b/test/features/statistics/presentation/pages/records_page_test.dart index 7ce7a3b47..313cee780 100644 --- a/test/features/statistics/presentation/pages/records_page_test.dart +++ b/test/features/statistics/presentation/pages/records_page_test.dart @@ -10,6 +10,7 @@ import 'package:submersion/features/dive_log/data/repositories/dive_repository_i import 'package:submersion/features/dive_log/presentation/providers/dive_providers.dart'; import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; import 'package:submersion/core/constants/card_color.dart'; +import 'package:submersion/core/constants/dive_detail_sections.dart'; import 'package:submersion/core/constants/profile_metrics.dart'; import 'package:submersion/features/dive_log/presentation/widgets/tissue_color_schemes.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; @@ -276,6 +277,13 @@ class _MockSettingsNotifier extends StateNotifier @override Future setShowDataSourceBadges(bool value) async => state = state.copyWith(showDataSourceBadges: value); + @override + Future setDiveDetailSections( + List sections, + ) async => state = state.copyWith(diveDetailSections: sections); + @override + Future resetDiveDetailSections() async => + state = state.copyWith(clearDiveDetailSections: true); } /// Mock CurrentDiverIdNotifier that doesn't access the database diff --git a/test/helpers/mock_providers.dart b/test/helpers/mock_providers.dart index 58c99e119..9a152a08f 100644 --- a/test/helpers/mock_providers.dart +++ b/test/helpers/mock_providers.dart @@ -7,6 +7,7 @@ import 'package:submersion/core/constants/list_view_mode.dart'; import 'package:submersion/core/constants/profile_metrics.dart'; import 'package:submersion/core/constants/units.dart'; import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/core/constants/dive_detail_sections.dart'; import 'package:submersion/features/dive_log/domain/entities/dive.dart'; import 'package:submersion/features/dive_log/presentation/widgets/tissue_color_schemes.dart'; import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; @@ -261,6 +262,13 @@ class MockSettingsNotifier extends StateNotifier Future setDefaultShowOtu(bool value) async => state = state.copyWith(defaultShowOtu: value); @override + Future setDiveDetailSections( + List sections, + ) async => state = state.copyWith(diveDetailSections: sections); + @override + Future resetDiveDetailSections() async => + state = state.copyWith(clearDiveDetailSections: true); + @override Future setShowDataSourceBadges(bool value) async => state = state.copyWith(showDataSourceBadges: value); @override