Skip to content

Commit 5eeab3c

Browse files
committed
Merge branch 'main' into fix-sac-date-format
2 parents fa6fa24 + dc29e37 commit 5eeab3c

139 files changed

Lines changed: 29899 additions & 5900 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.

docs/superpowers/plans/2026-04-28-media-source-extension-phase3a.md

Lines changed: 1470 additions & 0 deletions
Large diffs are not rendered by default.

docs/superpowers/plans/2026-04-28-media-source-extension-phase3b.md

Lines changed: 4086 additions & 0 deletions
Large diffs are not rendered by default.

docs/superpowers/plans/2026-04-28-media-source-extension-phase3c.md

Lines changed: 3224 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# Submersion Manifest — JSON v1
2+
3+
A small JSON-shaped feed format that Submersion can subscribe to. Pair with
4+
the dive-photo workflow: paste the manifest URL into the photo picker's URL
5+
tab → Manifest mode, optionally subscribe, and Submersion will keep your
6+
dive photos in sync as the feed grows.
7+
8+
## Top-level shape
9+
10+
```json
11+
{
12+
"version": 1,
13+
"title": "Eric's Dive Photos",
14+
"items": [ /* 0 or more entries */ ]
15+
}
16+
```
17+
18+
Required: `version` (must be `1`) and `items` (array; may be empty).
19+
Optional: `title` (string).
20+
21+
Unknown top-level fields are ignored — readers should be tolerant.
22+
23+
## Item shape
24+
25+
```json
26+
{
27+
"id": "dive-2024-04-12-img-001",
28+
"url": "https://photos.example.com/dive-001.jpg",
29+
"thumbnailUrl": "https://photos.example.com/dive-001-thumb.jpg",
30+
"takenAt": "2024-04-12T14:32:00Z",
31+
"caption": "Yellowtail at the swim-through",
32+
"mediaType": "photo",
33+
"lat": 25.123,
34+
"lon": -80.456,
35+
"width": 4032,
36+
"height": 3024,
37+
"durationSeconds": null
38+
}
39+
```
40+
41+
### Required item fields
42+
43+
| Field | Type | Notes |
44+
|---|---|---|
45+
| `url` | string | Direct URL to media bytes. HTTP(S) only. |
46+
47+
### Optional item fields
48+
49+
| Field | Type | Notes |
50+
|---|---|---|
51+
| `id` | string | Stable identifier. If omitted, Submersion derives `SHA-256(url + takenAt ?? '')` truncated to 32 hex chars. |
52+
| `takenAt` | RFC 3339 timestamp | If no offset is given, interpreted as UTC. |
53+
| `caption` | string | Free-form. Stored as `MediaItem.caption`. |
54+
| `thumbnailUrl` | string | Used for fast list previews. |
55+
| `mediaType` | `"photo"` or `"video"` | Hint; readers may still re-derive from `Content-Type`. |
56+
| `lat` | number | Decimal degrees. |
57+
| `lon` | number | Decimal degrees. |
58+
| `width` | integer | Pixels. |
59+
| `height` | integer | Pixels. |
60+
| `durationSeconds` | integer | For videos. |
61+
62+
Unknown item fields are ignored.
63+
64+
## Stable identity rules
65+
66+
The `(subscriptionId, id)` pair is the stable key Submersion uses to detect
67+
new vs. removed vs. changed entries on subsequent polls. **Never reuse an
68+
`id` for a different photo**, and don't let it change across polls — both
69+
will produce duplicate or orphaned rows.
70+
71+
## Polling expectations
72+
73+
Submersion polls subscriptions at most once per `pollIntervalSeconds / 4`
74+
(or once per hour, whichever is smaller). Servers should support
75+
conditional GET (`ETag` and/or `Last-Modified`) to keep traffic minimal.
76+
77+
## Minimum viable example
78+
79+
```json
80+
{
81+
"version": 1,
82+
"items": [
83+
{ "url": "https://example.com/a.jpg" },
84+
{ "url": "https://example.com/b.jpg" }
85+
]
86+
}
87+
```
88+
89+
This is valid: each entry will receive a SHA-derived `id`, and the
90+
`takenAt` fields will be filled in from EXIF after the eager fetch pipeline
91+
runs.

lib/core/router/app_router.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ import 'package:submersion/features/settings/presentation/pages/insurance_edit_p
8989
import 'package:submersion/features/settings/presentation/pages/notes_edit_page.dart';
9090
import 'package:submersion/features/settings/presentation/pages/debug_log_viewer_page.dart';
9191
import 'package:submersion/features/media/presentation/pages/media_sources_page.dart';
92+
import 'package:submersion/features/media/presentation/pages/network_sources_page.dart';
9293
import 'package:submersion/features/settings/presentation/pages/section_appearance_page.dart';
9394
import 'package:submersion/features/transfer/presentation/pages/transfer_page.dart';
9495
import 'package:submersion/features/dive_types/presentation/pages/dive_types_page.dart';
@@ -859,6 +860,13 @@ final appRouterProvider = Provider<GoRouter>((ref) {
859860
path: 'media-sources',
860861
name: 'mediaSources',
861862
builder: (context, state) => const MediaSourcesPage(),
863+
routes: [
864+
GoRoute(
865+
path: 'network-sources',
866+
name: 'networkSources',
867+
builder: (context, state) => const NetworkSourcesPage(),
868+
),
869+
],
862870
),
863871
GoRoute(
864872
path: 'diver-profile',

lib/core/util/wall_clock_utc.dart

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/// Wall-clock-UTC date utilities.
2+
///
3+
/// Submersion treats dive-computer-style timestamps as wall-clock-UTC: the
4+
/// digits the user sees on their dive computer face are stored verbatim as
5+
/// the components of a `DateTime.utc(...)`. This file centralizes the two
6+
/// flavors of "convert an external timestamp into wall-clock-UTC" used
7+
/// throughout the media import pipeline:
8+
///
9+
/// * [parseExternalDateAsWallClockUtc] — parse an ISO-8601-ish string,
10+
/// honoring an explicit zone designator if present and otherwise
11+
/// reinterpreting offset-less wall-clock components as UTC.
12+
/// * [asWallClockUtc] — reinterpret a local `DateTime`'s components as
13+
/// UTC verbatim, used for filesystem mtimes that arrive in local time
14+
/// but should match wall-clock-UTC dive times.
15+
library;
16+
17+
/// Matches a trailing `Z` or `+hh:mm` / `-hh:mm` / `+hhmm` / `-hhmm` offset
18+
/// on an ISO-8601 timestamp. Used to detect "no offset given" so we can
19+
/// reinterpret as UTC rather than shifting from local time.
20+
final RegExp _isoOffset = RegExp(r'(Z|[+\-]\d{2}:?\d{2})$');
21+
22+
/// Parses an ISO-8601-ish date string and returns it as a UTC `DateTime`,
23+
/// applying the codebase's wall-clock-as-UTC convention.
24+
///
25+
/// If [raw] carries a timezone designator (Z or ±HH:MM / ±HHMM), the
26+
/// returned DateTime represents the same absolute moment in UTC
27+
/// (`DateTime.parse(raw).toUtc()`).
28+
///
29+
/// If [raw] lacks a timezone designator, the wall-clock components are
30+
/// REINTERPRETED as UTC — i.e. `"2024-04-12T14:32:00"` returns
31+
/// `DateTime.utc(2024, 4, 12, 14, 32, 0)`. This matches how dive
32+
/// computers store time (no timezone metadata; the digits ARE the dive's
33+
/// wall clock) and how Submersion persists `takenAt` for matching.
34+
///
35+
/// Returns null when the string is unparseable.
36+
DateTime? parseExternalDateAsWallClockUtc(String raw) {
37+
final parsed = DateTime.tryParse(raw);
38+
if (parsed == null) return null;
39+
if (parsed.isUtc) return parsed;
40+
if (_isoOffset.hasMatch(raw)) return parsed.toUtc();
41+
return DateTime.utc(
42+
parsed.year,
43+
parsed.month,
44+
parsed.day,
45+
parsed.hour,
46+
parsed.minute,
47+
parsed.second,
48+
parsed.millisecond,
49+
);
50+
}
51+
52+
/// Reinterprets a local `DateTime`'s wall-clock components as UTC.
53+
///
54+
/// Used for filesystem mtimes (`File.lastModifiedSync()` returns local)
55+
/// when we want to treat the digits as wall-clock-UTC for matching.
56+
/// Example: an mtime of `2024-04-12 14:32 EDT` becomes
57+
/// `DateTime.utc(2024, 4, 12, 14, 32, 0)` — NOT shifted by the offset.
58+
DateTime asWallClockUtc(DateTime local) => DateTime.utc(
59+
local.year,
60+
local.month,
61+
local.day,
62+
local.hour,
63+
local.minute,
64+
local.second,
65+
local.millisecond,
66+
);

lib/features/dive_log/data/repositories/dive_repository_impl.dart

Lines changed: 99 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -753,6 +753,21 @@ class DiveRepository {
753753
Future<void> updateDive(domain.Dive dive) async {
754754
try {
755755
_log.info('Updating dive: ${dive.id}');
756+
757+
// Validate that all tanks have non-empty IDs on update to prevent data loss
758+
// (If a tank ID is empty, it would generate a new UUID and cause the old tank to be deleted)
759+
final emptyTankIndices = dive.tanks
760+
.asMap()
761+
.entries
762+
.where((e) => e.value.id.isEmpty)
763+
.map((e) => e.key);
764+
if (emptyTankIndices.isNotEmpty) {
765+
throw ArgumentError(
766+
'Cannot update dive: tank(s) at index(es) ${emptyTankIndices.join(', ')} '
767+
'have empty IDs. All tanks must have valid IDs when updating a dive.',
768+
);
769+
}
770+
756771
final now = DateTime.now().millisecondsSinceEpoch;
757772

758773
await (_db.update(_db.dives)..where((t) => t.id.equals(dive.id))).write(
@@ -848,44 +863,90 @@ class DiveRepository {
848863
localUpdatedAt: now,
849864
);
850865

851-
// Update tanks: delete and re-insert
852-
final existingTanks = await (_db.select(
866+
// Update tanks:
867+
// Try to match existing tanks by ID to do updates instead of delete+insert when possible,
868+
// to preserve sync records and avoid unnecessary deletions/insertions in the sync engine.
869+
// Delete tanks that are no longer present.
870+
// This is more complex but should result in better sync behavior and performance when tanks
871+
// are edited but not completely changed (which is more likely).
872+
873+
// Get existing tank IDs for this dive
874+
final existingTankRows = await (_db.select(
853875
_db.diveTanks,
854876
)..where((t) => t.diveId.equals(dive.id))).get();
855-
await (_db.delete(
856-
_db.diveTanks,
857-
)..where((t) => t.diveId.equals(dive.id))).go();
858-
for (final tank in existingTanks) {
859-
await _syncRepository.logDeletion(
860-
entityType: 'diveTanks',
861-
recordId: tank.id,
862-
);
863-
}
877+
final existingTankIds = existingTankRows.map((t) => t.id).toSet();
878+
final updatedTankIds = <String>{};
879+
880+
// Update or insert tanks
864881
for (final tank in dive.tanks) {
865-
final tankId = tank.id.isNotEmpty ? tank.id : _uuid.v4();
866-
await _db
867-
.into(_db.diveTanks)
868-
.insert(
869-
DiveTanksCompanion(
870-
id: Value(tankId),
871-
diveId: Value(dive.id),
872-
volume: Value(tank.volume),
873-
workingPressure: Value(tank.workingPressure),
874-
startPressure: Value(tank.startPressure),
875-
endPressure: Value(tank.endPressure),
876-
o2Percent: Value(tank.gasMix.o2),
877-
hePercent: Value(tank.gasMix.he),
878-
tankOrder: Value(tank.order),
879-
tankRole: Value(tank.role.name),
880-
tankMaterial: Value(tank.material?.name),
881-
tankName: Value(tank.name),
882-
presetName: Value(tank.presetName),
883-
),
884-
);
885-
await _syncRepository.markRecordPending(
882+
// Tank ID is guaranteed to be non-empty by validation at start of method
883+
final tankId = tank.id;
884+
updatedTankIds.add(tankId);
885+
886+
if (existingTankIds.contains(tankId)) {
887+
// Update existing tank
888+
await (_db.update(
889+
_db.diveTanks,
890+
)..where((t) => t.id.equals(tankId))).write(
891+
DiveTanksCompanion(
892+
volume: Value(tank.volume),
893+
workingPressure: Value(tank.workingPressure),
894+
startPressure: Value(tank.startPressure),
895+
endPressure: Value(tank.endPressure),
896+
o2Percent: Value(tank.gasMix.o2),
897+
hePercent: Value(tank.gasMix.he),
898+
tankOrder: Value(tank.order),
899+
tankRole: Value(tank.role.name),
900+
tankMaterial: Value(tank.material?.name),
901+
tankName: Value(tank.name),
902+
presetName: Value(tank.presetName),
903+
),
904+
);
905+
// Log as pending update (assuming sync handles updates)
906+
await _syncRepository.markRecordPending(
907+
entityType: 'diveTanks',
908+
recordId: tankId,
909+
localUpdatedAt: now,
910+
);
911+
} else {
912+
// Insert new tank
913+
await _db
914+
.into(_db.diveTanks)
915+
.insert(
916+
DiveTanksCompanion(
917+
id: Value(tankId),
918+
diveId: Value(dive.id),
919+
volume: Value(tank.volume),
920+
workingPressure: Value(tank.workingPressure),
921+
startPressure: Value(tank.startPressure),
922+
endPressure: Value(tank.endPressure),
923+
o2Percent: Value(tank.gasMix.o2),
924+
hePercent: Value(tank.gasMix.he),
925+
tankOrder: Value(tank.order),
926+
tankRole: Value(tank.role.name),
927+
tankMaterial: Value(tank.material?.name),
928+
tankName: Value(tank.name),
929+
presetName: Value(tank.presetName),
930+
),
931+
);
932+
await _syncRepository.markRecordPending(
933+
entityType: 'diveTanks',
934+
recordId: tankId,
935+
localUpdatedAt: now,
936+
);
937+
}
938+
}
939+
940+
// Delete tanks that are no longer present
941+
// This will cascade to both tank_pressure_profiles and gas_switches for removed tanks
942+
final tanksToDelete = existingTankIds.difference(updatedTankIds);
943+
for (final tankId in tanksToDelete) {
944+
await (_db.delete(
945+
_db.diveTanks,
946+
)..where((t) => t.id.equals(tankId))).go();
947+
await _syncRepository.logDeletion(
886948
entityType: 'diveTanks',
887949
recordId: tankId,
888-
localUpdatedAt: now,
889950
);
890951
}
891952

@@ -2446,9 +2507,9 @@ class DiveRepository {
24462507
: null,
24472508
tanks: tankRows.map((t) {
24482509
// Derive start/end pressure from profile data when available.
2449-
// Profile time-series from AI transmitters is the authoritative
2450-
// source, preferred over stored values (which may be stale or
2451-
// defaulted from a tank preset's working pressure).
2510+
// Profile time-series from AI transmitters is the fallback
2511+
// source, if values entered by the user are not available in the
2512+
// dive tanks table.
24522513
final profilePoints = tankPressuresByTankId[t.id];
24532514
final profileStartPressure =
24542515
profilePoints != null && profilePoints.isNotEmpty
@@ -2464,8 +2525,8 @@ class DiveRepository {
24642525
name: t.tankName,
24652526
volume: t.volume,
24662527
workingPressure: t.workingPressure,
2467-
startPressure: profileStartPressure ?? t.startPressure,
2468-
endPressure: profileEndPressure ?? t.endPressure,
2528+
startPressure: t.startPressure ?? profileStartPressure,
2529+
endPressure: t.endPressure ?? profileEndPressure,
24692530
gasMix: domain.GasMix(o2: t.o2Percent, he: t.hePercent),
24702531
role: TankRole.values.firstWhere(
24712532
(r) => r.name == t.tankRole,

0 commit comments

Comments
 (0)