Skip to content

Commit 0c6f970

Browse files
authored
Merge branch 'main' into fix-sac-by-tank-role
2 parents e1936c4 + 259c05f commit 0c6f970

93 files changed

Lines changed: 22214 additions & 63 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+
);
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// Adapted from plan
2+
// `docs/superpowers/plans/2026-04-28-media-source-extension-phase3a.md`
3+
// Task 16. Constants for the network media cache caps surfaced as
4+
// `cached_network_image` (memory) + `flutter_cache_manager` (disk) limits.
5+
//
6+
// Phase 3c will surface these as user-editable Settings; for Phase 3a
7+
// they live as `const`s here and are applied at app boot via
8+
// [applyMediaCacheCaps].
9+
//
10+
// Deviations from the plan:
11+
//
12+
// - The plan asks for "500 MB disk + 75 MB memory caps" configured via
13+
// `DefaultCacheManager`-style configuration. `flutter_cache_manager`
14+
// 3.4.1 only exposes `maxNrOfCacheObjects` (object count) on its `Config`
15+
// class — there is no public API to express a byte-size cap on the disk
16+
// cache, and `DefaultCacheManager` is a singleton constructed with the
17+
// default `Config` we cannot mutate after the fact. The byte cap is
18+
// therefore declared as a constant for now and not wired to disk in 3a;
19+
// Phase 3c is expected to introduce a custom `BaseCacheManager` subclass
20+
// passed via `CachedNetworkImage(cacheManager: ...)` that honours the
21+
// byte budget directly. See `kDiskCacheCapBytes` doc comment.
22+
// - Memory cap is wired today via [PaintingBinding.instance.imageCache]
23+
// because `cached_network_image` resolves through the global Flutter
24+
// image cache, so the byte cap is honoured immediately for in-RAM
25+
// decoded images.
26+
27+
import 'package:flutter/painting.dart';
28+
29+
/// Target on-disk LRU budget for cached remote media (500 MB).
30+
///
31+
/// Phase 3a: declarative only. `flutter_cache_manager` 3.4.1 only exposes
32+
/// an object-count cap (`maxNrOfCacheObjects`); a real byte budget needs
33+
/// a custom `BaseCacheManager` subclass which Phase 3c will introduce
34+
/// alongside the Settings UI for adjusting these caps.
35+
const int kDiskCacheCapBytes = 500 * 1024 * 1024;
36+
37+
/// Live in-memory decoded-image budget (75 MB), applied to
38+
/// [PaintingBinding.imageCache] at app boot via [applyMediaCacheCaps].
39+
const int kMemoryCacheCapBytes = 75 * 1024 * 1024;
40+
41+
/// Heuristic upper bound on the number of decoded images held in memory
42+
/// at once. The Flutter image cache enforces both this object count
43+
/// *and* [kMemoryCacheCapBytes]; the count guard exists so a stream of
44+
/// tiny thumbnails cannot blow past expectations even though each frame
45+
/// is well under the byte cap.
46+
const int kMemoryCacheCapObjects = 200;
47+
48+
/// Applies [kMemoryCacheCapBytes] / [kMemoryCacheCapObjects] to the
49+
/// global Flutter [PaintingBinding.imageCache] at app boot.
50+
///
51+
/// Disk-side caps ([kDiskCacheCapBytes]) are *not* wired here — see the
52+
/// constant's doc comment. Idempotent; safe to call multiple times.
53+
//
54+
// Configures global Flutter image cache; exercised at app boot by
55+
// `MediaSourcesApp.bootstrap()` in Phase 3c.
56+
// coverage:ignore-start
57+
void applyMediaCacheCaps() {
58+
final cache = PaintingBinding.instance.imageCache;
59+
cache.maximumSizeBytes = kMemoryCacheCapBytes;
60+
cache.maximumSize = kMemoryCacheCapObjects;
61+
}
62+
63+
// coverage:ignore-end

0 commit comments

Comments
 (0)