Skip to content

Commit d490b77

Browse files
committed
2 parents d2867d1 + d546c0f commit d490b77

166 files changed

Lines changed: 12143 additions & 476 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

integration_test/helpers/screenshot_test_data.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -425,7 +425,7 @@ class ScreenshotTestDataSeeder {
425425
exitTime: Value(
426426
diveTimestamp + durationMs,
427427
), // exit = entry + duration
428-
duration: Value(durationSeconds * 60), // duration in seconds
428+
bottomTime: Value(durationSeconds * 60), // duration in seconds
429429
maxDepth: Value(maxDepth),
430430
avgDepth: Value(avgDepth),
431431
waterTemp: Value(waterTemp),

integration_test/helpers/uddf_screenshot_helper.dart

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -659,7 +659,7 @@ class UddfScreenshotImporter {
659659
dateTime: dateTime,
660660
entryTime: entryTime,
661661
exitTime: exitTime,
662-
duration: diveData['duration'] as Duration?,
662+
bottomTime: diveData['duration'] as Duration?,
663663
runtime: runtime,
664664
maxDepth: diveData['maxDepth'] as double?,
665665
avgDepth: diveData['avgDepth'] as double?,
@@ -695,10 +695,10 @@ class UddfScreenshotImporter {
695695
);
696696

697697
// Auto-calculate bottom time from profile if not set and profile exists
698-
if (dive.duration == null && dive.profile.isNotEmpty) {
698+
if (dive.bottomTime == null && dive.profile.isNotEmpty) {
699699
final calculatedDuration = dive.calculateBottomTimeFromProfile();
700700
if (calculatedDuration != null) {
701-
dive = dive.copyWith(duration: calculatedDuration);
701+
dive = dive.copyWith(bottomTime: calculatedDuration);
702702
}
703703
}
704704

lib/core/constants/card_color.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ double? getCardColorValue(DiveSummary dive, CardColorAttribute attribute) {
6666
return switch (attribute) {
6767
CardColorAttribute.none => null,
6868
CardColorAttribute.depth => dive.maxDepth,
69-
CardColorAttribute.duration => dive.duration?.inMinutes.toDouble(),
69+
CardColorAttribute.duration => dive.bottomTime?.inMinutes.toDouble(),
7070
CardColorAttribute.temperature => dive.waterTemp,
7171
};
7272
}
@@ -76,7 +76,7 @@ double? getCardColorValueFromDive(Dive dive, CardColorAttribute attribute) {
7676
return switch (attribute) {
7777
CardColorAttribute.none => null,
7878
CardColorAttribute.depth => dive.maxDepth,
79-
CardColorAttribute.duration => dive.duration?.inMinutes.toDouble(),
79+
CardColorAttribute.duration => dive.bottomTime?.inMinutes.toDouble(),
8080
CardColorAttribute.temperature => dive.waterTemp,
8181
};
8282
}
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import 'dart:convert';
2+
3+
import 'package:submersion/l10n/arb/app_localizations.dart';
4+
5+
/// Identifies each configurable section on the Dive Details page.
6+
///
7+
/// Declaration order defines the default display order. The two fixed sections
8+
/// (Header and Dive Profile Chart) are not included — they always render first.
9+
enum DiveDetailSectionId {
10+
decoO2,
11+
sacSegments,
12+
details,
13+
environment,
14+
altitude,
15+
tide,
16+
weights,
17+
tanks,
18+
buddies,
19+
signatures,
20+
equipment,
21+
sightings,
22+
media,
23+
tags,
24+
notes,
25+
customFields,
26+
dataSources;
27+
28+
/// Human-readable name shown in the settings UI (English fallback).
29+
String get displayName {
30+
return switch (this) {
31+
decoO2 => 'Deco Status / Tissue Loading',
32+
sacSegments => 'SAC Rate by Segment',
33+
details => 'Details',
34+
environment => 'Environment',
35+
altitude => 'Altitude',
36+
tide => 'Tide',
37+
weights => 'Weights',
38+
tanks => 'Tanks',
39+
buddies => 'Buddies',
40+
signatures => 'Signatures',
41+
equipment => 'Equipment',
42+
sightings => 'Marine Life Sightings',
43+
media => 'Media',
44+
tags => 'Tags',
45+
notes => 'Notes',
46+
customFields => 'Custom Fields',
47+
dataSources => 'Data Sources',
48+
};
49+
}
50+
51+
/// Short description shown below the name in the settings UI
52+
/// (English fallback).
53+
String get description {
54+
return switch (this) {
55+
decoO2 => 'NDL, ceiling, tissue heat map, O2 toxicity',
56+
sacSegments => 'Phase/time segmentation, cylinder breakdown',
57+
details => 'Type, location, trip, dive center, interval',
58+
environment => 'Air/water temp, visibility, current',
59+
altitude => 'Altitude value, category, deco requirement',
60+
tide => 'Tide cycle graph and timing',
61+
weights => 'Weight breakdown, total weight',
62+
tanks => 'Tank list, gas mixes, pressures, per-tank SAC',
63+
buddies => 'Buddy list with roles',
64+
signatures => 'Buddy/instructor signature display and capture',
65+
equipment => 'Equipment used in dive',
66+
sightings => 'Species spotted, sighting details',
67+
media => 'Photos/videos gallery',
68+
tags => 'Dive tags',
69+
notes => 'Dive notes/description',
70+
customFields => 'User-defined custom fields',
71+
dataSources => 'Connected dive computers, source management',
72+
};
73+
}
74+
75+
/// Localized display name resolved via [AppLocalizations].
76+
String localizedDisplayName(AppLocalizations l10n) {
77+
return switch (this) {
78+
decoO2 => l10n.diveDetailSection_decoO2_name,
79+
sacSegments => l10n.diveDetailSection_sacSegments_name,
80+
details => l10n.diveDetailSection_details_name,
81+
environment => l10n.diveDetailSection_environment_name,
82+
altitude => l10n.diveDetailSection_altitude_name,
83+
tide => l10n.diveDetailSection_tide_name,
84+
weights => l10n.diveDetailSection_weights_name,
85+
tanks => l10n.diveDetailSection_tanks_name,
86+
buddies => l10n.diveDetailSection_buddies_name,
87+
signatures => l10n.diveDetailSection_signatures_name,
88+
equipment => l10n.diveDetailSection_equipment_name,
89+
sightings => l10n.diveDetailSection_sightings_name,
90+
media => l10n.diveDetailSection_media_name,
91+
tags => l10n.diveDetailSection_tags_name,
92+
notes => l10n.diveDetailSection_notes_name,
93+
customFields => l10n.diveDetailSection_customFields_name,
94+
dataSources => l10n.diveDetailSection_dataSources_name,
95+
};
96+
}
97+
98+
/// Localized description resolved via [AppLocalizations].
99+
String localizedDescription(AppLocalizations l10n) {
100+
return switch (this) {
101+
decoO2 => l10n.diveDetailSection_decoO2_description,
102+
sacSegments => l10n.diveDetailSection_sacSegments_description,
103+
details => l10n.diveDetailSection_details_description,
104+
environment => l10n.diveDetailSection_environment_description,
105+
altitude => l10n.diveDetailSection_altitude_description,
106+
tide => l10n.diveDetailSection_tide_description,
107+
weights => l10n.diveDetailSection_weights_description,
108+
tanks => l10n.diveDetailSection_tanks_description,
109+
buddies => l10n.diveDetailSection_buddies_description,
110+
signatures => l10n.diveDetailSection_signatures_description,
111+
equipment => l10n.diveDetailSection_equipment_description,
112+
sightings => l10n.diveDetailSection_sightings_description,
113+
media => l10n.diveDetailSection_media_description,
114+
tags => l10n.diveDetailSection_tags_description,
115+
notes => l10n.diveDetailSection_notes_description,
116+
customFields => l10n.diveDetailSection_customFields_description,
117+
dataSources => l10n.diveDetailSection_dataSources_description,
118+
};
119+
}
120+
}
121+
122+
/// Visibility and ordering configuration for a single dive detail section.
123+
class DiveDetailSectionConfig {
124+
final DiveDetailSectionId id;
125+
final bool visible;
126+
127+
const DiveDetailSectionConfig({required this.id, required this.visible});
128+
129+
DiveDetailSectionConfig copyWith({bool? visible}) {
130+
return DiveDetailSectionConfig(id: id, visible: visible ?? this.visible);
131+
}
132+
133+
Map<String, dynamic> toJson() => {'id': id.name, 'visible': visible};
134+
135+
factory DiveDetailSectionConfig.fromJson(Map<String, dynamic> json) {
136+
final idStr = json['id'] as String;
137+
final id = DiveDetailSectionId.values.firstWhere((e) => e.name == idStr);
138+
return DiveDetailSectionConfig(
139+
id: id,
140+
visible: json['visible'] as bool? ?? true,
141+
);
142+
}
143+
144+
static DiveDetailSectionConfig? tryFromJson(Map<String, dynamic> json) {
145+
try {
146+
return DiveDetailSectionConfig.fromJson(json);
147+
} catch (_) {
148+
return null;
149+
}
150+
}
151+
152+
static const List<DiveDetailSectionConfig> defaultSections = [
153+
DiveDetailSectionConfig(id: DiveDetailSectionId.decoO2, visible: true),
154+
DiveDetailSectionConfig(id: DiveDetailSectionId.sacSegments, visible: true),
155+
DiveDetailSectionConfig(id: DiveDetailSectionId.details, visible: true),
156+
DiveDetailSectionConfig(id: DiveDetailSectionId.environment, visible: true),
157+
DiveDetailSectionConfig(id: DiveDetailSectionId.altitude, visible: true),
158+
DiveDetailSectionConfig(id: DiveDetailSectionId.tide, visible: true),
159+
DiveDetailSectionConfig(id: DiveDetailSectionId.weights, visible: true),
160+
DiveDetailSectionConfig(id: DiveDetailSectionId.tanks, visible: true),
161+
DiveDetailSectionConfig(id: DiveDetailSectionId.buddies, visible: true),
162+
DiveDetailSectionConfig(id: DiveDetailSectionId.signatures, visible: true),
163+
DiveDetailSectionConfig(id: DiveDetailSectionId.equipment, visible: true),
164+
DiveDetailSectionConfig(id: DiveDetailSectionId.sightings, visible: true),
165+
DiveDetailSectionConfig(id: DiveDetailSectionId.media, visible: true),
166+
DiveDetailSectionConfig(id: DiveDetailSectionId.tags, visible: true),
167+
DiveDetailSectionConfig(id: DiveDetailSectionId.notes, visible: true),
168+
DiveDetailSectionConfig(
169+
id: DiveDetailSectionId.customFields,
170+
visible: true,
171+
),
172+
DiveDetailSectionConfig(id: DiveDetailSectionId.dataSources, visible: true),
173+
];
174+
175+
static String sectionsToJson(List<DiveDetailSectionConfig> sections) {
176+
return jsonEncode(sections.map((s) => s.toJson()).toList());
177+
}
178+
179+
static List<DiveDetailSectionConfig> sectionsFromJson(String? json) {
180+
if (json == null || json.isEmpty) return List.of(defaultSections);
181+
try {
182+
final decoded = jsonDecode(json) as List;
183+
final sections = decoded
184+
.map((e) => e is Map<String, dynamic> ? tryFromJson(e) : null)
185+
.whereType<DiveDetailSectionConfig>()
186+
.toList();
187+
if (sections.isEmpty) return List.of(defaultSections);
188+
return ensureAllSections(sections);
189+
} catch (_) {
190+
return List.of(defaultSections);
191+
}
192+
}
193+
194+
static List<DiveDetailSectionConfig> ensureAllSections(
195+
List<DiveDetailSectionConfig> sections,
196+
) {
197+
final presentIds = sections.map((s) => s.id).toSet();
198+
final missing = DiveDetailSectionId.values
199+
.where((id) => !presentIds.contains(id))
200+
.map((id) => DiveDetailSectionConfig(id: id, visible: true));
201+
if (missing.isEmpty) return sections;
202+
return [...sections, ...missing];
203+
}
204+
}

lib/core/constants/sort_options.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ enum DiveSortField {
1818
date('Date', Icons.calendar_today),
1919
site('Site', Icons.place),
2020
depth('Max Depth', Icons.vertical_align_bottom),
21-
duration('Duration', Icons.timer),
21+
bottomTime('Bottom Time', Icons.timer),
2222
rating('Rating', Icons.star),
2323
diveNumber('Dive Number', Icons.tag);
2424

lib/core/database/database.dart

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ class Dives extends Table {
112112
integer().nullable()(); // Unix timestamp - when diver entered water
113113
IntColumn get exitTime =>
114114
integer().nullable()(); // Unix timestamp - when diver exited water
115-
IntColumn get duration => integer().nullable()(); // seconds (bottom time)
115+
IntColumn get bottomTime => integer().nullable()(); // seconds (bottom time)
116116
IntColumn get runtime => integer().nullable()(); // seconds (total runtime)
117117
RealColumn get maxDepth => real().nullable()();
118118
RealColumn get avgDepth => real().nullable()();
@@ -717,6 +717,8 @@ class DiverSettings extends Table {
717717
// Data source badge visibility (v55)
718718
BoolColumn get showDataSourceBadges =>
719719
boolean().withDefault(const Constant(true))();
720+
// Dive detail section order and visibility (v56) — JSON array
721+
TextColumn get diveDetailSections => text().nullable()();
720722
IntColumn get createdAt => integer()();
721723
IntColumn get updatedAt => integer()();
722724

@@ -1238,7 +1240,7 @@ class AppDatabase extends _$AppDatabase {
12381240

12391241
/// The current schema version as a static constant so that pre-open checks
12401242
/// (e.g. version-mismatch guard) can reference it without an instance.
1241-
static const int currentSchemaVersion = 55;
1243+
static const int currentSchemaVersion = 57;
12421244

12431245
@override
12441246
int get schemaVersion => currentSchemaVersion;
@@ -2470,6 +2472,17 @@ class AppDatabase extends _$AppDatabase {
24702472
'ALTER TABLE diver_settings ADD COLUMN show_data_source_badges INTEGER NOT NULL DEFAULT 1',
24712473
);
24722474
}
2475+
if (from < 56) {
2476+
await m.database.customStatement(
2477+
'ALTER TABLE dives RENAME COLUMN duration TO bottom_time',
2478+
);
2479+
}
2480+
if (from < 57) {
2481+
// Add dive detail section configuration column to diver_settings
2482+
await customStatement(
2483+
'ALTER TABLE diver_settings ADD COLUMN dive_detail_sections TEXT',
2484+
);
2485+
}
24732486
},
24742487
beforeOpen: (details) async {
24752488
// Enable foreign keys

lib/core/router/app_router.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ import 'package:submersion/features/settings/presentation/pages/fix_dive_times_p
7373
import 'package:submersion/features/settings/presentation/pages/settings_page.dart';
7474
import 'package:submersion/features/settings/presentation/pages/appearance_page.dart';
7575
import 'package:submersion/features/settings/presentation/pages/default_visible_metrics_page.dart';
76+
import 'package:submersion/features/settings/presentation/pages/dive_detail_sections_page.dart';
7677
import 'package:submersion/features/settings/presentation/pages/language_settings_page.dart';
7778
import 'package:submersion/features/settings/presentation/pages/theme_gallery_page.dart';
7879
import 'package:submersion/features/settings/presentation/pages/storage_settings_page.dart';
@@ -733,6 +734,11 @@ final appRouterProvider = Provider<GoRouter>((ref) {
733734
name: 'appearance',
734735
builder: (context, state) => const AppearancePage(),
735736
),
737+
GoRoute(
738+
path: 'dive-detail-sections',
739+
name: 'diveDetailSections',
740+
builder: (context, state) => const DiveDetailSectionsPage(),
741+
),
736742
GoRoute(
737743
path: 'default-metrics',
738744
name: 'defaultMetrics',

lib/core/services/export/csv/csv_export_service.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ class CsvExportService {
159159
dive.site?.locationString ?? '',
160160
dive.maxDepth?.toStringAsFixed(1) ?? '',
161161
dive.avgDepth?.toStringAsFixed(1) ?? '',
162-
dive.duration?.inMinutes ?? '',
162+
dive.bottomTime?.inMinutes ?? '',
163163
dive.runtime?.inMinutes ?? '',
164164
dive.waterTemp?.toStringAsFixed(0) ?? '',
165165
dive.airTemp?.toStringAsFixed(0) ?? '',

lib/core/services/export/excel/excel_export_service.dart

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ class ExcelExportService {
207207
dive.site?.locationString ?? '',
208208
convertDepth(dive.maxDepth, depthUnit),
209209
convertDepth(dive.avgDepth, depthUnit),
210-
dive.duration?.inMinutes ?? '',
210+
dive.bottomTime?.inMinutes ?? '',
211211
dive.runtime?.inMinutes ?? '',
212212
convertTemperature(dive.waterTemp, temperatureUnit),
213213
convertTemperature(dive.airTemp, temperatureUnit),
@@ -437,7 +437,7 @@ class ExcelExportService {
437437
if (dives.isNotEmpty) {
438438
final totalMinutes = dives.fold<int>(
439439
0,
440-
(sum, d) => sum + (d.duration?.inMinutes ?? 0),
440+
(sum, d) => sum + (d.bottomTime?.inMinutes ?? 0),
441441
);
442442
final hours = totalMinutes ~/ 60;
443443
final mins = totalMinutes % 60;
@@ -458,10 +458,12 @@ class ExcelExportService {
458458

459459
final longestDive = dives.reduce(
460460
(a, b) =>
461-
(a.duration?.inMinutes ?? 0) > (b.duration?.inMinutes ?? 0) ? a : b,
461+
(a.bottomTime?.inMinutes ?? 0) > (b.bottomTime?.inMinutes ?? 0)
462+
? a
463+
: b,
462464
);
463-
if (longestDive.duration != null) {
464-
addStat('Longest Dive', '${longestDive.duration!.inMinutes} min');
465+
if (longestDive.bottomTime != null) {
466+
addStat('Longest Dive', '${longestDive.bottomTime!.inMinutes} min');
465467
}
466468

467469
final divesWithTemp = dives.where((d) => d.waterTemp != null).toList();
@@ -496,7 +498,7 @@ class ExcelExportService {
496498
final yearDives = divesByYear[year]!;
497499
final yearMinutes = yearDives.fold<int>(
498500
0,
499-
(sum, d) => sum + (d.duration?.inMinutes ?? 0),
501+
(sum, d) => sum + (d.bottomTime?.inMinutes ?? 0),
500502
);
501503
final yearHours = yearMinutes ~/ 60;
502504
final yearMins = yearMinutes % 60;

lib/core/services/export/kml/kml_export_service.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -271,8 +271,8 @@ class KmlExportService {
271271
final depth = dive.maxDepth != null
272272
? '${convertDepth(dive.maxDepth, depthUnit)}${depthUnit.symbol}'
273273
: '?';
274-
final duration = dive.duration != null
275-
? '${dive.duration!.inMinutes}min'
274+
final duration = dive.bottomTime != null
275+
? '${dive.bottomTime!.inMinutes}min'
276276
: '?';
277277
buffer.writeln('<li><b>$dateStr</b> - $depth, $duration</li>');
278278
}

0 commit comments

Comments
 (0)